chore(ticket): incorporate integrated Drive and structured WIP contracts
# Conflicts: # openapi/server-api.json # web/workspace/src/lib/generated/companion-api.ts # web/workspace/src/lib/generated/repository-api.ts # web/workspace/src/lib/generated/runtime-api.ts # web/workspace/src/lib/generated/ticket-api.ts # web/workspace/src/lib/generated/worker-launch-api.ts
This commit is contained in:
@@ -54,6 +54,7 @@ base64 = { workspace = true }
|
||||
wip-client = { workspace = true }
|
||||
wip-http = { workspace = true }
|
||||
wip-protocol = { workspace = true }
|
||||
wip-text-view = { workspace = true }
|
||||
|
||||
[dev-dependencies]
|
||||
axum = { workspace = true }
|
||||
|
||||
@@ -90,7 +90,7 @@ manifests of already-running Workers.
|
||||
|
||||
Public integration seams: `WorkspaceConfigFeature::for_workspace`,
|
||||
`workspace_config::wip::mount_workspace_config_wip`, and
|
||||
`WipRuntime::from_mounts` plus `discover`/`inspect`/`call`. Worker tests exercise the
|
||||
`WipRuntime::from_mounts` plus `tree`/`inspect`/`invoke`. Worker tests exercise the
|
||||
real Feature registry, native mount and WIP Client/Host through a typed in-process
|
||||
contract router: deep discovery, slash/native attach, read/edit/write,
|
||||
create/delete, atomic multi-source updates, grant restrictions/revocation,
|
||||
|
||||
@@ -23,8 +23,8 @@ use crate::feature::builtin::manage_workdir::wip::{decode_identity, encode_ident
|
||||
use crate::permission::permission_action_for;
|
||||
use crate::wip::{
|
||||
WipCallContext, WipMountError, WipMountRegistry, WipOperationError, WipOperationHandler,
|
||||
WipOperationOutput, WipProjection, WipProjectionKind, WipSubtreeMount, WipSubtreeProvider,
|
||||
json_to_wip, wip_to_json,
|
||||
WipOperationOutput, WipProjection, WipProjectionKind, WipPublication, WipSubtreeMount,
|
||||
WipSubtreeProvider, json_to_wip, wip_to_json,
|
||||
};
|
||||
|
||||
const ROOT: &str = "/checkouts";
|
||||
@@ -146,8 +146,10 @@ impl Provider {
|
||||
kind: &str,
|
||||
handler: Arc<dyn WipOperationHandler>,
|
||||
) -> WipProjection {
|
||||
let encoded: String = path.as_bytes().iter().map(|b| format!("{b:02x}")).collect();
|
||||
let interface = format!("yoi.checkout/{}/{kind}/v1/@/{encoded}", self.incarnation);
|
||||
let interface = crate::wip::contextual_reference(
|
||||
&format!("yoi.checkout/{}/{kind}/v1", self.incarnation),
|
||||
path,
|
||||
);
|
||||
let interface_validator = Sha256::digest(format!("{descriptor:?}")).to_vec();
|
||||
WipProjection {
|
||||
route: path.into(),
|
||||
@@ -286,7 +288,7 @@ impl Provider {
|
||||
|
||||
#[async_trait]
|
||||
impl WipSubtreeProvider for Arc<Provider> {
|
||||
async fn projection(&self, path: &str) -> Result<Option<WipProjection>, ProtocolError> {
|
||||
async fn publication(&self, path: &str) -> Result<Option<WipPublication>, ProtocolError> {
|
||||
let _permit = tokio::time::timeout(self.deadline, self.permits.acquire())
|
||||
.await
|
||||
.map_err(|_| {
|
||||
@@ -304,6 +306,7 @@ impl WipSubtreeProvider for Arc<Provider> {
|
||||
"checkout observation deadline",
|
||||
)
|
||||
})?
|
||||
.and_then(|projection| projection.map(WipPublication::self_scoped).transpose())
|
||||
}
|
||||
async fn children(&self, path: &str) -> Result<Vec<String>, ProtocolError> {
|
||||
let _permit = tokio::time::timeout(self.deadline, self.permits.acquire())
|
||||
|
||||
@@ -320,8 +320,8 @@ fn native_runtime(remote: Arc<RemoteWorkdirSession>) -> (WipRuntime, Arc<Workdir
|
||||
(runtime, router)
|
||||
}
|
||||
|
||||
async fn observe_interface(runtime: &WipRuntime, path: &str) -> String {
|
||||
runtime.discover(path.into(), 0, true).await.unwrap();
|
||||
async fn observe_interface(runtime: &WipRuntime, path: &str) -> wip_protocol::InterfaceReference {
|
||||
runtime.tree(path.into(), 0, true).await.unwrap();
|
||||
// Select from the real Client's Known Space, not a direct handler invocation.
|
||||
let interface = {
|
||||
let state = runtime.state.lock().unwrap();
|
||||
@@ -337,7 +337,7 @@ async fn observe_interface(runtime: &WipRuntime, path: &str) -> String {
|
||||
.interfaces[0]
|
||||
.clone()
|
||||
};
|
||||
runtime.inspect(interface.clone(), true).await.unwrap();
|
||||
runtime.inspect(path.into(), true).await.unwrap();
|
||||
interface
|
||||
}
|
||||
|
||||
|
||||
@@ -33,10 +33,10 @@ fn runtime(
|
||||
)
|
||||
.unwrap()
|
||||
}
|
||||
async fn interface(r: &WipRuntime, path: &str) -> String {
|
||||
r.discover(path.into(), 0, true).await.unwrap();
|
||||
async fn interface(r: &WipRuntime, path: &str) -> wip_protocol::InterfaceReference {
|
||||
r.tree(path.into(), 0, true).await.unwrap();
|
||||
let p = r.host.projection_live(path).await.unwrap().unwrap();
|
||||
r.inspect(p.interface.clone(), true).await.unwrap();
|
||||
r.inspect(path.into(), true).await.unwrap();
|
||||
p.interface
|
||||
}
|
||||
async fn call(
|
||||
|
||||
@@ -101,7 +101,8 @@ pub fn mount_workspace_workdir_wip(
|
||||
for domain in [Domain::Repository, Domain::Workdir, Domain::Attachment] {
|
||||
let namespace =
|
||||
registry.allocate_namespace(domain.owner(), domain.route().trim_start_matches('/'))?;
|
||||
let interface = format!("yoi.{}/collection/v1", domain.owner());
|
||||
let interface =
|
||||
crate::wip::root_reference(&format!("yoi.{}/collection/v1", domain.owner()));
|
||||
let handler = Arc::new(CollectionHandler {
|
||||
provider: provider.clone(),
|
||||
domain,
|
||||
@@ -130,7 +131,7 @@ pub fn mount_workspace_workdir_wip(
|
||||
handler,
|
||||
})?;
|
||||
}
|
||||
let item_interface = format!("yoi.{}/item/v1", domain.owner());
|
||||
let item_interface = crate::wip::root_reference(&format!("yoi.{}/item/v1", domain.owner()));
|
||||
let resolver = Arc::new(ItemResolver {
|
||||
provider: provider.clone(),
|
||||
domain,
|
||||
@@ -668,7 +669,7 @@ impl WipDynamicItemResolver for ItemResolver {
|
||||
}
|
||||
let raw = self.provider.raw_item(self.domain, &id).ok()?;
|
||||
let projected = self.provider.projected(self.domain, &raw).ok()?;
|
||||
let interface = format!("yoi.{}/item/v1", self.domain.owner());
|
||||
let interface = crate::wip::root_reference(&format!("yoi.{}/item/v1", self.domain.owner()));
|
||||
Some(WipDynamicItem {
|
||||
object: object(&item_path(self.domain, &id), &id, &interface, &projected),
|
||||
handler: Arc::new(ItemHandler {
|
||||
@@ -836,7 +837,12 @@ impl WipOperationHandler for ItemHandler {
|
||||
}
|
||||
}
|
||||
|
||||
fn object(path: &str, identity: &str, interface: &str, value: &Json) -> Object {
|
||||
fn object(
|
||||
path: &str,
|
||||
identity: &str,
|
||||
interface: &wip_protocol::InterfaceReference,
|
||||
value: &Json,
|
||||
) -> Object {
|
||||
let mut digest = Sha256::new();
|
||||
digest.update(path);
|
||||
digest.update([0]);
|
||||
@@ -844,7 +850,7 @@ fn object(path: &str, identity: &str, interface: &str, value: &Json) -> Object {
|
||||
Object {
|
||||
name: path.rsplit('/').next().unwrap_or_default().into(),
|
||||
description: Some(format!("Workspace authority Object: {identity}")),
|
||||
interfaces: vec![interface.into()],
|
||||
interfaces: vec![interface.clone()],
|
||||
r#ref: Some(identity.into()),
|
||||
validator: Some(digest.finalize().to_vec()),
|
||||
}
|
||||
@@ -1658,7 +1664,7 @@ pub(crate) mod tests {
|
||||
registry.contribute_operations(WipOperationContribution {
|
||||
route: WORKDIRS.into(),
|
||||
contributor: "another-feature".into(),
|
||||
interface: "yoi.workdir/collection/v1".into(),
|
||||
interface: crate::wip::root_reference("yoi.workdir/collection/v1"),
|
||||
descriptor: collection_descriptor(Domain::Workdir, true),
|
||||
handler: Arc::new(CollectionHandler {
|
||||
provider: provider(Arc::new(CatalogClient::default()), true, true, None),
|
||||
|
||||
@@ -558,14 +558,16 @@ pub fn mount_workspace_http_merge_request_wip(
|
||||
description: Some(
|
||||
"Authoritative Merge Request collection through scoped Backend authority".into(),
|
||||
),
|
||||
interfaces: vec![MERGE_REQUEST_COLLECTION_INTERFACE.into()],
|
||||
interfaces: vec![crate::wip::root_reference(
|
||||
MERGE_REQUEST_COLLECTION_INTERFACE,
|
||||
)],
|
||||
r#ref: Some("merge-request:collection".into()),
|
||||
validator: Some(merge_request_route_validator(
|
||||
&collection_route,
|
||||
"collection",
|
||||
)),
|
||||
},
|
||||
interface: MERGE_REQUEST_COLLECTION_INTERFACE.into(),
|
||||
interface: crate::wip::root_reference(MERGE_REQUEST_COLLECTION_INTERFACE),
|
||||
interface_validator: Some(merge_request_descriptor_validator(&collection_descriptor)),
|
||||
descriptor: collection_descriptor,
|
||||
handler: Arc::new(MergeRequestCollectionWipHandler {
|
||||
@@ -584,7 +586,7 @@ pub fn mount_workspace_http_merge_request_wip(
|
||||
registry.mount_dynamic(WipDynamicMount {
|
||||
collection_route: collection_route.clone(),
|
||||
capability: "merge-request:item".into(),
|
||||
interface: MERGE_REQUEST_ITEM_INTERFACE.into(),
|
||||
interface: crate::wip::root_reference(MERGE_REQUEST_ITEM_INTERFACE),
|
||||
interface_validator: Some(merge_request_descriptor_validator(&item_descriptor)),
|
||||
descriptor: item_descriptor,
|
||||
resolver: Arc::new(MergeRequestItemResolver {
|
||||
@@ -682,7 +684,7 @@ impl WipDynamicItemResolver for MergeRequestItemResolver {
|
||||
object: Object {
|
||||
name: item_reference.into(),
|
||||
description: Some("Authoritative Merge Request bound to this object route".into()),
|
||||
interfaces: vec![MERGE_REQUEST_ITEM_INTERFACE.into()],
|
||||
interfaces: vec![crate::wip::root_reference(MERGE_REQUEST_ITEM_INTERFACE)],
|
||||
r#ref: Some(format!("merge-request:{item_reference}")),
|
||||
validator: Some(merge_request_route_validator(&route, &revision)),
|
||||
},
|
||||
|
||||
@@ -578,11 +578,11 @@ pub fn mount_workspace_http_objective_wip(
|
||||
"Authoritative Objective collection; search and create through Backend authority"
|
||||
.into(),
|
||||
),
|
||||
interfaces: vec![OBJECTIVE_COLLECTION_INTERFACE.into()],
|
||||
interfaces: vec![crate::wip::root_reference(OBJECTIVE_COLLECTION_INTERFACE)],
|
||||
r#ref: Some("objective:collection".into()),
|
||||
validator: Some(route_validator(&collection_route, "collection")),
|
||||
},
|
||||
interface: OBJECTIVE_COLLECTION_INTERFACE.into(),
|
||||
interface: crate::wip::root_reference(OBJECTIVE_COLLECTION_INTERFACE),
|
||||
descriptor: collection_descriptor,
|
||||
interface_validator: Some(collection_validator),
|
||||
handler: collection_handler,
|
||||
@@ -593,7 +593,7 @@ pub fn mount_workspace_http_objective_wip(
|
||||
registry.mount_dynamic(WipDynamicMount {
|
||||
collection_route: collection_route.clone(),
|
||||
capability: "objective:item".into(),
|
||||
interface: OBJECTIVE_ITEM_INTERFACE.into(),
|
||||
interface: crate::wip::root_reference(OBJECTIVE_ITEM_INTERFACE),
|
||||
descriptor: item_descriptor,
|
||||
interface_validator: Some(item_validator),
|
||||
resolver: Arc::new(ObjectiveItemResolver {
|
||||
@@ -744,7 +744,7 @@ impl WipDynamicItemResolver for ObjectiveItemResolver {
|
||||
object: Object {
|
||||
name: item_reference.to_string(),
|
||||
description: Some("Authoritative Objective bound to this object route".into()),
|
||||
interfaces: vec![OBJECTIVE_ITEM_INTERFACE.into()],
|
||||
interfaces: vec![crate::wip::root_reference(OBJECTIVE_ITEM_INTERFACE)],
|
||||
r#ref: Some(format!("objective:{item_reference}")),
|
||||
validator: Some(route_validator(&route, &revision)),
|
||||
},
|
||||
|
||||
@@ -1813,11 +1813,11 @@ pub fn mount_workspace_http_ticket_wip(
|
||||
description: Some(
|
||||
"Authoritative Ticket collection through scoped Backend authority".into(),
|
||||
),
|
||||
interfaces: vec![TICKET_COLLECTION_INTERFACE.into()],
|
||||
interfaces: vec![crate::wip::root_reference(TICKET_COLLECTION_INTERFACE)],
|
||||
r#ref: Some("ticket:collection".into()),
|
||||
validator: Some(route_validator(&collection_route, "collection")),
|
||||
},
|
||||
interface: TICKET_COLLECTION_INTERFACE.into(),
|
||||
interface: crate::wip::root_reference(TICKET_COLLECTION_INTERFACE),
|
||||
interface_validator: Some(descriptor_validator(&collection_descriptor)),
|
||||
descriptor: collection_descriptor,
|
||||
handler: Arc::new(TicketCollectionWipHandler {
|
||||
@@ -1836,7 +1836,7 @@ pub fn mount_workspace_http_ticket_wip(
|
||||
registry.mount_dynamic(WipDynamicMount {
|
||||
collection_route: collection_route.clone(),
|
||||
capability: "ticket:item".into(),
|
||||
interface: TICKET_ITEM_INTERFACE.into(),
|
||||
interface: crate::wip::root_reference(TICKET_ITEM_INTERFACE),
|
||||
interface_validator: Some(descriptor_validator(&item_descriptor)),
|
||||
descriptor: item_descriptor,
|
||||
resolver: Arc::new(TicketItemResolver {
|
||||
@@ -1922,7 +1922,7 @@ impl WipDynamicItemResolver for TicketItemResolver {
|
||||
object: Object {
|
||||
name: item_reference.into(),
|
||||
description: Some("Authoritative Ticket bound to this object route".into()),
|
||||
interfaces: vec![TICKET_ITEM_INTERFACE.into()],
|
||||
interfaces: vec![crate::wip::root_reference(TICKET_ITEM_INTERFACE)],
|
||||
r#ref: Some(format!("ticket:{item_reference}")),
|
||||
validator: Some(route_validator(&route, &revision)),
|
||||
},
|
||||
|
||||
@@ -318,7 +318,7 @@ impl FeatureInvocationHandler for WorkspaceConfigFeature {
|
||||
result.access.as_str()
|
||||
),
|
||||
context: Some(format!(
|
||||
"Workspace configuration is available as logical attachment `{ATTACHMENT_ALIAS}` at WIP `{CONTENT_ROOT}` with {} access. Discover structure through WIP; read and change bodies through published Operations. This attachment grants no OS paths or command execution. Backend validation, concurrency checks and activation rules apply; saving does not imply running Workers adopted new configuration.",
|
||||
"Workspace configuration is available as logical attachment `{ATTACHMENT_ALIAS}` at WIP `{CONTENT_ROOT}` with {} access. Use Tree for indexable structure, Inspect with a known Object path for its complete signatures, and Invoke for published Operations. This attachment grants no OS paths or command execution. Backend validation, concurrency checks and activation rules apply; saving does not imply running Workers adopted new configuration.",
|
||||
result.access.as_str()
|
||||
)),
|
||||
})
|
||||
|
||||
@@ -185,16 +185,8 @@ async fn observe(
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl WipSubtreeProvider for ConfigProvider {
|
||||
fn max_depth(&self) -> u32 {
|
||||
MAX_DEPTH
|
||||
}
|
||||
fn max_nodes(&self) -> usize {
|
||||
MAX_NODES
|
||||
}
|
||||
|
||||
async fn projection(&self, path: &str) -> Result<Option<WipProjection>, ProtocolError> {
|
||||
impl ConfigProvider {
|
||||
async fn projection_at(&self, path: &str) -> Result<Option<WipProjection>, ProtocolError> {
|
||||
let source = source_path(path).map_err(protocol)?;
|
||||
let Some(attachment) = visible_metadata(self.backend.current_async().await)? else {
|
||||
return Ok(None);
|
||||
@@ -205,7 +197,7 @@ impl WipSubtreeProvider for ConfigProvider {
|
||||
return Ok(None);
|
||||
}
|
||||
let mut projection = super::wip::attach_projection(&self.feature);
|
||||
projection.interface = contextual_reference(&projection.interface, path);
|
||||
projection.interface = contextual_reference(&projection.interface.name, path);
|
||||
projection.object.interfaces = vec![projection.interface.clone()];
|
||||
return Ok(Some(projection));
|
||||
};
|
||||
@@ -259,6 +251,26 @@ impl WipSubtreeProvider for ConfigProvider {
|
||||
}),
|
||||
}))
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl WipSubtreeProvider for ConfigProvider {
|
||||
fn max_depth(&self) -> u32 {
|
||||
MAX_DEPTH
|
||||
}
|
||||
fn max_nodes(&self) -> usize {
|
||||
MAX_NODES
|
||||
}
|
||||
|
||||
async fn publication(
|
||||
&self,
|
||||
path: &str,
|
||||
) -> Result<Option<crate::wip::WipPublication>, ProtocolError> {
|
||||
self.projection_at(path)
|
||||
.await?
|
||||
.map(crate::wip::WipPublication::self_scoped)
|
||||
.transpose()
|
||||
}
|
||||
|
||||
async fn children(&self, path: &str) -> Result<Vec<String>, ProtocolError> {
|
||||
let source = source_path(path).map_err(protocol)?;
|
||||
|
||||
@@ -428,7 +428,7 @@ fn runtime(client: Arc<dyn WorkspaceClient>) -> (WorkspaceConfigFeature, WipRunt
|
||||
let runtime = WipRuntime::from_mounts(mounts, "worker-1@workspace-1".into()).unwrap();
|
||||
(feature, runtime)
|
||||
}
|
||||
fn interface(path: &str, attached: bool) -> String {
|
||||
fn interface(path: &str, attached: bool) -> wip_protocol::InterfaceReference {
|
||||
crate::wip::contextual_reference(
|
||||
if attached {
|
||||
"yoi.workspace-config/node/v1"
|
||||
@@ -438,12 +438,9 @@ fn interface(path: &str, attached: bool) -> String {
|
||||
path,
|
||||
)
|
||||
}
|
||||
async fn observe_interface(runtime: &WipRuntime, path: &str, attached: bool) {
|
||||
runtime.discover(path.into(), 0, true).await.unwrap();
|
||||
runtime
|
||||
.inspect(interface(path, attached), true)
|
||||
.await
|
||||
.unwrap();
|
||||
async fn observe_interface(runtime: &WipRuntime, path: &str, _attached: bool) {
|
||||
runtime.tree(path.into(), 0, true).await.unwrap();
|
||||
runtime.inspect(path.into(), true).await.unwrap();
|
||||
}
|
||||
async fn call(
|
||||
runtime: &WipRuntime,
|
||||
@@ -497,7 +494,7 @@ async fn workspace_config_production_slash_deep_discover_read_edit_and_atomic_im
|
||||
.starts_with("Already attached")
|
||||
);
|
||||
let discovered = runtime
|
||||
.discover(CONTENT_ROOT.into(), 5, true)
|
||||
.tree(CONTENT_ROOT.into(), 5, true)
|
||||
.await
|
||||
.unwrap()
|
||||
.content
|
||||
@@ -606,7 +603,7 @@ async fn workspace_config_production_native_attach_create_delete_and_detach_end_
|
||||
);
|
||||
assert!(
|
||||
runtime
|
||||
.discover("/workspace-config/main.dcdl".into(), 0, true)
|
||||
.tree("/workspace-config/main.dcdl".into(), 0, true)
|
||||
.await
|
||||
.is_err()
|
||||
);
|
||||
@@ -633,19 +630,15 @@ async fn workspace_config_production_revocation_ro_cross_workspace_and_cache_iso
|
||||
);
|
||||
observe_interface(&runtime, path, true).await;
|
||||
let descriptor = runtime
|
||||
.inspect(interface(path, true), true)
|
||||
.inspect(path.into(), true)
|
||||
.await
|
||||
.unwrap()
|
||||
.content
|
||||
.unwrap();
|
||||
let descriptor: Json = serde_json::from_str(&descriptor).unwrap();
|
||||
let names = descriptor["descriptor"]["operations"]
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.map(|op| op["name"].as_str().unwrap())
|
||||
.collect::<Vec<_>>();
|
||||
assert_eq!(names, ["read"]);
|
||||
let signature = descriptor["interfaces"][0]["signature"].as_str().unwrap();
|
||||
assert!(signature.contains("operation read("));
|
||||
assert!(!signature.contains("operation write("));
|
||||
assert!(
|
||||
call(&runtime, path, "write", json!({"content":"DENIED"}))
|
||||
.await
|
||||
@@ -657,7 +650,7 @@ async fn workspace_config_production_revocation_ro_cross_workspace_and_cache_iso
|
||||
});
|
||||
let (feature, other) = runtime_for_cross(cross);
|
||||
assert!(feature.attach("cross", None).await.is_err());
|
||||
assert!(other.discover(path.into(), 0, false).await.is_err());
|
||||
assert!(other.tree(path.into(), 0, false).await.is_err());
|
||||
assert!(client.state.lock().unwrap().commits.is_empty());
|
||||
}
|
||||
fn runtime_for_cross(client: Arc<dyn WorkspaceClient>) -> (WorkspaceConfigFeature, WipRuntime) {
|
||||
@@ -760,21 +753,16 @@ async fn workspace_config_production_bounds_and_traversal_reject_before_content_
|
||||
let client = Router::new();
|
||||
let (feature, runtime) = runtime(client.clone());
|
||||
feature.attach("initial", None).await.unwrap();
|
||||
assert!(
|
||||
runtime
|
||||
.discover(CONTENT_ROOT.into(), 9, true)
|
||||
.await
|
||||
.is_err()
|
||||
);
|
||||
assert!(runtime.tree(CONTENT_ROOT.into(), 9, true).await.is_err());
|
||||
for path in [
|
||||
"/workspace-config/../secret",
|
||||
"/workspace-config//main.dcdl",
|
||||
"/workspace-config/./main.dcdl",
|
||||
] {
|
||||
assert!(runtime.discover(path.into(), 0, true).await.is_err());
|
||||
assert!(runtime.tree(path.into(), 0, true).await.is_err());
|
||||
}
|
||||
let path = format!("/workspace-config/{}", "a".repeat(513));
|
||||
assert!(runtime.discover(path, 0, true).await.is_err());
|
||||
assert!(runtime.tree(path, 0, true).await.is_err());
|
||||
assert!(client.state.lock().unwrap().commits.is_empty());
|
||||
}
|
||||
|
||||
@@ -837,7 +825,7 @@ async fn workspace_config_production_cumulative_node_limit_and_edit_growth_are_b
|
||||
// No individual depth-1 response exceeds the cap; the whole Host traversal
|
||||
// must nevertheless stop before exceeding 256 Objects.
|
||||
let error = runtime
|
||||
.discover(CONTENT_ROOT.into(), 4, true)
|
||||
.tree(CONTENT_ROOT.into(), 4, true)
|
||||
.await
|
||||
.unwrap_err();
|
||||
assert!(error.to_string().contains("ResourceLimitExceeded"));
|
||||
@@ -849,7 +837,7 @@ async fn workspace_config_production_backend_failure_is_not_hidden_as_unauthoriz
|
||||
client.state.lock().unwrap().fail_current = true;
|
||||
let (_, runtime) = runtime(client);
|
||||
let error = runtime
|
||||
.discover(CONTENT_ROOT.into(), 0, true)
|
||||
.tree(CONTENT_ROOT.into(), 0, true)
|
||||
.await
|
||||
.unwrap_err();
|
||||
let message = error.to_string();
|
||||
@@ -907,16 +895,11 @@ async fn workspace_config_production_ungranted_root_does_not_poison_worldspace_d
|
||||
let client = Router::new();
|
||||
client.state.lock().unwrap().granted = false;
|
||||
let (_, runtime) = runtime(client.clone());
|
||||
let discovery = runtime.discover("/".into(), 2, true).await.unwrap();
|
||||
let discovery = runtime.tree("/".into(), 2, true).await.unwrap();
|
||||
let discovery: Json = serde_json::from_str(discovery.content.as_deref().unwrap()).unwrap();
|
||||
assert_eq!(discovery["known_space"].as_array().unwrap().len(), 1);
|
||||
assert_eq!(discovery["known_space"][0]["path"], "/");
|
||||
assert!(
|
||||
runtime
|
||||
.discover(CONTENT_ROOT.into(), 0, true)
|
||||
.await
|
||||
.is_err()
|
||||
);
|
||||
assert_eq!(discovery["tree"]["children"], json!([]));
|
||||
assert_eq!(discovery["tree"]["path"], "/");
|
||||
assert!(runtime.tree(CONTENT_ROOT.into(), 0, true).await.is_err());
|
||||
assert!(
|
||||
client
|
||||
.state
|
||||
|
||||
@@ -93,13 +93,13 @@ pub(super) fn attach_projection(feature: &WorkspaceConfigFeature) -> WipProjecti
|
||||
object: Object {
|
||||
name: ATTACHMENT_ALIAS.into(),
|
||||
description: Some("Logical Workspace configuration attachment entrance".into()),
|
||||
interfaces: vec![interface.into()],
|
||||
interfaces: vec![crate::wip::root_reference(interface)],
|
||||
r#ref: None,
|
||||
// Attaching does not modify config content. The content subtree will
|
||||
// supply its own Backend validators for file/directory Operations.
|
||||
validator: None,
|
||||
},
|
||||
interface: interface.into(),
|
||||
interface: crate::wip::root_reference(interface),
|
||||
descriptor,
|
||||
interface_validator,
|
||||
handler: Arc::new(AttachHandler(feature.clone())),
|
||||
|
||||
@@ -1,13 +1,37 @@
|
||||
//! Recovery tests use the normal Worker execution and append-log commit gate.
|
||||
use super::*;
|
||||
use agen::llm_client::event::{Event as LlmEvent, ResponseStatus, StatusEvent};
|
||||
use agen::llm_client::scheme::{Scheme, openai_responses::OpenAIResponsesScheme};
|
||||
use agen::llm_client::{ClientError, Request, ResponseStream};
|
||||
use agen::tool::{Attachment, ImageAttachment};
|
||||
use async_trait::async_trait;
|
||||
|
||||
#[derive(Clone, Copy, Debug, Default)]
|
||||
enum RejectionRoute {
|
||||
#[default]
|
||||
Http,
|
||||
SseError,
|
||||
ResponseFailed,
|
||||
}
|
||||
|
||||
const REJECTION_ROUTES: [RejectionRoute; 3] = [
|
||||
RejectionRoute::Http,
|
||||
RejectionRoute::SseError,
|
||||
RejectionRoute::ResponseFailed,
|
||||
];
|
||||
|
||||
struct RetryObservation {
|
||||
store: session_store::FsStore,
|
||||
session_id: SessionId,
|
||||
segment_id: SegmentId,
|
||||
committed: Option<Vec<LogEntry>>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Default)]
|
||||
struct RejectImages {
|
||||
route: RejectionRoute,
|
||||
requests: Arc<Mutex<Vec<Request>>>,
|
||||
retry_observation: Arc<Mutex<Option<RetryObservation>>>,
|
||||
cancel_on_retry: Arc<Mutex<Option<tokio::sync::mpsc::Sender<()>>>>,
|
||||
}
|
||||
#[async_trait]
|
||||
@@ -21,12 +45,49 @@ impl LlmClient for RejectImages {
|
||||
);
|
||||
self.requests.lock().unwrap().push(request);
|
||||
if has_image {
|
||||
return Err(ClientError::Api {
|
||||
status: Some(400),
|
||||
code: Some("invalid_value".into()),
|
||||
message: "The image requires 52150 patches, exceeding the limit of 30000.".into(),
|
||||
retry_after: None,
|
||||
});
|
||||
if matches!(self.route, RejectionRoute::Http) {
|
||||
return Err(ClientError::Api {
|
||||
status: Some(400),
|
||||
code: Some("invalid_value".into()),
|
||||
message: "The image requires 52150 patches, exceeding the limit of 30000."
|
||||
.into(),
|
||||
retry_after: None,
|
||||
});
|
||||
}
|
||||
// Exercise the public Responses parser, not a hand-built ErrorEvent.
|
||||
// Started is metadata; no generated block precedes this rejection.
|
||||
let scheme = OpenAIResponsesScheme::new();
|
||||
let mut state = Default::default();
|
||||
let mut events = scheme.parse_sse(
|
||||
"response.created",
|
||||
r#"{"response":{"id":"image-response"}}"#,
|
||||
&mut state,
|
||||
)?;
|
||||
let (event_type, data) = match self.route {
|
||||
RejectionRoute::Http => unreachable!(),
|
||||
RejectionRoute::SseError => (
|
||||
"error",
|
||||
r#"{"type":"error","code":"invalid_value","message":"The image requires 52150 patches, exceeding the limit of 30000."}"#,
|
||||
),
|
||||
RejectionRoute::ResponseFailed => (
|
||||
"response.failed",
|
||||
r#"{"type":"response.failed","response":{"id":"image-response","status":"failed","error":{"code":"invalid_value","message":"The image requires 52150 patches, exceeding the limit of 30000."}}}"#,
|
||||
),
|
||||
};
|
||||
events.extend(scheme.parse_sse(event_type, data, &mut state)?);
|
||||
return Ok(Box::pin(futures::stream::iter(events.into_iter().map(Ok))));
|
||||
}
|
||||
// Capture committed filesystem state at the retry boundary, before any
|
||||
// successful output or cancellation can cause a later append.
|
||||
if let Some(observation) = self.retry_observation.lock().unwrap().as_mut()
|
||||
&& observation.committed.is_none()
|
||||
{
|
||||
observation.committed = Some(
|
||||
observation
|
||||
.store
|
||||
.read_all(observation.session_id, observation.segment_id)
|
||||
.unwrap(),
|
||||
);
|
||||
}
|
||||
if let Some(cancel) = self.cancel_on_retry.lock().unwrap().take() {
|
||||
cancel.try_send(()).unwrap();
|
||||
@@ -43,7 +104,9 @@ impl LlmClient for RejectImages {
|
||||
}
|
||||
}
|
||||
|
||||
async fn image_worker() -> (
|
||||
async fn image_worker(
|
||||
route: RejectionRoute,
|
||||
) -> (
|
||||
tempfile::TempDir,
|
||||
Worker<RejectImages, session_store::FsStore>,
|
||||
RejectImages,
|
||||
@@ -63,7 +126,10 @@ allow = []
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
let client = RejectImages::default();
|
||||
let client = RejectImages {
|
||||
route,
|
||||
..RejectImages::default()
|
||||
};
|
||||
let mut worker = Worker::new(
|
||||
manifest,
|
||||
Engine::<_, Mutable, SessionHistoryMetadata>::new_annotated(client.clone()),
|
||||
@@ -90,24 +156,81 @@ allow = []
|
||||
),
|
||||
]);
|
||||
worker.wire_history_persistence();
|
||||
*client.retry_observation.lock().unwrap() = Some(RetryObservation {
|
||||
// Reopen the store so this observes filesystem records, not engine state.
|
||||
store: session_store::FsStore::new(worker.store.root_dir()).unwrap(),
|
||||
session_id: worker.session_id(),
|
||||
segment_id: worker.segment_id(),
|
||||
committed: None,
|
||||
});
|
||||
(dir, worker, client)
|
||||
}
|
||||
fn rejected(item: &Item) -> bool {
|
||||
matches!(item, Item::ToolResult { call_id, is_error: true, attachments, .. } if call_id == "image-call" && attachments.is_empty())
|
||||
}
|
||||
|
||||
fn assert_correction_committed_before_retry(client: &RejectImages) {
|
||||
let observation = client.retry_observation.lock().unwrap();
|
||||
let observation = observation.as_ref().unwrap();
|
||||
let raw = observation
|
||||
.committed
|
||||
.as_ref()
|
||||
.expect("the retry must observe the committed correction");
|
||||
assert_eq!(
|
||||
raw.iter()
|
||||
.filter(|entry| matches!(entry, LogEntry::ToolResultCorrected { .. }))
|
||||
.count(),
|
||||
1,
|
||||
"{:?}: correction must already be appended at retry",
|
||||
client.route
|
||||
);
|
||||
let restored =
|
||||
restore_history_entries(observation.session_id, observation.segment_id, raw).unwrap();
|
||||
let corrected = restored
|
||||
.iter()
|
||||
.find(|entry| rejected(&entry.item))
|
||||
.expect("committed retry history must contain the attachment-free error");
|
||||
let requests = client.requests.lock().unwrap();
|
||||
assert_eq!(requests.len(), 2, "{:?}", client.route);
|
||||
assert!(requests[0].items.iter().any(
|
||||
|item| matches!(item, Item::ToolResult { attachments, .. } if !attachments.is_empty())
|
||||
));
|
||||
assert_eq!(
|
||||
requests[1].items.iter().find(|item| rejected(item)),
|
||||
Some(&corrected.item),
|
||||
"{:?}: retry must use the durable corrected result",
|
||||
client.route
|
||||
);
|
||||
assert!(requests[1].items.iter().all(
|
||||
|item| !matches!(item, Item::ToolResult { attachments, .. } if !attachments.is_empty())
|
||||
));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn image_correction_is_committed_before_retry_and_survives_restore_and_reseed() {
|
||||
let (_dir, mut worker, client) = image_worker().await;
|
||||
for route in REJECTION_ROUTES {
|
||||
assert_image_correction_survives_restore_and_reseed(route).await;
|
||||
}
|
||||
}
|
||||
|
||||
async fn assert_image_correction_survives_restore_and_reseed(route: RejectionRoute) {
|
||||
let (_dir, mut worker, client) = image_worker(route).await;
|
||||
assert_eq!(
|
||||
worker.run_text("continue").await.unwrap(),
|
||||
WorkerRunResult::Finished
|
||||
);
|
||||
assert_eq!(client.requests.lock().unwrap().len(), 2);
|
||||
assert_correction_committed_before_retry(&client);
|
||||
let raw = worker
|
||||
.store
|
||||
.read_all(worker.session_id(), worker.segment_id())
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
raw.iter()
|
||||
.filter(|entry| matches!(entry, LogEntry::ToolResultCorrected { .. }))
|
||||
.count(),
|
||||
1,
|
||||
"{route:?}: recovery must append exactly one correction"
|
||||
);
|
||||
let correction = raw
|
||||
.iter()
|
||||
.find(|entry| matches!(entry, LogEntry::ToolResultCorrected { .. }))
|
||||
@@ -177,8 +300,17 @@ async fn image_correction_is_committed_before_retry_and_survives_restore_and_res
|
||||
forked_from: None,
|
||||
compacted_from: None,
|
||||
};
|
||||
let reseeded_segment_id = session_store::new_segment_id();
|
||||
worker
|
||||
.store
|
||||
.create_segment(worker.session_id(), reseeded_segment_id, &[seed])
|
||||
.unwrap();
|
||||
let reopened_store = session_store::FsStore::new(worker.store.root_dir()).unwrap();
|
||||
let reseeded_raw = reopened_store
|
||||
.read_all(worker.session_id(), reseeded_segment_id)
|
||||
.unwrap();
|
||||
let restored_again =
|
||||
restore_history_entries(worker.session_id(), worker.segment_id(), &[seed]).unwrap();
|
||||
restore_history_entries(worker.session_id(), reseeded_segment_id, &reseeded_raw).unwrap();
|
||||
assert_eq!(
|
||||
restored_again
|
||||
.iter()
|
||||
@@ -186,6 +318,12 @@ async fn image_correction_is_committed_before_retry_and_survives_restore_and_res
|
||||
.count(),
|
||||
1
|
||||
);
|
||||
let effective_correction = restored_again
|
||||
.iter()
|
||||
.find(|entry| rejected(&entry.item))
|
||||
.unwrap();
|
||||
assert_eq!(effective_correction.annotation, original.metadata);
|
||||
let expected_result = effective_correction.item.clone();
|
||||
let mut history = History::from_entries(restored_again);
|
||||
let out = Engine::<_, Mutable, SessionHistoryMetadata>::new_annotated(client.clone())
|
||||
.run_with_annotation(&mut history, "continue after restart", &mut |_| {
|
||||
@@ -193,22 +331,41 @@ async fn image_correction_is_committed_before_retry_and_survives_restore_and_res
|
||||
})
|
||||
.await;
|
||||
assert!(matches!(out.result, EngineRunExit::Finished));
|
||||
assert_eq!(client.requests.lock().unwrap().len(), 3);
|
||||
let requests = client.requests.lock().unwrap();
|
||||
assert_eq!(requests.len(), 3, "{route:?}");
|
||||
assert_eq!(
|
||||
requests[2].items.iter().find(|item| rejected(item)),
|
||||
Some(&expected_result),
|
||||
"{route:?}: next request must retain the restored correction"
|
||||
);
|
||||
assert!(requests[2].items.iter().all(
|
||||
|item| !matches!(item, Item::ToolResult { attachments, .. } if !attachments.is_empty())
|
||||
));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn cancellation_after_image_correction_does_not_rollback_the_correction() {
|
||||
let (_dir, mut worker, client) = image_worker().await;
|
||||
for route in REJECTION_ROUTES {
|
||||
assert_cancellation_preserves_image_correction(route).await;
|
||||
}
|
||||
}
|
||||
|
||||
async fn assert_cancellation_preserves_image_correction(route: RejectionRoute) {
|
||||
let (_dir, mut worker, client) = image_worker(route).await;
|
||||
*client.cancel_on_retry.lock().unwrap() = Some(worker.engine().cancel_sender());
|
||||
let result = worker.run_text("continue").await.unwrap();
|
||||
assert_eq!(result, WorkerRunResult::Cancelled);
|
||||
assert_eq!(result, WorkerRunResult::Cancelled, "{route:?}");
|
||||
assert_correction_committed_before_retry(&client);
|
||||
let raw = worker
|
||||
.store
|
||||
.read_all(worker.session_id(), worker.segment_id())
|
||||
.unwrap();
|
||||
assert!(
|
||||
assert_eq!(
|
||||
raw.iter()
|
||||
.any(|entry| matches!(entry, LogEntry::ToolResultCorrected { .. }))
|
||||
.filter(|entry| matches!(entry, LogEntry::ToolResultCorrected { .. }))
|
||||
.count(),
|
||||
1,
|
||||
"{route:?}: cancellation must preserve exactly one correction"
|
||||
);
|
||||
let restored = restore_history_entries(worker.session_id(), worker.segment_id(), &raw).unwrap();
|
||||
assert_eq!(
|
||||
|
||||
+656
-809
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,540 @@
|
||||
//! Regression coverage for MR P1_CURRENT_ANCESTOR_SCOPE, through real adapters.
|
||||
use super::*;
|
||||
|
||||
struct State {
|
||||
visible: bool,
|
||||
identity: Option<String>,
|
||||
name: String,
|
||||
generation: usize,
|
||||
target_validator: Option<Vec<u8>>,
|
||||
lookups: Vec<String>,
|
||||
}
|
||||
|
||||
struct AncestorProvider {
|
||||
scope_path: String,
|
||||
target_path: String,
|
||||
state: Arc<Mutex<State>>,
|
||||
calls: Arc<AtomicUsize>,
|
||||
blocked: bool,
|
||||
started: Arc<tokio::sync::Notify>,
|
||||
release: Arc<tokio::sync::Notify>,
|
||||
}
|
||||
|
||||
impl AncestorProvider {
|
||||
fn scope_projection(&self, state: &State) -> WipProjection {
|
||||
let mut p = projection(
|
||||
&self.scope_path,
|
||||
&self.scope_path,
|
||||
&state.name,
|
||||
self.calls.clone(),
|
||||
);
|
||||
p.object.r#ref = state.identity.clone();
|
||||
self.version(p, state.generation)
|
||||
}
|
||||
|
||||
fn version(&self, mut p: WipProjection, generation: usize) -> WipProjection {
|
||||
p.descriptor.documentation = Some(Documentation {
|
||||
summary: format!("Published generation {generation}"),
|
||||
details: None,
|
||||
});
|
||||
if self.blocked {
|
||||
p.interface_validator = Some(vec![generation as u8]);
|
||||
if generation != 0 {
|
||||
p.descriptor.operations[0].returns.r#type = TypeExpr::Integer;
|
||||
}
|
||||
p.handler = Arc::new(FrozenResultHandler {
|
||||
generation,
|
||||
started: self.started.clone(),
|
||||
release: self.release.clone(),
|
||||
calls: self.calls.clone(),
|
||||
});
|
||||
}
|
||||
p
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl WipSubtreeProvider for AncestorProvider {
|
||||
async fn publication(&self, path: &str) -> Result<Option<WipPublication>, ProtocolError> {
|
||||
// One lock captures target, scope lifetime, descriptor/validator and
|
||||
// handler. No separate scope lookup can splice new identity onto old data.
|
||||
let mut state = self.state.lock().unwrap();
|
||||
state.lookups.push(path.into());
|
||||
if path == self.scope_path {
|
||||
return if state.visible {
|
||||
WipPublication::self_scoped(self.scope_projection(&state)).map(Some)
|
||||
} else {
|
||||
Ok(None)
|
||||
};
|
||||
}
|
||||
if path == "/github" && self.scope_path != path {
|
||||
return if state.visible {
|
||||
WipPublication::self_scoped(projection(path, path, "root", self.calls.clone()))
|
||||
.map(Some)
|
||||
} else {
|
||||
Ok(None)
|
||||
};
|
||||
}
|
||||
if path != self.target_path {
|
||||
return Ok(None);
|
||||
}
|
||||
let mut target = projection(path, &self.scope_path, &state.name, self.calls.clone());
|
||||
target.object.validator = state.target_validator.clone();
|
||||
let target = self.version(target, state.generation);
|
||||
// Retain a target artifact after deleting the scope to exercise Host
|
||||
// fail-closed behavior, not merely a provider that already hides orphans.
|
||||
let scope = state.visible.then(|| self.scope_projection(&state).object);
|
||||
Ok(Some(WipPublication {
|
||||
projection: target,
|
||||
scope,
|
||||
}))
|
||||
}
|
||||
|
||||
async fn children(&self, path: &str) -> Result<Vec<String>, ProtocolError> {
|
||||
let state = self.state.lock().unwrap();
|
||||
if !state.visible {
|
||||
return Ok(vec![]);
|
||||
}
|
||||
if path == self.scope_path {
|
||||
return Ok(vec![self.target_path.clone()]);
|
||||
}
|
||||
if path == "/github" && path != self.scope_path {
|
||||
return Ok(vec![self.scope_path.clone()]);
|
||||
}
|
||||
Ok(vec![])
|
||||
}
|
||||
}
|
||||
|
||||
fn fixture(scope: &str, with_ref: bool, blocked: bool) -> (WipRuntime, Arc<AncestorProvider>) {
|
||||
let provider = Arc::new(AncestorProvider {
|
||||
scope_path: scope.into(),
|
||||
target_path: if scope == "/" {
|
||||
"/item".into()
|
||||
} else {
|
||||
format!("{scope}/item")
|
||||
},
|
||||
state: Arc::new(Mutex::new(State {
|
||||
visible: true,
|
||||
identity: with_ref.then(|| "live-scope".into()),
|
||||
name: "read".into(),
|
||||
generation: 0,
|
||||
target_validator: None,
|
||||
lookups: vec![],
|
||||
})),
|
||||
calls: Arc::new(AtomicUsize::new(0)),
|
||||
blocked,
|
||||
started: Arc::new(tokio::sync::Notify::new()),
|
||||
release: Arc::new(tokio::sync::Notify::new()),
|
||||
});
|
||||
let mut mounts = WipMountRegistry::new();
|
||||
mounts.allocate_namespace("scope", "github").unwrap();
|
||||
let mount_root = if scope == "/" { "/" } else { "/github" };
|
||||
let mut placeholder = projection(mount_root, mount_root, "read", provider.calls.clone());
|
||||
placeholder.object.r#ref = Some("registration-placeholder".into());
|
||||
mounts.mount(placeholder).unwrap();
|
||||
mounts
|
||||
.mount_subtree(WipSubtreeMount {
|
||||
root: mount_root.into(),
|
||||
provider: provider.clone(),
|
||||
})
|
||||
.unwrap();
|
||||
(
|
||||
WipRuntime::from_mounts(mounts, "ancestor-test".into()).unwrap(),
|
||||
provider,
|
||||
)
|
||||
}
|
||||
|
||||
fn reference(scope: &str, name: &str) -> InterfaceReference {
|
||||
InterfaceReference {
|
||||
scope: scope.into(),
|
||||
name: name.into(),
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn live_root_and_intermediate_ancestors_use_one_current_publication_not_placeholder() {
|
||||
for scope in ["/", "/github", "/github/team"] {
|
||||
for with_ref in [true, false] {
|
||||
let (runtime, provider) = fixture(scope, with_ref, false);
|
||||
let selected = reference(scope, "read");
|
||||
let fetched = runtime.host.fetch_interface_live(&selected).await.unwrap();
|
||||
assert_eq!(fetched.scope_ref, with_ref.then(|| "live-scope".into()));
|
||||
runtime
|
||||
.inspect(provider.target_path.clone(), false)
|
||||
.await
|
||||
.unwrap();
|
||||
provider.state.lock().unwrap().lookups.clear();
|
||||
runtime
|
||||
.invoke(
|
||||
provider.target_path.clone(),
|
||||
selected,
|
||||
"read".into(),
|
||||
json!({}),
|
||||
ToolExecutionContext::direct(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(provider.calls.load(Ordering::SeqCst), 1);
|
||||
assert_eq!(
|
||||
provider.state.lock().unwrap().lookups,
|
||||
[provider.target_path.clone()]
|
||||
);
|
||||
let state = runtime.state.lock().unwrap();
|
||||
let call = state
|
||||
.client
|
||||
.call_history(&state.session)
|
||||
.unwrap()
|
||||
.back()
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
call.context.request().interface.scope_ref,
|
||||
fetched.scope_ref
|
||||
);
|
||||
drop(state);
|
||||
// A specified ref must still match even when the current scope has none.
|
||||
assert_eq!(
|
||||
rejected_code(
|
||||
&runtime,
|
||||
metadata_request(
|
||||
&provider.target_path,
|
||||
json!({"scope":scope,"name":"read"}),
|
||||
None,
|
||||
Some("wrong")
|
||||
)
|
||||
)
|
||||
.await,
|
||||
"interface_mismatch"
|
||||
);
|
||||
assert_eq!(provider.calls.load(Ordering::SeqCst), 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn deletion_without_prior_scope_inspect_rejects_cached_invoke_and_orphan_observation() {
|
||||
for scope in ["/github", "/github/team"] {
|
||||
for with_ref in [true, false] {
|
||||
let (runtime, provider) = fixture(scope, with_ref, false);
|
||||
let selected = reference(scope, "read");
|
||||
runtime
|
||||
.inspect(provider.target_path.clone(), false)
|
||||
.await
|
||||
.unwrap();
|
||||
provider.state.lock().unwrap().visible = false;
|
||||
let failed = runtime
|
||||
.invoke(
|
||||
provider.target_path.clone(),
|
||||
selected.clone(),
|
||||
"read".into(),
|
||||
json!({}),
|
||||
ToolExecutionContext::direct(),
|
||||
)
|
||||
.await
|
||||
.unwrap_err();
|
||||
assert!(failed.to_string().contains("InterfaceMismatch"), "{failed}");
|
||||
assert_eq!(provider.calls.load(Ordering::SeqCst), 0);
|
||||
assert!(
|
||||
runtime
|
||||
.inspect(provider.target_path.clone(), true)
|
||||
.await
|
||||
.is_err()
|
||||
);
|
||||
assert!(
|
||||
runtime
|
||||
.tree(provider.target_path.clone(), 0, true)
|
||||
.await
|
||||
.is_err()
|
||||
);
|
||||
assert_eq!(
|
||||
runtime
|
||||
.host
|
||||
.fetch_interface_live(&selected)
|
||||
.await
|
||||
.unwrap_err()
|
||||
.code,
|
||||
ProtocolErrorCode::InterfaceNotFound
|
||||
);
|
||||
let root = runtime.tree("/".into(), 3, true).await.unwrap();
|
||||
assert!(!root.content.unwrap().contains("/github"));
|
||||
assert_eq!(provider.calls.load(Ordering::SeqCst), 0);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn republishing_scope_never_restores_old_registration_even_without_refs() {
|
||||
for with_ref in [true, false] {
|
||||
let (runtime, provider) = fixture("/github", with_ref, false);
|
||||
let old = reference("/github", "read");
|
||||
runtime
|
||||
.inspect(provider.target_path.clone(), false)
|
||||
.await
|
||||
.unwrap();
|
||||
provider.state.lock().unwrap().visible = false;
|
||||
assert_eq!(
|
||||
runtime
|
||||
.host
|
||||
.fetch_interface_live(&old)
|
||||
.await
|
||||
.unwrap_err()
|
||||
.code,
|
||||
ProtocolErrorCode::InterfaceNotFound
|
||||
);
|
||||
{
|
||||
let mut state = provider.state.lock().unwrap();
|
||||
state.visible = true;
|
||||
state.identity = with_ref.then(|| "replacement".into());
|
||||
state.name = "new".into();
|
||||
state.generation = 1;
|
||||
}
|
||||
// The old root registration and cached old descriptor both still exist.
|
||||
assert_eq!(
|
||||
runtime
|
||||
.host
|
||||
.fetch_interface_live(&old)
|
||||
.await
|
||||
.unwrap_err()
|
||||
.code,
|
||||
ProtocolErrorCode::InterfaceNotFound
|
||||
);
|
||||
assert!(
|
||||
runtime
|
||||
.invoke(
|
||||
provider.target_path.clone(),
|
||||
old,
|
||||
"read".into(),
|
||||
json!({}),
|
||||
ToolExecutionContext::direct()
|
||||
)
|
||||
.await
|
||||
.is_err()
|
||||
);
|
||||
assert_eq!(provider.calls.load(Ordering::SeqCst), 0);
|
||||
runtime
|
||||
.inspect(provider.target_path.clone(), true)
|
||||
.await
|
||||
.unwrap();
|
||||
let new = reference("/github", "new");
|
||||
assert_eq!(
|
||||
runtime
|
||||
.host
|
||||
.fetch_interface_live(&new)
|
||||
.await
|
||||
.unwrap()
|
||||
.scope_ref,
|
||||
with_ref.then(|| "replacement".into())
|
||||
);
|
||||
runtime
|
||||
.invoke(
|
||||
provider.target_path.clone(),
|
||||
new,
|
||||
"read".into(),
|
||||
json!({}),
|
||||
ToolExecutionContext::direct(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(provider.calls.load(Ordering::SeqCst), 1);
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn missing_ancestor_preserves_wire_shape_target_and_object_validator_precedence() {
|
||||
let (runtime, provider) = fixture("/github", false, false);
|
||||
{
|
||||
let mut state = provider.state.lock().unwrap();
|
||||
state.visible = false;
|
||||
state.target_validator = Some(vec![1]);
|
||||
}
|
||||
assert_eq!(
|
||||
rejected_code(
|
||||
&runtime,
|
||||
metadata_request("/missing", json!("old"), None, None)
|
||||
)
|
||||
.await,
|
||||
"invalid_request"
|
||||
);
|
||||
assert_eq!(
|
||||
rejected_code(
|
||||
&runtime,
|
||||
metadata_request(
|
||||
"/missing",
|
||||
json!({"scope":"/github","name":"read"}),
|
||||
Some("AA=="),
|
||||
None
|
||||
)
|
||||
)
|
||||
.await,
|
||||
"not_found"
|
||||
);
|
||||
assert_eq!(
|
||||
rejected_code(
|
||||
&runtime,
|
||||
metadata_request(
|
||||
&provider.target_path,
|
||||
json!({"scope":"/github","name":"read"}),
|
||||
Some("AA=="),
|
||||
None
|
||||
)
|
||||
)
|
||||
.await,
|
||||
"validator_mismatch"
|
||||
);
|
||||
assert_eq!(
|
||||
rejected_code(
|
||||
&runtime,
|
||||
metadata_request(
|
||||
&provider.target_path,
|
||||
json!({"scope":"/github","name":"read"}),
|
||||
Some("AQ=="),
|
||||
None
|
||||
)
|
||||
)
|
||||
.await,
|
||||
"interface_mismatch"
|
||||
);
|
||||
assert_eq!(provider.calls.load(Ordering::SeqCst), 0);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn ancestor_replacement_during_in_flight_call_keeps_the_original_descriptor_and_handler() {
|
||||
let (runtime, provider) = fixture("/github/team", true, true);
|
||||
let selected = reference("/github/team", "read");
|
||||
runtime
|
||||
.inspect(provider.target_path.clone(), false)
|
||||
.await
|
||||
.unwrap();
|
||||
let pending = runtime.invoke(
|
||||
provider.target_path.clone(),
|
||||
selected.clone(),
|
||||
"read".into(),
|
||||
json!({}),
|
||||
ToolExecutionContext::direct(),
|
||||
);
|
||||
let update = async {
|
||||
provider.started.notified().await;
|
||||
{
|
||||
let mut state = provider.state.lock().unwrap();
|
||||
state.identity = Some("replacement".into());
|
||||
state.generation = 1;
|
||||
}
|
||||
let updated = runtime
|
||||
.inspect(provider.target_path.clone(), true)
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(updated.content.unwrap().contains("integer"));
|
||||
provider.release.notify_one();
|
||||
};
|
||||
let (old, ()) = tokio::time::timeout(std::time::Duration::from_secs(5), async {
|
||||
tokio::join!(pending, update)
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(old.unwrap().content.as_deref(), Some("\"old result\""));
|
||||
assert_eq!(provider.calls.load(Ordering::SeqCst), 1);
|
||||
let state = runtime.state.lock().unwrap();
|
||||
let history = state.client.call_history(&state.session).unwrap();
|
||||
let old = history.back().unwrap();
|
||||
assert_eq!(
|
||||
old.context.request().interface.scope_ref.as_deref(),
|
||||
Some("live-scope")
|
||||
);
|
||||
assert_eq!(
|
||||
old.context.descriptor().operations[0].returns.r#type,
|
||||
TypeExpr::String
|
||||
);
|
||||
drop(state);
|
||||
let new = runtime
|
||||
.invoke(
|
||||
provider.target_path.clone(),
|
||||
selected,
|
||||
"read".into(),
|
||||
json!({}),
|
||||
ToolExecutionContext::direct(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(new.content.as_deref(), Some("2"));
|
||||
assert_eq!(provider.calls.load(Ordering::SeqCst), 2);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn same_name_scope_replacement_or_loss_of_ref_rejects_old_metadata_without_replay() {
|
||||
for identity in [Some("replacement"), None] {
|
||||
let (runtime, provider) = fixture("/github/team", true, false);
|
||||
let selected = reference("/github/team", "read");
|
||||
runtime
|
||||
.inspect(provider.target_path.clone(), false)
|
||||
.await
|
||||
.unwrap();
|
||||
{
|
||||
let mut state = provider.state.lock().unwrap();
|
||||
state.identity = identity.map(Into::into);
|
||||
if identity.is_some() {
|
||||
state.generation = 1;
|
||||
}
|
||||
}
|
||||
assert_eq!(
|
||||
rejected_code(
|
||||
&runtime,
|
||||
metadata_request(
|
||||
&provider.target_path,
|
||||
json!({"scope":"/github/team","name":"read"}),
|
||||
None,
|
||||
Some("live-scope")
|
||||
)
|
||||
)
|
||||
.await,
|
||||
"interface_mismatch"
|
||||
);
|
||||
let failed = runtime
|
||||
.invoke(
|
||||
provider.target_path.clone(),
|
||||
selected.clone(),
|
||||
"read".into(),
|
||||
json!({}),
|
||||
ToolExecutionContext::direct(),
|
||||
)
|
||||
.await
|
||||
.unwrap_err();
|
||||
assert!(failed.to_string().contains("InterfaceMismatch"), "{failed}");
|
||||
assert_eq!(provider.calls.load(Ordering::SeqCst), 0);
|
||||
let fetched = runtime.host.fetch_interface_live(&selected).await.unwrap();
|
||||
assert_eq!(fetched.scope_ref.as_deref(), identity);
|
||||
assert_eq!(
|
||||
fetched.descriptor.documentation.unwrap().summary,
|
||||
if identity.is_some() {
|
||||
"Published generation 1"
|
||||
} else {
|
||||
"Published generation 0"
|
||||
}
|
||||
);
|
||||
runtime
|
||||
.inspect(provider.target_path.clone(), true)
|
||||
.await
|
||||
.unwrap();
|
||||
runtime
|
||||
.invoke(
|
||||
provider.target_path.clone(),
|
||||
selected,
|
||||
"read".into(),
|
||||
json!({}),
|
||||
ToolExecutionContext::direct(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(provider.calls.load(Ordering::SeqCst), 1);
|
||||
let state = runtime.state.lock().unwrap();
|
||||
let call = state
|
||||
.client
|
||||
.call_history(&state.session)
|
||||
.unwrap()
|
||||
.back()
|
||||
.unwrap();
|
||||
// Omitted scope_ref is never filled from the placeholder or other observations.
|
||||
assert_eq!(
|
||||
call.context.request().interface.scope_ref.as_deref(),
|
||||
identity
|
||||
);
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,565 @@
|
||||
//! Scope publication and precondition tests through the actual Yoi HTTP adapter.
|
||||
use super::*;
|
||||
use std::sync::atomic::{AtomicBool, AtomicUsize};
|
||||
|
||||
#[path = "ancestor_tests.rs"]
|
||||
mod ancestor_tests;
|
||||
|
||||
struct ReadHandler(Arc<AtomicUsize>);
|
||||
#[async_trait]
|
||||
impl WipOperationHandler for ReadHandler {
|
||||
async fn call(
|
||||
&self,
|
||||
_: &str,
|
||||
_: &BTreeMap<String, Value>,
|
||||
_: WipCallContext,
|
||||
) -> Result<WipOperationOutput, WipOperationError> {
|
||||
self.0.fetch_add(1, Ordering::SeqCst);
|
||||
Ok(WipOperationOutput::native(Value::String("value".into())))
|
||||
}
|
||||
}
|
||||
|
||||
fn projection(path: &str, scope: &str, name: &str, calls: Arc<AtomicUsize>) -> WipProjection {
|
||||
let interface = InterfaceReference {
|
||||
scope: scope.into(),
|
||||
name: name.into(),
|
||||
};
|
||||
WipProjection {
|
||||
route: path.into(),
|
||||
capability: "scope:object".into(),
|
||||
kind: WipProjectionKind::Native,
|
||||
object: Object {
|
||||
name: path.rsplit('/').next().unwrap().into(),
|
||||
description: None,
|
||||
interfaces: vec![interface.clone()],
|
||||
r#ref: Some(format!("object:{path}")),
|
||||
validator: None,
|
||||
},
|
||||
interface,
|
||||
descriptor: InterfaceDescriptor {
|
||||
format: INTERFACE_FORMAT_V1.into(),
|
||||
documentation: None,
|
||||
types: vec![],
|
||||
operations: vec![OperationDeclaration {
|
||||
name: "read".into(),
|
||||
documentation: None,
|
||||
parameters: vec![],
|
||||
returns: ReturnDeclaration {
|
||||
documentation: None,
|
||||
r#type: TypeExpr::String,
|
||||
},
|
||||
}],
|
||||
},
|
||||
interface_validator: None,
|
||||
handler: Arc::new(ReadHandler(calls)),
|
||||
}
|
||||
}
|
||||
|
||||
fn metadata_request(
|
||||
path: &str,
|
||||
reference: Json,
|
||||
object_validator: Option<&str>,
|
||||
scope_ref: Option<&str>,
|
||||
) -> Request<Vec<u8>> {
|
||||
let mut target = json!({"path": path});
|
||||
if let Some(v) = object_validator {
|
||||
target["validator"] = json!(v);
|
||||
}
|
||||
let mut interface = json!({"reference": reference});
|
||||
if let Some(v) = scope_ref {
|
||||
interface["scope_ref"] = json!(v);
|
||||
}
|
||||
Request::builder()
|
||||
.body(
|
||||
serde_json::to_vec(
|
||||
&json!({"target":target,"interface":interface,"operation":"read","arguments":{}}),
|
||||
)
|
||||
.unwrap(),
|
||||
)
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
async fn rejected_code(runtime: &WipRuntime, request: Request<Vec<u8>>) -> String {
|
||||
let (response, passthrough, outcome) = runtime
|
||||
.dispatch_call(&request, ToolExecutionContext::direct())
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(passthrough.is_none());
|
||||
assert_eq!(outcome, WipAuditOutcome::Rejected);
|
||||
let json: Json = serde_json::from_slice(response.body()).unwrap();
|
||||
json["error"]["code"].as_str().unwrap().into()
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn adapter_preconditions_preserve_shape_target_validator_and_scope_precedence() {
|
||||
let calls = Arc::new(AtomicUsize::new(0));
|
||||
let mut registry = WipMountRegistry::new();
|
||||
registry.allocate_namespace("scope", "github").unwrap();
|
||||
let mut p = projection("/github/item", "/github/item", "read", calls.clone());
|
||||
p.object.validator = Some(vec![1]);
|
||||
registry.mount(p).unwrap();
|
||||
let runtime = WipRuntime::from_mounts(registry, "scope-test".into()).unwrap();
|
||||
assert_eq!(
|
||||
rejected_code(
|
||||
&runtime,
|
||||
metadata_request("/missing", json!("legacy"), None, None)
|
||||
)
|
||||
.await,
|
||||
"invalid_request"
|
||||
);
|
||||
assert_eq!(
|
||||
rejected_code(
|
||||
&runtime,
|
||||
metadata_request(
|
||||
"/missing",
|
||||
json!({"scope":"/sibling","name":"read"}),
|
||||
Some("AA=="),
|
||||
None
|
||||
)
|
||||
)
|
||||
.await,
|
||||
"not_found"
|
||||
);
|
||||
assert_eq!(
|
||||
rejected_code(
|
||||
&runtime,
|
||||
metadata_request(
|
||||
"/github/item",
|
||||
json!({"scope":"/sibling","name":"read"}),
|
||||
Some("AA=="),
|
||||
None
|
||||
)
|
||||
)
|
||||
.await,
|
||||
"validator_mismatch"
|
||||
);
|
||||
for scope in ["/sibling", "/github/item/child", "/github-extra"] {
|
||||
assert_eq!(
|
||||
rejected_code(
|
||||
&runtime,
|
||||
metadata_request(
|
||||
"/github/item",
|
||||
json!({"scope":scope,"name":"read"}),
|
||||
Some("AQ=="),
|
||||
None
|
||||
)
|
||||
)
|
||||
.await,
|
||||
"interface_mismatch"
|
||||
);
|
||||
}
|
||||
assert_eq!(
|
||||
rejected_code(
|
||||
&runtime,
|
||||
metadata_request(
|
||||
"/github/item",
|
||||
json!({"scope":"/github/item","name":"read"}),
|
||||
Some("AQ=="),
|
||||
Some("old")
|
||||
)
|
||||
)
|
||||
.await,
|
||||
"interface_mismatch"
|
||||
);
|
||||
assert_eq!(calls.load(Ordering::SeqCst), 0);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn actual_provider_root_and_self_scope_inspect_invoke_preserve_optional_refs() {
|
||||
for path in ["/", "/github"] {
|
||||
for with_ref in [true, false] {
|
||||
let calls = Arc::new(AtomicUsize::new(0));
|
||||
let mut registry = WipMountRegistry::new();
|
||||
registry.allocate_namespace("scope", "github").unwrap();
|
||||
let mut p = projection(path, path, "read", calls.clone());
|
||||
if !with_ref {
|
||||
p.object.r#ref = None;
|
||||
}
|
||||
let reference = p.interface.clone();
|
||||
registry.mount(p).unwrap();
|
||||
let runtime = WipRuntime::from_mounts(registry, "scope-test".into()).unwrap();
|
||||
let fetched = runtime.host.fetch_interface_live(&reference).await.unwrap();
|
||||
assert_eq!(
|
||||
fetched.scope_ref,
|
||||
with_ref.then(|| format!("object:{path}"))
|
||||
);
|
||||
runtime.inspect(path.into(), false).await.unwrap();
|
||||
runtime
|
||||
.invoke(
|
||||
path.into(),
|
||||
reference.clone(),
|
||||
"read".into(),
|
||||
json!({}),
|
||||
ToolExecutionContext::direct(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(calls.load(Ordering::SeqCst), 1);
|
||||
let state = runtime.state.lock().unwrap();
|
||||
let history = state.client.call_history(&state.session).unwrap();
|
||||
assert_eq!(
|
||||
history
|
||||
.back()
|
||||
.unwrap()
|
||||
.context
|
||||
.request()
|
||||
.interface
|
||||
.scope_ref,
|
||||
fetched.scope_ref
|
||||
);
|
||||
drop(state);
|
||||
if !with_ref {
|
||||
assert_eq!(
|
||||
rejected_code(
|
||||
&runtime,
|
||||
metadata_request(
|
||||
path,
|
||||
json!({"scope":path,"name":"read"}),
|
||||
None,
|
||||
Some("invented")
|
||||
)
|
||||
)
|
||||
.await,
|
||||
"interface_mismatch"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn multiple_interfaces_same_operation_are_inspected_and_dispatched_without_flattening() {
|
||||
let a = Arc::new(AtomicUsize::new(0));
|
||||
let b = Arc::new(AtomicUsize::new(0));
|
||||
let mut registry = WipMountRegistry::new();
|
||||
registry.allocate_namespace("scope", "github").unwrap();
|
||||
let owner = projection("/github", "/", "same", a.clone());
|
||||
let other = projection("/github", "/github", "same", b.clone());
|
||||
let refs = [owner.interface.clone(), other.interface.clone()];
|
||||
registry.mount(owner).unwrap();
|
||||
registry.mount_interface(other).unwrap();
|
||||
let runtime = WipRuntime::from_mounts(registry, "scope-test".into()).unwrap();
|
||||
let inspected = runtime.inspect("/github".into(), false).await.unwrap();
|
||||
let inspected: Json = serde_json::from_str(inspected.content.as_ref().unwrap()).unwrap();
|
||||
assert_eq!(inspected["interfaces"].as_array().unwrap().len(), 2);
|
||||
for reference in refs {
|
||||
runtime
|
||||
.invoke(
|
||||
"/github".into(),
|
||||
reference,
|
||||
"read".into(),
|
||||
json!({}),
|
||||
ToolExecutionContext::direct(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
}
|
||||
assert_eq!(a.load(Ordering::SeqCst), 1);
|
||||
assert_eq!(b.load(Ordering::SeqCst), 1);
|
||||
}
|
||||
|
||||
struct Publication {
|
||||
visible: Arc<AtomicBool>,
|
||||
identity: Arc<Mutex<Option<String>>>,
|
||||
validator: Arc<Mutex<Option<Vec<u8>>>>,
|
||||
calls: Arc<AtomicUsize>,
|
||||
}
|
||||
impl WipDynamicItemResolver for Publication {
|
||||
fn contextual_interface(&self) -> bool {
|
||||
true
|
||||
}
|
||||
fn resolve(&self, item: &str) -> Option<WipDynamicItem> {
|
||||
if item != "item" || !self.visible.load(Ordering::SeqCst) {
|
||||
return None;
|
||||
}
|
||||
let mut p = projection("/github/item", "/", "read", self.calls.clone());
|
||||
p.object.r#ref = self.identity.lock().unwrap().clone();
|
||||
p.object.validator = self.validator.lock().unwrap().clone();
|
||||
Some(WipDynamicItem {
|
||||
object: p.object,
|
||||
handler: p.handler,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn current_self_scope_fetch_does_not_attach_a_new_ref_to_an_old_target_snapshot() {
|
||||
let calls = Arc::new(AtomicUsize::new(0));
|
||||
let visible = Arc::new(AtomicBool::new(true));
|
||||
let identity = Arc::new(Mutex::new(Some("first".into())));
|
||||
let validator = Arc::new(Mutex::new(None));
|
||||
let mut registry = WipMountRegistry::new();
|
||||
registry.allocate_namespace("scope", "github").unwrap();
|
||||
registry
|
||||
.mount(projection("/github", "/", "collection", calls.clone()))
|
||||
.unwrap();
|
||||
let p = projection("/github/item", "/", "read", calls.clone());
|
||||
registry
|
||||
.mount_dynamic(WipDynamicMount {
|
||||
collection_route: "/github".into(),
|
||||
capability: "scope:item".into(),
|
||||
interface: p.interface,
|
||||
descriptor: p.descriptor,
|
||||
interface_validator: None,
|
||||
resolver: Arc::new(Publication {
|
||||
visible: visible.clone(),
|
||||
identity: identity.clone(),
|
||||
validator: validator.clone(),
|
||||
calls: calls.clone(),
|
||||
}),
|
||||
})
|
||||
.unwrap();
|
||||
let runtime = WipRuntime::from_mounts(registry, "scope-test".into()).unwrap();
|
||||
let reference = contextual_reference("read", "/github/item");
|
||||
runtime.inspect("/github/item".into(), false).await.unwrap();
|
||||
assert_eq!(
|
||||
runtime
|
||||
.host
|
||||
.fetch_interface_live(&reference)
|
||||
.await
|
||||
.unwrap()
|
||||
.scope_ref
|
||||
.as_deref(),
|
||||
Some("first")
|
||||
);
|
||||
// Ordinary content-state updates do not change the scope Object identity.
|
||||
*validator.lock().unwrap() = Some(vec![2]);
|
||||
assert_eq!(
|
||||
runtime
|
||||
.host
|
||||
.fetch_interface_live(&reference)
|
||||
.await
|
||||
.unwrap()
|
||||
.scope_ref
|
||||
.as_deref(),
|
||||
Some("first")
|
||||
);
|
||||
*validator.lock().unwrap() = None;
|
||||
*identity.lock().unwrap() = Some("second".into());
|
||||
let error = runtime
|
||||
.invoke(
|
||||
"/github/item".into(),
|
||||
reference.clone(),
|
||||
"read".into(),
|
||||
json!({}),
|
||||
ToolExecutionContext::direct(),
|
||||
)
|
||||
.await
|
||||
.unwrap_err();
|
||||
assert!(error.to_string().contains("InterfaceMismatch"));
|
||||
assert_eq!(calls.load(Ordering::SeqCst), 0);
|
||||
// Both target and Interface are invalidated; retrievals are not reexecution.
|
||||
let state = runtime.state.lock().unwrap();
|
||||
assert_eq!(
|
||||
state
|
||||
.client
|
||||
.object(&state.session, "/github/item")
|
||||
.unwrap()
|
||||
.state,
|
||||
ObservationState::Stale
|
||||
);
|
||||
assert_eq!(
|
||||
state
|
||||
.client
|
||||
.interface(&state.session, &reference)
|
||||
.unwrap()
|
||||
.state,
|
||||
ObservationState::Stale
|
||||
);
|
||||
drop(state);
|
||||
runtime
|
||||
.invoke(
|
||||
"/github/item".into(),
|
||||
reference.clone(),
|
||||
"read".into(),
|
||||
json!({}),
|
||||
ToolExecutionContext::direct(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(calls.load(Ordering::SeqCst), 1);
|
||||
visible.store(false, Ordering::SeqCst);
|
||||
assert!(runtime.host.fetch_interface_live(&reference).await.is_err());
|
||||
assert!(runtime.inspect("/github/item".into(), true).await.is_err());
|
||||
let state = runtime.state.lock().unwrap();
|
||||
assert!(state.client.interface(&state.session, &reference).is_none());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn supplied_validators_require_current_values_and_required_object_validator_is_late() {
|
||||
let calls = Arc::new(AtomicUsize::new(0));
|
||||
let mut registry = WipMountRegistry::new();
|
||||
registry.allocate_namespace("scope", "github").unwrap();
|
||||
registry
|
||||
.mount(projection("/github", "/github", "read", calls.clone()))
|
||||
.unwrap();
|
||||
let runtime = WipRuntime::from_mounts(registry, "scope-test".into()).unwrap();
|
||||
let reference = json!({"scope":"/github","name":"read"});
|
||||
assert_eq!(
|
||||
rejected_code(
|
||||
&runtime,
|
||||
metadata_request("/github", reference.clone(), Some("AQ=="), None)
|
||||
)
|
||||
.await,
|
||||
"validator_mismatch"
|
||||
);
|
||||
let mut request = metadata_request("/github", reference, None, None);
|
||||
let mut body: Json = serde_json::from_slice(request.body()).unwrap();
|
||||
body["interface"]["validator"] = json!("AQ==");
|
||||
*request.body_mut() = serde_json::to_vec(&body).unwrap();
|
||||
assert_eq!(
|
||||
rejected_code(&runtime, request).await,
|
||||
"interface_validator_mismatch"
|
||||
);
|
||||
let mut registry = WipMountRegistry::new();
|
||||
registry.allocate_namespace("scope", "github").unwrap();
|
||||
let mut p = projection("/github", "/github", "read", calls.clone());
|
||||
p.object.validator = Some(vec![1]);
|
||||
registry.mount(p).unwrap();
|
||||
let runtime = WipRuntime::from_mounts(registry, "scope-test".into()).unwrap();
|
||||
assert_eq!(
|
||||
rejected_code(
|
||||
&runtime,
|
||||
metadata_request(
|
||||
"/github",
|
||||
json!({"scope":"/wrong","name":"read"}),
|
||||
None,
|
||||
None
|
||||
)
|
||||
)
|
||||
.await,
|
||||
"interface_mismatch"
|
||||
);
|
||||
assert_eq!(calls.load(Ordering::SeqCst), 0);
|
||||
}
|
||||
|
||||
struct ChangingProvider {
|
||||
generation: Arc<AtomicUsize>,
|
||||
started: Arc<tokio::sync::Notify>,
|
||||
release: Arc<tokio::sync::Notify>,
|
||||
calls: Arc<AtomicUsize>,
|
||||
}
|
||||
struct FrozenResultHandler {
|
||||
generation: usize,
|
||||
started: Arc<tokio::sync::Notify>,
|
||||
release: Arc<tokio::sync::Notify>,
|
||||
calls: Arc<AtomicUsize>,
|
||||
}
|
||||
#[async_trait]
|
||||
impl WipOperationHandler for FrozenResultHandler {
|
||||
async fn call(
|
||||
&self,
|
||||
_: &str,
|
||||
_: &BTreeMap<String, Value>,
|
||||
_: WipCallContext,
|
||||
) -> Result<WipOperationOutput, WipOperationError> {
|
||||
self.calls.fetch_add(1, Ordering::SeqCst);
|
||||
if self.generation == 0 {
|
||||
self.started.notify_one();
|
||||
self.release.notified().await;
|
||||
Ok(WipOperationOutput::native(Value::String(
|
||||
"old result".into(),
|
||||
)))
|
||||
} else {
|
||||
Ok(WipOperationOutput::native(Value::Integer(2)))
|
||||
}
|
||||
}
|
||||
}
|
||||
#[async_trait]
|
||||
impl WipSubtreeProvider for ChangingProvider {
|
||||
async fn publication(&self, path: &str) -> Result<Option<WipPublication>, ProtocolError> {
|
||||
if path != "/github" {
|
||||
return Ok(None);
|
||||
}
|
||||
let generation = self.generation.load(Ordering::SeqCst);
|
||||
let mut p = projection(path, path, "read", self.calls.clone());
|
||||
p.interface_validator = Some(vec![generation as u8]);
|
||||
if generation != 0 {
|
||||
p.descriptor.operations[0].returns.r#type = TypeExpr::Integer;
|
||||
}
|
||||
p.handler = Arc::new(FrozenResultHandler {
|
||||
generation,
|
||||
started: self.started.clone(),
|
||||
release: self.release.clone(),
|
||||
calls: self.calls.clone(),
|
||||
});
|
||||
WipPublication::self_scoped(p).map(Some)
|
||||
}
|
||||
async fn children(&self, _: &str) -> Result<Vec<String>, ProtocolError> {
|
||||
Ok(vec![])
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn in_flight_result_uses_frozen_host_and_client_descriptors_after_refresh() {
|
||||
let generation = Arc::new(AtomicUsize::new(0));
|
||||
let started = Arc::new(tokio::sync::Notify::new());
|
||||
let release = Arc::new(tokio::sync::Notify::new());
|
||||
let calls = Arc::new(AtomicUsize::new(0));
|
||||
let mut registry = WipMountRegistry::new();
|
||||
registry.allocate_namespace("scope", "github").unwrap();
|
||||
registry
|
||||
.mount(projection("/github", "/github", "read", calls.clone()))
|
||||
.unwrap();
|
||||
registry
|
||||
.mount_subtree(WipSubtreeMount {
|
||||
root: "/github".into(),
|
||||
provider: Arc::new(ChangingProvider {
|
||||
generation: generation.clone(),
|
||||
started: started.clone(),
|
||||
release: release.clone(),
|
||||
calls: calls.clone(),
|
||||
}),
|
||||
})
|
||||
.unwrap();
|
||||
let runtime = WipRuntime::from_mounts(registry, "scope-test".into()).unwrap();
|
||||
runtime.inspect("/github".into(), false).await.unwrap();
|
||||
let pending = runtime.invoke(
|
||||
"/github".into(),
|
||||
contextual_reference("read", "/github"),
|
||||
"read".into(),
|
||||
json!({}),
|
||||
ToolExecutionContext::direct(),
|
||||
);
|
||||
let update = async {
|
||||
started.notified().await;
|
||||
generation.store(1, Ordering::SeqCst);
|
||||
let output = runtime.inspect("/github".into(), true).await.unwrap();
|
||||
assert!(output.content.unwrap().contains("integer"));
|
||||
release.notify_one();
|
||||
};
|
||||
let (old_result, ()) = tokio::time::timeout(std::time::Duration::from_secs(5), async {
|
||||
tokio::join!(pending, update)
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
old_result.unwrap().content.as_deref(),
|
||||
Some("\"old result\"")
|
||||
);
|
||||
assert_eq!(calls.load(Ordering::SeqCst), 1);
|
||||
let state = runtime.state.lock().unwrap();
|
||||
let record = state
|
||||
.client
|
||||
.call_history(&state.session)
|
||||
.unwrap()
|
||||
.back()
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
record.context.descriptor().operations[0].returns.r#type,
|
||||
TypeExpr::String
|
||||
);
|
||||
assert_eq!(
|
||||
state
|
||||
.client
|
||||
.interface(&state.session, &contextual_reference("read", "/github"))
|
||||
.unwrap()
|
||||
.descriptor
|
||||
.as_ref()
|
||||
.unwrap()
|
||||
.operations[0]
|
||||
.returns
|
||||
.r#type,
|
||||
TypeExpr::Integer
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user