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:
2026-10-09 02:54:09 +09:00
62 changed files with 11501 additions and 1008 deletions
+1
View File
@@ -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 }
+1 -1
View File
@@ -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,
+8 -5
View File
@@ -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())
+3 -3
View File
@@ -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
}
+3 -3
View File
@@ -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)),
},
+4 -4
View File
@@ -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())),
+173 -16
View File
@@ -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
View File
File diff suppressed because it is too large Load Diff
+540
View File
@@ -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
+565
View File
@@ -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
);
}