From bc7b2f894c8f780393b5d47790d6fbce5935a621 Mon Sep 17 00:00:00 2001 From: Hare Date: Mon, 5 Oct 2026 15:33:43 +0900 Subject: [PATCH] feat: expose WIP objects from host namespaces --- crates/worker/src/controller.rs | 24 +- .../src/feature/builtin/merge_request.rs | 29 +- .../worker/src/feature/builtin/objective.rs | 45 +- crates/worker/src/feature/builtin/ticket.rs | 35 +- crates/worker/src/wip.rs | 914 ++++++++++++++++-- docs/README.md | 2 + docs/design/merge-request-wip-projection.md | 8 +- docs/design/objective-wip-projection.md | 8 +- docs/design/ticket-wip-projection.md | 8 +- docs/design/worker-wip-mode.md | 29 +- 10 files changed, 914 insertions(+), 188 deletions(-) diff --git a/crates/worker/src/controller.rs b/crates/worker/src/controller.rs index 950a81a55..9b959f8b6 100644 --- a/crates/worker/src/controller.rs +++ b/crates/worker/src/controller.rs @@ -1593,12 +1593,12 @@ where let engine = worker.engine_mut(); if feature_config.merge_request.any() && wip_mode { - let feature_route = wip_mount_registry - .allocate_feature_route("merge-request") + let namespace_route = wip_mount_registry + .allocate_namespace("merge-request", "merge-requests") .map_err(|error| { std::io::Error::new( std::io::ErrorKind::InvalidInput, - format!("allocate Merge Request WIP route: {error}"), + format!("allocate Merge Request WIP namespace: {error}"), ) })?; crate::feature::builtin::merge_request::mount_workspace_http_merge_request_wip( @@ -1606,7 +1606,7 @@ where workspace_client.clone(), feature_config.merge_request, wip_permissions.clone(), - &feature_route, + &namespace_route, ) .map_err(|error| { std::io::Error::new( @@ -1623,12 +1623,12 @@ where intake: feature_config.ticket.intake, workflow: feature_config.ticket.workflow, }; - let feature_route = wip_mount_registry - .allocate_feature_route("ticket") + let namespace_route = wip_mount_registry + .allocate_namespace("ticket", "tickets") .map_err(|error| { std::io::Error::new( std::io::ErrorKind::InvalidInput, - format!("allocate Ticket WIP route: {error}"), + format!("allocate Ticket WIP namespace: {error}"), ) })?; crate::feature::builtin::ticket::mount_workspace_http_ticket_wip( @@ -1636,7 +1636,7 @@ where workspace_client.clone(), ticket_access, wip_permissions.clone(), - &feature_route, + &namespace_route, ) .map_err(|error| { std::io::Error::new( @@ -1657,19 +1657,19 @@ where engine.register_tool(definition); } if wip_mode { - let feature_route = wip_mount_registry - .allocate_feature_route("objective") + let namespace_route = wip_mount_registry + .allocate_namespace("objective", "objectives") .map_err(|error| { std::io::Error::new( std::io::ErrorKind::InvalidInput, - format!("allocate Objective WIP route: {error}"), + format!("allocate Objective WIP namespace: {error}"), ) })?; crate::feature::builtin::objective::mount_workspace_http_objective_wip( &mut wip_mount_registry, workspace_client.clone(), wip_permissions.clone(), - &feature_route, + &namespace_route, ) .map_err(|error| { std::io::Error::new( diff --git a/crates/worker/src/feature/builtin/merge_request.rs b/crates/worker/src/feature/builtin/merge_request.rs index afc5e08f7..88460b2d7 100644 --- a/crates/worker/src/feature/builtin/merge_request.rs +++ b/crates/worker/src/feature/builtin/merge_request.rs @@ -5,9 +5,9 @@ use crate::feature::{ }; use crate::permission::permission_action_for; use crate::wip::{ - WipCallContext, WipDynamicItem, WipDynamicItemResolver, WipDynamicMount, WipFeatureRoute, - WipMountError, WipMountRegistry, WipOperationError, WipOperationHandler, WipOperationOutput, - WipProjection, WipProjectionKind, json_to_wip, wip_to_json, + WipCallContext, WipDynamicItem, WipDynamicItemResolver, WipDynamicMount, WipMountError, + WipMountRegistry, WipNamespaceRoute, WipOperationError, WipOperationHandler, + WipOperationOutput, WipProjection, WipProjectionKind, json_to_wip, wip_to_json, }; use crate::worker::{WorkspaceClient, WorkspaceRequest, WorkspaceRequestMethod}; use agen::tool::{Tool, ToolError, ToolExecutionContext, ToolMeta, ToolOutput}; @@ -529,7 +529,7 @@ fn native_merge_request_tools( let (meta, tool) = definition(client.clone(), kind)(); jsonschema::validator_for(&meta.input_schema).map_err(|error| { WipMountError::InvalidProjection { - route: "/features/merge-request/merge-requests".into(), + route: "/merge-requests".into(), message: format!("Merge Request tool schema cannot be retained: {error}"), } })?; @@ -562,9 +562,9 @@ pub fn mount_workspace_http_merge_request_wip( client: Arc, config: MergeRequestFeatureConfig, permissions: Option, - feature_route: &WipFeatureRoute, + namespace_route: &WipNamespaceRoute, ) -> Result<(), WipMountError> { - let collection_route = feature_route.child("merge-requests")?; + let collection_route = namespace_route.root().to_string(); let tools = native_merge_request_tools(client, config)?; let claimed_tools = tools .iter() @@ -1049,7 +1049,7 @@ fn merge_request_operation_declaration( .get("properties") .and_then(Value::as_object) .ok_or_else(|| WipMountError::InvalidProjection { - route: "/features/merge-request/merge-requests".into(), + route: "/merge-requests".into(), message: format!( "Merge Request tool `{}` schema has no properties", tool.name @@ -1474,7 +1474,9 @@ mod tests { #[test] fn native_mount_uses_host_route_and_accepts_only_one_canonical_item_segment() { let mut registry = WipMountRegistry::new(); - let feature_route = registry.allocate_feature_route("merge-request").unwrap(); + let namespace_route = registry + .allocate_namespace("merge-request", "merge-requests") + .unwrap(); mount_workspace_http_merge_request_wip( &mut registry, Arc::new(TestWorkspaceHttpClient::new("workspace", "http://unused")), @@ -1484,17 +1486,14 @@ mod tests { ..Default::default() }, None, - &feature_route, + &namespace_route, ) .unwrap(); - assert_eq!( - registry.routes().collect::>(), - ["/features/merge-request/merge-requests"] - ); + assert_eq!(registry.routes().collect::>(), ["/merge-requests"]); let resolver = MergeRequestItemResolver { tools: HashMap::new(), permissions: None, - collection_route: "/features/merge-request/merge-requests".into(), + collection_route: "/merge-requests".into(), revisions: Arc::new(Mutex::new(MergeRequestRevisionState::default())), }; assert!( @@ -1645,7 +1644,7 @@ mod tests { let resolver = MergeRequestItemResolver { tools: HashMap::new(), permissions: None, - collection_route: "/features/merge-request/merge-requests".into(), + collection_route: "/merge-requests".into(), revisions: Arc::clone(&revisions), }; let first = resolver.resolve("MR-1").unwrap().object.validator; diff --git a/crates/worker/src/feature/builtin/objective.rs b/crates/worker/src/feature/builtin/objective.rs index 9f30111c6..3b86d0935 100644 --- a/crates/worker/src/feature/builtin/objective.rs +++ b/crates/worker/src/feature/builtin/objective.rs @@ -21,9 +21,9 @@ use wip_protocol::{ use crate::permission::permission_action_for; use crate::wip::{ - WipCallContext, WipDynamicItem, WipDynamicItemResolver, WipDynamicMount, WipFeatureRoute, - WipMountError, WipMountRegistry, WipOperationError, WipOperationHandler, WipOperationOutput, - WipProjection, WipProjectionKind, json_to_wip, wip_to_json, + WipCallContext, WipDynamicItem, WipDynamicItemResolver, WipDynamicMount, WipMountError, + WipMountRegistry, WipNamespaceRoute, WipOperationError, WipOperationHandler, + WipOperationOutput, WipProjection, WipProjectionKind, json_to_wip, wip_to_json, }; use crate::worker::{WorkspaceClient, WorkspaceRequest, WorkspaceRequestMethod}; @@ -551,9 +551,9 @@ pub fn mount_workspace_http_objective_wip( registry: &mut WipMountRegistry, client: Arc, permissions: Option, - feature_route: &WipFeatureRoute, + namespace_route: &WipNamespaceRoute, ) -> Result<(), WipMountError> { - let collection_route = feature_route.child("objectives")?; + let collection_route = namespace_route.root().to_string(); let backend = WorkspaceHttpObjectiveBackend::new(client); let revisions = Arc::new(Mutex::new(ObjectiveRevisionState::default())); let collection_descriptor = objective_collection_descriptor(); @@ -1775,7 +1775,9 @@ mod tests { })); let mut registry = WipMountRegistry::new(); - let feature_route = registry.allocate_feature_route("objective").unwrap(); + let namespace_route = registry + .allocate_namespace("objective", "objectives") + .unwrap(); mount_workspace_http_objective_wip( &mut registry, Arc::new(crate::worker::TestWorkspaceHttpClient::new( @@ -1783,18 +1785,16 @@ mod tests { "http://backend", )), None, - &feature_route, + &namespace_route, ) .unwrap(); - assert_eq!( - registry.routes().collect::>(), - ["/features/objective/objectives"] + assert_eq!(registry.routes().collect::>(), ["/objectives"]); + assert_eq!(namespace_route.root(), "/objectives"); + assert!( + registry + .allocate_namespace("objective", "../tickets") + .is_err() ); - assert_eq!( - feature_route.child("objectives").unwrap(), - "/features/objective/objectives" - ); - assert!(feature_route.child("../tickets").is_err()); } #[test] @@ -1805,7 +1805,7 @@ mod tests { crate::worker::TestWorkspaceHttpClient::new("workspace", "http://backend"), )), permissions: None, - collection_route: "/features/objective/objectives".into(), + collection_route: "/objectives".into(), revisions: Arc::clone(&revisions), }; let initial = resolver.resolve("O-3").unwrap(); @@ -1971,7 +1971,7 @@ mod tests { let collection = ObjectiveCollectionWipHandler { backend, permissions: None, - collection_route: "/features/objective/objectives".into(), + collection_route: "/objectives".into(), revisions: Arc::new(Mutex::new(ObjectiveRevisionState::default())), }; let result = collection @@ -2013,7 +2013,7 @@ mod tests { let collection = ObjectiveCollectionWipHandler { backend: backend.clone(), permissions: None, - collection_route: "/features/objective/objectives".into(), + collection_route: "/objectives".into(), revisions: Arc::clone(&revisions), }; let context = || WipCallContext { @@ -2170,7 +2170,7 @@ mod tests { crate::worker::TestWorkspaceHttpClient::new("workspace", base_url), )), permissions: None, - collection_route: "/features/objective/objectives".into(), + collection_route: "/objectives".into(), revisions: Arc::new(Mutex::new(ObjectiveRevisionState::default())), }; let output = match handler @@ -2191,10 +2191,7 @@ mod tests { let output = crate::wip::wip_to_json(&output.value).unwrap(); assert_eq!(output["next_cursor"], "next-page"); assert_eq!(output["has_more"], true); - assert_eq!( - output["objectives"][0]["path"], - "/features/objective/objectives/O-3" - ); + assert_eq!(output["objectives"][0]["path"], "/objectives/O-3"); assert_eq!(output["objectives"][0]["linked_tickets"][0], "T-7"); assert!(!output.to_string().contains("00001OBJECTIVE")); assert!(!output.to_string().contains("00001TICKET")); @@ -2243,7 +2240,7 @@ mod tests { let resolver = ObjectiveItemResolver { backend: backend.clone(), permissions: None, - collection_route: "/features/objective/objectives".into(), + collection_route: "/objectives".into(), revisions: Arc::clone(&revisions), }; let before = resolver.resolve("O-3").unwrap().object.validator; diff --git a/crates/worker/src/feature/builtin/ticket.rs b/crates/worker/src/feature/builtin/ticket.rs index 8a82ab6f3..f740ae6d0 100644 --- a/crates/worker/src/feature/builtin/ticket.rs +++ b/crates/worker/src/feature/builtin/ticket.rs @@ -34,9 +34,9 @@ use crate::feature::{ }; use crate::permission::permission_action_for; use crate::wip::{ - WipCallContext, WipDynamicItem, WipDynamicItemResolver, WipDynamicMount, WipFeatureRoute, - WipMountError, WipMountRegistry, WipOperationError, WipOperationHandler, WipOperationOutput, - WipProjection, WipProjectionKind, json_to_wip, wip_to_json, + WipCallContext, WipDynamicItem, WipDynamicItemResolver, WipDynamicMount, WipMountError, + WipMountRegistry, WipNamespaceRoute, WipOperationError, WipOperationHandler, + WipOperationOutput, WipProjection, WipProjectionKind, json_to_wip, wip_to_json, }; use crate::worker::{WorkspaceClient, WorkspaceRequest, WorkspaceRequestMethod}; use agen::tool::{Tool, ToolError, ToolExecutionContext, ToolMeta, ToolOutput}; @@ -1377,7 +1377,7 @@ fn native_ticket_tools( let (meta, tool) = definition(); let projection = native_ticket_operation(&meta.name).ok_or_else(|| { WipMountError::InvalidProjection { - route: "/features/ticket/tickets".into(), + route: "/tickets".into(), message: format!( "enabled Ticket tool `{}` has no native projection", meta.name @@ -1386,7 +1386,7 @@ fn native_ticket_tools( })?; jsonschema::validator_for(&meta.input_schema).map_err(|error| { WipMountError::InvalidProjection { - route: "/features/ticket/tickets".into(), + route: "/tickets".into(), message: format!("Ticket tool schema cannot be retained: {error}"), } })?; @@ -1418,9 +1418,9 @@ pub fn mount_workspace_http_ticket_wip( client: Arc, access: TicketFeatureAccess, permissions: Option, - feature_route: &WipFeatureRoute, + namespace_route: &WipNamespaceRoute, ) -> Result<(), WipMountError> { - let collection_route = feature_route.child("tickets")?; + let collection_route = namespace_route.root().to_string(); let tools = native_ticket_tools(client, access)?; let claimed_tools = tools .iter() @@ -1920,7 +1920,7 @@ fn ticket_operation_declaration( .get("properties") .and_then(Value::as_object) .ok_or_else(|| WipMountError::InvalidProjection { - route: "/features/ticket/tickets".into(), + route: "/tickets".into(), message: format!("Ticket tool `{}` schema has no properties", tool.name), })?; let required = tool @@ -2103,7 +2103,7 @@ mod tests { #[test] fn native_ticket_mount_uses_host_route_and_rejects_non_ticket_children() { let mut registry = WipMountRegistry::new(); - let feature_route = registry.allocate_feature_route("ticket").unwrap(); + let namespace_route = registry.allocate_namespace("ticket", "tickets").unwrap(); mount_workspace_http_ticket_wip( &mut registry, Arc::new(crate::worker::TestWorkspaceHttpClient::new( @@ -2112,20 +2112,21 @@ mod tests { )), TicketFeatureAccess::review(), None, - &feature_route, + &namespace_route, ) .unwrap(); - assert_eq!( - registry.routes().collect::>(), - ["/features/ticket/tickets"] + assert_eq!(registry.routes().collect::>(), ["/tickets"]); + assert!( + registry + .allocate_namespace("ticket", "../objectives") + .is_err() ); - assert!(feature_route.child("../objectives").is_err()); let revisions = Arc::new(Mutex::new(TicketRevisionState::default())); let resolver = TicketItemResolver { tools: HashMap::new(), permissions: None, - collection_route: "/features/ticket/tickets".into(), + collection_route: "/tickets".into(), revisions, }; assert!(resolver.resolve("T-42").is_some()); @@ -2189,7 +2190,7 @@ mod tests { let resolver = TicketItemResolver { tools: HashMap::new(), permissions: None, - collection_route: "/features/ticket/tickets".into(), + collection_route: "/tickets".into(), revisions: Arc::clone(&revisions), }; let unobserved_internal = resolver.resolve("00001TICKET").unwrap().object.validator; @@ -2243,7 +2244,7 @@ mod tests { let resolver = TicketItemResolver { tools: HashMap::new(), permissions: None, - collection_route: "/features/ticket/tickets".into(), + collection_route: "/tickets".into(), revisions: Arc::clone(&revisions), }; let target_v1 = resolver.resolve("T-43").unwrap().object.validator; diff --git a/crates/worker/src/wip.rs b/crates/worker/src/wip.rs index a489cf36e..0e772dafa 100644 --- a/crates/worker/src/wip.rs +++ b/crates/worker/src/wip.rs @@ -68,6 +68,14 @@ pub enum WipMountError { RouteCollision { route: String, existing: String }, #[error("WIP interface `{interface}` has conflicting descriptors")] InterfaceCollision { interface: String }, + #[error("WIP operation `{operation}` on `{route}` is already contributed by `{existing}`")] + OperationCollision { + route: String, + operation: String, + existing: String, + }, + #[error("WIP operation contribution target `{route}` is not a mounted Object")] + OperationTargetNotFound { route: String }, #[error("invalid WIP projection for `{route}`: {message}")] InvalidProjection { route: String, message: String }, } @@ -139,9 +147,21 @@ pub struct WipProjection { pub handler: Arc, } -/// One dynamically resolved direct-child object beneath a Feature-owned -/// collection route. The Host retains route allocation and descriptor authority; -/// the Feature only binds a validated child segment to an object and handler. +/// An additional Feature's operations for an Object whose existence and route +/// are already owned by the Host registry. Contributions may extend the mounted +/// interface, but cannot replace Object resolution or another operation. +#[derive(Clone)] +pub struct WipOperationContribution { + pub route: String, + pub contributor: String, + pub interface: String, + pub descriptor: InterfaceDescriptor, + pub handler: Arc, +} + +/// One dynamically resolved direct-child object beneath a Host-owned collection +/// route. Object resolution stays unique; operation contribution is independent +/// for statically mounted Objects and can be extended without remounting them. pub struct WipDynamicItem { pub object: Object, pub handler: Arc, @@ -160,43 +180,89 @@ pub struct WipDynamicMount { pub resolver: Arc, } +pub trait WipDynamicOperationResolver: Send + Sync { + fn handler(&self, item_reference: &str) -> Arc; +} + +pub struct WipDynamicOperationContribution { + pub collection_route: String, + pub contributor: String, + pub interface: String, + pub descriptor: InterfaceDescriptor, + pub resolver: Arc, +} + +struct MountedDynamicOperationContribution { + contributor: String, + operations: Vec, + resolver: Arc, +} + #[derive(Debug, Clone, PartialEq, Eq)] -pub struct WipFeatureRoute { +pub struct WipNamespaceRoute { root: String, } -impl WipFeatureRoute { +impl WipNamespaceRoute { pub fn root(&self) -> &str { &self.root } - - pub fn child(&self, segment: &str) -> Result { - if segment.is_empty() - || segment == "." - || segment == ".." - || segment.contains('/') - || !segment - .bytes() - .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_')) - { - return Err(WipMountError::InvalidRoute { - route: format!("{}/{}", self.root, segment), - message: "Feature child must be one canonical path segment".into(), - }); - } - Ok(format!("{}/{}", self.root, segment)) - } } struct MountedProjection { projection: WipProjection, + operation_handlers: BTreeMap)>, +} + +impl MountedProjection { + fn resolved(&self) -> WipProjection { + let mut projection = self.projection.clone(); + projection.handler = Arc::new(OperationDispatchHandler { + handlers: self + .operation_handlers + .iter() + .map(|(operation, (_, handler))| (operation.clone(), Arc::clone(handler))) + .collect(), + }); + projection + } +} + +struct OperationDispatchHandler { + handlers: BTreeMap>, +} + +#[async_trait] +impl WipOperationHandler for OperationDispatchHandler { + async fn call( + &self, + operation: &str, + arguments: &BTreeMap, + context: WipCallContext, + ) -> Result { + let handler = self.handlers.get(operation).ok_or_else(|| { + WipOperationError::Protocol(protocol_error( + ProtocolErrorCode::OperationNotFound, + "operation is not published by the selected interface", + )) + })?; + handler.call(operation, arguments, context).await + } + + async fn cancel(&self, context: &ToolExecutionContext) -> Result<(), ToolError> { + for handler in self.handlers.values() { + handler.cancel(context).await?; + } + Ok(()) + } } #[derive(Default)] pub struct WipMountRegistry { mounts: BTreeMap, dynamic_mounts: Vec, - feature_routes: BTreeSet, + dynamic_operation_contributions: BTreeMap>, + namespaces: BTreeMap, replaced_compatibility_capabilities: BTreeMap, } @@ -205,30 +271,57 @@ impl WipMountRegistry { Self::default() } - /// Allocate the Host namespace owned by one enabled Feature. Features join - /// checked relative segments to this route instead of selecting global paths. - pub fn allocate_feature_route( + /// Allocate one Host-owned root namespace to its unique Object provider. + /// The provider identity is registry metadata, never a public path segment. + pub fn allocate_namespace( &mut self, - feature: &str, - ) -> Result { - if feature.is_empty() - || !feature - .bytes() - .all(|byte| byte.is_ascii_lowercase() || byte.is_ascii_digit() || byte == b'-') - { + owner: &str, + namespace: &str, + ) -> Result { + let valid_segment = |value: &str| { + !value.is_empty() + && value + .bytes() + .all(|byte| byte.is_ascii_lowercase() || byte.is_ascii_digit() || byte == b'-') + }; + if !valid_segment(owner) { return Err(WipMountError::InvalidRoute { - route: format!("/features/{feature}"), - message: "Feature name must use lowercase ASCII letters, digits, or '-'".into(), + route: format!("/{namespace}"), + message: "namespace owner must use lowercase ASCII letters, digits, or '-'".into(), }); } - let root = format!("/features/{feature}"); - if !self.feature_routes.insert(root.clone()) { + if !valid_segment(namespace) { + return Err(WipMountError::InvalidRoute { + route: format!("/{namespace}"), + message: "root namespace must be one lowercase ASCII path segment".into(), + }); + } + if matches!(namespace, "features" | "tools") { + return Err(WipMountError::InvalidRoute { + route: format!("/{namespace}"), + message: "the Host reserves this root namespace".into(), + }); + } + let root = format!("/{namespace}"); + if let Some(existing) = self.namespaces.get(&root) { return Err(WipMountError::RouteCollision { route: root, - existing: format!("feature:{feature}"), + existing: format!("namespace owner `{existing}`"), }); } - Ok(WipFeatureRoute { root }) + if self.mounts.contains_key(&root) + || self + .mounts + .keys() + .any(|route| route.starts_with(&format!("{root}/"))) + { + return Err(WipMountError::RouteCollision { + route: root, + existing: "already mounted route".into(), + }); + } + self.namespaces.insert(root.clone(), owner.to_string()); + Ok(WipNamespaceRoute { root }) } pub fn mount( @@ -236,6 +329,30 @@ impl WipMountRegistry { projection: WipProjection, ) -> Result { validate_projection(&projection)?; + if projection.kind == WipProjectionKind::Native + && !projection.route.starts_with(&format!("{WIP_TOOLS_ROOT}/")) + { + let namespace_root = projection + .route + .strip_prefix('/') + .and_then(|path| path.split('/').next()) + .map(|segment| format!("/{segment}")) + .unwrap_or_default(); + let Some(owner) = self.namespaces.get(&namespace_root) else { + return Err(WipMountError::InvalidProjection { + route: projection.route, + message: "native Object route requires an allocated Host namespace".into(), + }); + }; + if projection.capability.split(':').next() != Some(owner.as_str()) { + return Err(WipMountError::InvalidProjection { + route: projection.route, + message: format!( + "native Object capability does not belong to namespace owner `{owner}`" + ), + }); + } + } if self.dynamic_mounts.iter().any(|dynamic| { projection .route @@ -256,8 +373,15 @@ impl WipMountRegistry { match (existing.projection.kind, projection.kind) { (WipProjectionKind::Compatibility, WipProjectionKind::Native) => { self.ensure_interface_available(&projection, Some(&existing.projection.route))?; - self.mounts - .insert(projection.route.clone(), MountedProjection { projection }); + let operation_handlers = + operation_handlers(&projection, &projection.capability); + self.mounts.insert( + projection.route.clone(), + MountedProjection { + projection, + operation_handlers, + }, + ); return Ok(WipMountDisposition::ReplacedCompatibility); } (WipProjectionKind::Native, WipProjectionKind::Compatibility) => { @@ -273,10 +397,99 @@ impl WipMountRegistry { } self.ensure_interface_available(&projection, None)?; let route = projection.route.clone(); - self.mounts.insert(route, MountedProjection { projection }); + let operation_handlers = operation_handlers(&projection, &projection.capability); + self.mounts.insert( + route, + MountedProjection { + projection, + operation_handlers, + }, + ); Ok(WipMountDisposition::Mounted) } + /// Add operations from another Feature without changing Object ownership or + /// resolution. Interface shape and operation names are global protocol + /// contracts, so incompatible declarations fail at registration time. + pub fn contribute_operations( + &mut self, + contribution: WipOperationContribution, + ) -> Result<(), WipMountError> { + contribution + .descriptor + .validate() + .map_err(|error| WipMountError::InvalidProjection { + route: contribution.route.clone(), + message: error.to_string(), + })?; + let Some(mounted) = self.mounts.get(&contribution.route) else { + return Err(WipMountError::OperationTargetNotFound { + route: contribution.route, + }); + }; + if mounted.projection.interface != contribution.interface { + return Err(WipMountError::InterfaceCollision { + interface: contribution.interface, + }); + } + if !same_interface_shape(&mounted.projection.descriptor, &contribution.descriptor) { + return Err(WipMountError::InterfaceCollision { + interface: contribution.interface, + }); + } + for operation in &contribution.descriptor.operations { + if let Some((existing, _)) = mounted.operation_handlers.get(&operation.name) { + return Err(WipMountError::OperationCollision { + route: contribution.route, + operation: operation.name.clone(), + existing: existing.clone(), + }); + } + } + + let mut merged = mounted.projection.descriptor.clone(); + merged + .operations + .extend(contribution.descriptor.operations.iter().cloned()); + merged + .operations + .sort_by(|left, right| left.name.cmp(&right.name)); + merged + .validate() + .map_err(|error| WipMountError::InvalidProjection { + route: contribution.route.clone(), + message: error.to_string(), + })?; + if self.interface_conflicts( + &contribution.interface, + &merged, + Some(&descriptor_validator(&merged)), + Some(&contribution.route), + ) { + return Err(WipMountError::InterfaceCollision { + interface: contribution.interface, + }); + } + + let mounted = self + .mounts + .get_mut(&contribution.route) + .expect("operation contribution target was checked"); + for operation in contribution.descriptor.operations { + mounted.operation_handlers.insert( + operation.name, + ( + contribution.contributor.clone(), + Arc::clone(&contribution.handler), + ), + ); + } + mounted.projection.descriptor = merged; + mounted.projection.interface_validator = + Some(descriptor_validator(&mounted.projection.descriptor)); + Ok(()) + } + /// Mount a one-segment item family below an already mounted collection. /// Dynamic resolution never creates authority: the collection route must be /// native and every resolved child is validated before it is exposed. @@ -344,6 +557,128 @@ impl WipMountRegistry { Ok(()) } + /// Add operations to every item resolved by one existing dynamic Object + /// family. The family keeps exactly one Object resolver; each Feature only + /// supplies handlers for its own disjoint operation names. + pub fn contribute_dynamic_operations( + &mut self, + contribution: WipDynamicOperationContribution, + ) -> Result<(), WipMountError> { + contribution + .descriptor + .validate() + .map_err(|error| WipMountError::InvalidProjection { + route: contribution.collection_route.clone(), + message: error.to_string(), + })?; + let Some(index) = self + .dynamic_mounts + .iter() + .position(|mount| mount.collection_route == contribution.collection_route) + else { + return Err(WipMountError::OperationTargetNotFound { + route: contribution.collection_route, + }); + }; + let mounted = &self.dynamic_mounts[index]; + if mounted.interface != contribution.interface + || !same_interface_shape(&mounted.descriptor, &contribution.descriptor) + { + return Err(WipMountError::InterfaceCollision { + interface: contribution.interface, + }); + } + for operation in &contribution.descriptor.operations { + if mounted + .descriptor + .operations + .iter() + .any(|existing| existing.name == operation.name) + { + let existing = self + .dynamic_operation_contributions + .get(&contribution.collection_route) + .and_then(|contributions| { + contributions.iter().find(|existing| { + existing + .operations + .iter() + .any(|name| name == &operation.name) + }) + }) + .map(|existing| existing.contributor.clone()) + .unwrap_or_else(|| mounted.capability.clone()); + return Err(WipMountError::OperationCollision { + route: contribution.collection_route, + operation: operation.name.clone(), + existing, + }); + } + } + let mut merged = mounted.descriptor.clone(); + merged + .operations + .extend(contribution.descriptor.operations.iter().cloned()); + merged + .operations + .sort_by(|left, right| left.name.cmp(&right.name)); + merged + .validate() + .map_err(|error| WipMountError::InvalidProjection { + route: contribution.collection_route.clone(), + message: error.to_string(), + })?; + let validator = descriptor_validator(&merged); + let conflicts_static = self.registry_interface_conflicts_static( + &contribution.interface, + &merged, + Some(&validator), + ); + let conflicts_dynamic = self.dynamic_mounts.iter().any(|other| { + other.collection_route != contribution.collection_route + && other.interface == contribution.interface + && (other.descriptor != merged + || other.interface_validator.as_deref() != Some(validator.as_slice())) + }); + if conflicts_static || conflicts_dynamic { + return Err(WipMountError::InterfaceCollision { + interface: contribution.interface, + }); + } + + let operations = contribution + .descriptor + .operations + .into_iter() + .map(|operation| operation.name) + .collect(); + let mounted = &mut self.dynamic_mounts[index]; + mounted.descriptor = merged; + mounted.interface_validator = Some(validator); + self.dynamic_operation_contributions + .entry(contribution.collection_route) + .or_default() + .push(MountedDynamicOperationContribution { + contributor: contribution.contributor, + operations, + resolver: contribution.resolver, + }); + Ok(()) + } + + fn registry_interface_conflicts_static( + &self, + interface: &str, + descriptor: &InterfaceDescriptor, + validator: Option<&[u8]>, + ) -> bool { + self.mounts.values().any(|mounted| { + mounted.projection.interface == interface + && (mounted.projection.descriptor != *descriptor + || mounted.projection.interface_validator.as_deref() != validator) + }) + } + /// Declare ordinary tool capabilities fully represented by native mounts. /// Claimed compatibility projections are removed from WIP mode while the /// normal Tool-mode registration remains unchanged. @@ -433,6 +768,35 @@ impl WipMountRegistry { } } +fn operation_handlers( + projection: &WipProjection, + contributor: &str, +) -> BTreeMap)> { + projection + .descriptor + .operations + .iter() + .map(|operation| { + ( + operation.name.clone(), + (contributor.to_string(), Arc::clone(&projection.handler)), + ) + }) + .collect() +} + +fn same_interface_shape(left: &InterfaceDescriptor, right: &InterfaceDescriptor) -> bool { + left.format == right.format + && left.documentation == right.documentation + && left.types == right.types +} + +fn descriptor_validator(descriptor: &InterfaceDescriptor) -> Vec { + let mut digest = Sha256::new(); + digest.update(format!("{descriptor:?}").as_bytes()); + digest.finalize().to_vec() +} + fn validate_projection(projection: &WipProjection) -> Result<(), WipMountError> { wip_protocol::validate_path(&projection.route).map_err(|error| { WipMountError::InvalidRoute { @@ -508,7 +872,7 @@ impl WipHost { fn projection(&self, path: &str) -> Option { if let Some(mounted) = self.registry.mounts.get(path) { - return Some(mounted.projection.clone()); + return Some(mounted.resolved()); } self.registry.dynamic_mounts.iter().find_map(|mounted| { let item_reference = path.strip_prefix(&format!("{}/", mounted.collection_route))?; @@ -516,6 +880,28 @@ impl WipHost { return None; } let item = mounted.resolver.resolve(item_reference)?; + let contributions = self + .registry + .dynamic_operation_contributions + .get(&mounted.collection_route); + let contributed_names = contributions + .into_iter() + .flatten() + .flat_map(|contribution| contribution.operations.iter()) + .collect::>(); + let mut handlers = mounted + .descriptor + .operations + .iter() + .filter(|operation| !contributed_names.contains(&operation.name)) + .map(|operation| (operation.name.clone(), Arc::clone(&item.handler))) + .collect::>(); + for contribution in contributions.into_iter().flatten() { + let handler = contribution.resolver.handler(item_reference); + for operation in &contribution.operations { + handlers.insert(operation.clone(), Arc::clone(&handler)); + } + } let projection = WipProjection { route: path.to_string(), capability: mounted.capability.clone(), @@ -524,7 +910,7 @@ impl WipHost { interface: mounted.interface.clone(), descriptor: mounted.descriptor.clone(), interface_validator: mounted.interface_validator.clone(), - handler: item.handler, + handler: Arc::new(OperationDispatchHandler { handlers }), }; validate_projection(&projection).ok()?; Some(projection) @@ -590,9 +976,9 @@ impl WipHost { } fn observe(&self, path: &str, depth: u32) -> Result { - let object = self.object_at(path).ok_or_else(|| { - protocol_error(ProtocolErrorCode::NotFound, "target path is not published") - })?; + let object = self + .object_at(path) + .ok_or_else(|| unpublished_path_error(path))?; let children = if depth == 0 { None } else { @@ -625,10 +1011,7 @@ impl WipHost { context: WipCallContext, ) -> Result { let projection = self.projection(&request.target.path).ok_or_else(|| { - WipOperationError::Protocol(protocol_error( - ProtocolErrorCode::NotFound, - "target path is not published", - )) + WipOperationError::Protocol(unpublished_path_error(&request.target.path)) })?; let expected_object_validator = projection.object.validator.as_deref(); match ( @@ -707,6 +1090,15 @@ impl WipHost { } } +fn unpublished_path_error(path: &str) -> ProtocolError { + let message = if path == "/features" || path.starts_with("/features/") { + "legacy /features/... routes are no longer published; rediscover from `/` and use the root namespace" + } else { + "target path is not published" + }; + protocol_error(ProtocolErrorCode::NotFound, message) +} + fn protocol_error(code: ProtocolErrorCode, message: impl Into) -> ProtocolError { ProtocolError { code, @@ -1139,9 +1531,8 @@ impl WipRuntime { ) .map_err(ToolError::InvalidArgument)?, None => { - return Err(ToolError::InvalidArgument(format!( - "WIP target `{path}` is not published" - ))); + let error = unpublished_path_error(&path); + return Err(ToolError::InvalidArgument(error.message)); } }; let prepared = { @@ -1956,7 +2347,7 @@ mod tests { use agen::llm_client::{ClientError, Request as LlmRequest, ResponseStream}; use futures::stream; use std::collections::VecDeque; - use std::sync::atomic::{AtomicUsize, Ordering}; + use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering}; use wip_protocol::{EnumCase, FieldDeclaration, TypeDeclaration, UnionCase}; struct EchoTool { @@ -2184,6 +2575,259 @@ mod tests { )); } + struct ContributionHandler { + calls: Arc, + allowed: Arc, + } + + #[async_trait] + impl WipOperationHandler for ContributionHandler { + async fn call( + &self, + _operation: &str, + _arguments: &BTreeMap, + _context: WipCallContext, + ) -> Result { + if !self.allowed.load(Ordering::SeqCst) { + return Err(WipOperationError::Protocol(protocol_error( + ProtocolErrorCode::PermissionDenied, + "current subject is not authorized", + ))); + } + self.calls.fetch_add(1, Ordering::SeqCst); + Ok(WipOperationOutput::native(Value::Unit)) + } + } + + fn contributed_operation(name: &str) -> OperationDeclaration { + OperationDeclaration { + name: name.into(), + documentation: documentation(name), + parameters: Vec::new(), + returns: ReturnDeclaration { + documentation: None, + r#type: TypeExpr::Unit, + }, + } + } + + fn contribution_projection(handler: Arc) -> WipProjection { + let descriptor = InterfaceDescriptor { + format: INTERFACE_FORMAT_V1.into(), + documentation: documentation("shared object interface"), + types: Vec::new(), + operations: vec![contributed_operation("read")], + }; + WipProjection { + route: "/assets/A-1".into(), + capability: "asset:object".into(), + kind: WipProjectionKind::Native, + object: Object { + name: "A-1".into(), + description: Some("host-resolved asset".into()), + interfaces: vec!["test.asset/item/v1".into()], + r#ref: Some("asset:A-1".into()), + validator: Some(vec![1]), + }, + interface: "test.asset/item/v1".into(), + interface_validator: Some(descriptor_validator(&descriptor)), + descriptor, + handler, + } + } + + #[tokio::test] + async fn host_namespace_object_resolution_and_operation_contributions_are_independent() { + let read_calls = Arc::new(AtomicUsize::new(0)); + let manage_calls = Arc::new(AtomicUsize::new(0)); + let read_allowed = Arc::new(AtomicBool::new(true)); + let manage_allowed = Arc::new(AtomicBool::new(false)); + let mut registry = WipMountRegistry::new(); + assert!(matches!( + registry.allocate_namespace("host", "features"), + Err(WipMountError::InvalidRoute { .. }) + )); + assert!(matches!( + registry.allocate_namespace("host", "tools"), + Err(WipMountError::InvalidRoute { .. }) + )); + let namespace = registry.allocate_namespace("asset", "assets").unwrap(); + assert_eq!(namespace.root(), "/assets"); + assert!(matches!( + registry.allocate_namespace("other", "assets"), + Err(WipMountError::RouteCollision { .. }) + )); + registry + .mount(contribution_projection(Arc::new(ContributionHandler { + calls: Arc::clone(&read_calls), + allowed: Arc::clone(&read_allowed), + }))) + .unwrap(); + + let host_without_management = WipHost::new(registry); + let resolved = host_without_management.projection("/assets/A-1").unwrap(); + assert_eq!(resolved.object.r#ref.as_deref(), Some("asset:A-1")); + assert_eq!( + resolved + .descriptor + .operations + .iter() + .map(|operation| operation.name.as_str()) + .collect::>(), + ["read"] + ); + + let mut registry = host_without_management.registry; + let contribution_descriptor = InterfaceDescriptor { + format: INTERFACE_FORMAT_V1.into(), + documentation: documentation("shared object interface"), + types: Vec::new(), + operations: vec![contributed_operation("manage")], + }; + registry + .contribute_operations(WipOperationContribution { + route: "/assets/A-1".into(), + contributor: "asset-management".into(), + interface: "test.asset/item/v1".into(), + descriptor: contribution_descriptor.clone(), + handler: Arc::new(ContributionHandler { + calls: Arc::clone(&manage_calls), + allowed: Arc::clone(&manage_allowed), + }), + }) + .unwrap(); + assert!(matches!( + registry.contribute_operations(WipOperationContribution { + route: "/assets/A-1".into(), + contributor: "duplicate-management".into(), + interface: "test.asset/item/v1".into(), + descriptor: contribution_descriptor, + handler: Arc::new(ContributionHandler { + calls: Arc::clone(&manage_calls), + allowed: Arc::clone(&manage_allowed), + }), + }), + Err(WipMountError::OperationCollision { .. }) + )); + let host = WipHost::new(registry); + let projection = host.projection("/assets/A-1").unwrap(); + assert_eq!( + projection + .descriptor + .operations + .iter() + .map(|operation| operation.name.as_str()) + .collect::>(), + ["manage", "read"] + ); + + let request = |operation: &str| CallOperationRequest { + target: wip_protocol::Target { + path: "/assets/A-1".into(), + validator: projection.object.validator.clone(), + }, + interface: wip_protocol::InterfaceTarget { + reference: projection.interface.clone(), + validator: projection.interface_validator.clone(), + }, + operation: operation.into(), + arguments: BTreeMap::new(), + }; + let context = || WipCallContext { + execution: ToolExecutionContext::direct(), + security_context: "worker-a".into(), + }; + assert!(matches!( + host.call(request("manage"), context()).await, + Err(WipOperationError::Protocol(ProtocolError { + code: ProtocolErrorCode::PermissionDenied, + .. + })) + )); + manage_allowed.store(true, Ordering::SeqCst); + host.call(request("manage"), context()) + .await + .unwrap_or_else(|_| panic!("authorized management contribution should run")); + manage_allowed.store(false, Ordering::SeqCst); + assert!(matches!( + host.call(request("manage"), context()).await, + Err(WipOperationError::Protocol(ProtocolError { + code: ProtocolErrorCode::PermissionDenied, + .. + })) + )); + host.call(request("read"), context()) + .await + .unwrap_or_else(|_| panic!("independent read contribution should run")); + assert_eq!(manage_calls.load(Ordering::SeqCst), 1); + assert_eq!(read_calls.load(Ordering::SeqCst), 1); + } + + #[test] + fn operation_contributions_reject_interface_and_target_conflicts() { + let calls = Arc::new(AtomicUsize::new(0)); + let allowed = Arc::new(AtomicBool::new(true)); + let handler: Arc = + Arc::new(ContributionHandler { calls, allowed }); + let mut registry = WipMountRegistry::new(); + let projection = contribution_projection(Arc::clone(&handler)); + assert!(matches!( + registry.mount(projection.clone()), + Err(WipMountError::InvalidProjection { .. }) + )); + registry.allocate_namespace("asset", "assets").unwrap(); + registry.mount(projection).unwrap(); + let mut inconsistent = InterfaceDescriptor { + format: INTERFACE_FORMAT_V1.into(), + documentation: documentation("different interface contract"), + types: Vec::new(), + operations: vec![contributed_operation("manage")], + }; + assert!(matches!( + registry.contribute_operations(WipOperationContribution { + route: "/assets/A-1".into(), + contributor: "management".into(), + interface: "test.asset/item/v1".into(), + descriptor: inconsistent.clone(), + handler: Arc::clone(&handler), + }), + Err(WipMountError::InterfaceCollision { .. }) + )); + inconsistent.documentation = documentation("shared object interface"); + assert!(matches!( + registry.contribute_operations(WipOperationContribution { + route: "/assets/A-2".into(), + contributor: "management".into(), + interface: "test.asset/item/v1".into(), + descriptor: inconsistent, + handler, + }), + Err(WipMountError::OperationTargetNotFound { .. }) + )); + } + + #[tokio::test] + async fn legacy_feature_routes_fail_with_rediscovery_guidance() { + let host = WipHost::new(WipMountRegistry::new()); + let error = host.observe("/features/ticket/tickets/T-1", 0).unwrap_err(); + assert_eq!(error.code, ProtocolErrorCode::NotFound); + assert!(error.message.contains("rediscover from `/`")); + assert!(error.message.contains("no longer published")); + + let runtime = WipRuntime::new(host, SecurityContext::new("worker-a"), 0).unwrap(); + let error = runtime + .call( + "/features/ticket/tickets/T-1".into(), + "yoi.ticket/item/v1".into(), + "read".into(), + json!({}), + ToolExecutionContext::direct(), + ) + .await + .unwrap_err(); + assert!(error.to_string().contains("rediscover from `/`")); + } + struct NativeTyped; #[async_trait] @@ -2338,6 +2982,7 @@ mod tests { #[tokio::test] async fn native_projection_preserves_descriptor_and_decodes_typed_arguments_end_to_end() { let mut registry = WipMountRegistry::new(); + registry.allocate_namespace("native", "native").unwrap(); registry.mount(native_typed_projection()).unwrap(); let runtime = WipRuntime::new(WipHost::new(registry), SecurityContext::new("worker-a"), 0).unwrap(); @@ -2600,10 +3245,26 @@ mod tests { } } + struct DynamicContributionResolver { + calls: Arc, + allowed: Arc, + } + + impl WipDynamicOperationResolver for DynamicContributionResolver { + fn handler(&self, _item_reference: &str) -> Arc { + Arc::new(ContributionHandler { + calls: Arc::clone(&self.calls), + allowed: Arc::clone(&self.allowed), + }) + } + } + fn dynamic_registry(revision: Arc, calls: Arc) -> WipMountRegistry { let mut registry = WipMountRegistry::new(); - let feature = registry.allocate_feature_route("objective").unwrap(); - let collection_route = feature.child("objectives").unwrap(); + let namespace = registry + .allocate_namespace("objective", "objectives") + .unwrap(); + let collection_route = namespace.root().to_string(); let mut collection = compatibility_projection( meta("Dynamic"), Arc::new(EchoTool { @@ -2616,8 +3277,8 @@ mod tests { collection.capability = "objective:collection".into(); collection.kind = WipProjectionKind::Native; collection.object.name = "objectives".into(); - collection.object.interfaces = vec!["test.dynamic/v1".into()]; - collection.interface = "test.dynamic/v1".into(); + collection.object.interfaces = vec!["test.dynamic.collection/v1".into()]; + collection.interface = "test.dynamic.collection/v1".into(); collection.handler = Arc::new(DynamicHandler { calls: Arc::clone(&calls), }); @@ -2642,17 +3303,9 @@ mod tests { let revision = Arc::new(AtomicUsize::new(1)); let calls = Arc::new(AtomicUsize::new(0)); let host = WipHost::new(dynamic_registry(Arc::clone(&revision), Arc::clone(&calls))); - let initial = host - .projection("/features/objective/objectives/O-3") - .unwrap(); - assert!( - host.projection("/features/objective/objectives/O-3/other") - .is_none() - ); - assert!( - host.projection("/features/objective/objectives/T-3") - .is_none() - ); + let initial = host.projection("/objectives/O-3").unwrap(); + assert!(host.projection("/objectives/O-3/other").is_none()); + assert!(host.projection("/objectives/T-3").is_none()); assert!(host.projection("/hidden/objectives/O-3").is_none()); revision.store(2, Ordering::SeqCst); @@ -2660,7 +3313,7 @@ mod tests { .call( CallOperationRequest { target: wip_protocol::Target { - path: "/features/objective/objectives/O-3".into(), + path: "/objectives/O-3".into(), validator: initial.object.validator, }, interface: wip_protocol::InterfaceTarget { @@ -2689,6 +3342,65 @@ mod tests { assert_eq!(calls.load(Ordering::SeqCst), 0); } + #[tokio::test] + async fn dynamic_object_resolver_accepts_disjoint_feature_operations() { + let revision = Arc::new(AtomicUsize::new(1)); + let base_calls = Arc::new(AtomicUsize::new(0)); + let contribution_calls = Arc::new(AtomicUsize::new(0)); + let allowed = Arc::new(AtomicBool::new(true)); + let mut registry = dynamic_registry(revision, base_calls); + let mounted = registry + .dynamic_mounts + .first() + .expect("dynamic object family is mounted"); + let mut descriptor = mounted.descriptor.clone(); + descriptor.operations = vec![contributed_operation("manage")]; + registry + .contribute_dynamic_operations(WipDynamicOperationContribution { + collection_route: "/objectives".into(), + contributor: "objective-management".into(), + interface: "test.dynamic/v1".into(), + descriptor, + resolver: Arc::new(DynamicContributionResolver { + calls: Arc::clone(&contribution_calls), + allowed, + }), + }) + .unwrap(); + let host = WipHost::new(registry); + let projection = host.projection("/objectives/O-7").unwrap(); + assert_eq!( + projection + .descriptor + .operations + .iter() + .map(|operation| operation.name.as_str()) + .collect::>(), + ["call", "manage"] + ); + host.call( + CallOperationRequest { + target: wip_protocol::Target { + path: "/objectives/O-7".into(), + validator: projection.object.validator, + }, + interface: wip_protocol::InterfaceTarget { + reference: projection.interface, + validator: projection.interface_validator, + }, + operation: "manage".into(), + arguments: BTreeMap::new(), + }, + WipCallContext { + execution: ToolExecutionContext::direct(), + security_context: "worker-a".into(), + }, + ) + .await + .unwrap_or_else(|_| panic!("dynamic contributed operation should run")); + assert_eq!(contribution_calls.load(Ordering::SeqCst), 1); + } + #[test] fn native_capability_claim_hides_only_claimed_compatibility_route() { let calls = Arc::new(AtomicUsize::new(0)); @@ -2699,18 +3411,13 @@ mod tests { let revision = Arc::new(AtomicUsize::new(1)); let mut registry = dynamic_registry(revision, calls); registry - .replace_compatibility_tools("/features/objective/objectives", ["Echo"]) + .replace_compatibility_tools("/objectives", ["Echo"]) .unwrap(); let runtime = install_wip_mode_with_mounts(&mut engine, None, "worker-a".into(), registry).unwrap(); assert!(runtime.host.projection("/tools/Echo").is_none()); assert!(runtime.host.projection("/tools/Other").is_some()); - assert!( - runtime - .host - .projection("/features/objective/objectives") - .is_some() - ); + assert!(runtime.host.projection("/objectives").is_some()); } #[tokio::test] @@ -2752,7 +3459,9 @@ mod tests { authoring_engine.register_tool(definition); } let mut authoring_registry = WipMountRegistry::new(); - let route = authoring_registry.allocate_feature_route("ticket").unwrap(); + let route = authoring_registry + .allocate_namespace("ticket", "tickets") + .unwrap(); crate::feature::builtin::ticket::mount_workspace_http_ticket_wip( &mut authoring_registry, authoring_client.clone(), @@ -2771,14 +3480,15 @@ mod tests { let discovered = authoring.discover("/".into(), 3, false).await.unwrap(); let discovered = discovered.content.unwrap(); - assert!(discovered.contains("/features/ticket/tickets")); + assert!(discovered.contains("/tickets")); + assert!(!discovered.contains("/features")); assert!(!discovered.contains("/tools/QueryTicket")); assert!(!discovered.contains("/tools/TicketCreate")); authoring .inspect("yoi.ticket/collection/v1".into(), false) .await .unwrap(); - let collection = "/features/ticket/tickets"; + let collection = "/tickets"; authoring .call( collection.into(), @@ -2799,13 +3509,8 @@ mod tests { ) .await .unwrap(); - assert!( - created - .content - .unwrap() - .contains("/features/ticket/tickets/T-9") - ); - let item = "/features/ticket/tickets/T-9"; + assert!(created.content.unwrap().contains("/tickets/T-9")); + let item = "/tickets/T-9"; authoring.discover(item.into(), 0, false).await.unwrap(); authoring .inspect("yoi.ticket/item/v1".into(), false) @@ -2862,7 +3567,9 @@ mod tests { workflow_engine.register_tool(definition); } let mut workflow_registry = WipMountRegistry::new(); - let route = workflow_registry.allocate_feature_route("ticket").unwrap(); + let route = workflow_registry + .allocate_namespace("ticket", "tickets") + .unwrap(); crate::feature::builtin::ticket::mount_workspace_http_ticket_wip( &mut workflow_registry, workflow_client.clone(), @@ -2963,7 +3670,7 @@ mod tests { } let mut coder_registry = WipMountRegistry::new(); let route = coder_registry - .allocate_feature_route("merge-request") + .allocate_namespace("merge-request", "merge-requests") .unwrap(); crate::feature::builtin::merge_request::mount_workspace_http_merge_request_wip( &mut coder_registry, @@ -2982,14 +3689,14 @@ mod tests { .unwrap(); let discovered = coder.discover("/".into(), 3, false).await.unwrap(); let discovered = discovered.content.unwrap(); - assert!(discovered.contains("/features/merge-request/merge-requests")); + assert!(discovered.contains("/merge-requests")); assert!(!discovered.contains("/tools/OpenMergeRequest")); assert!(!discovered.contains("/tools/ShowMergeRequest")); coder .inspect("yoi.merge-request/collection/v1".into(), false) .await .unwrap(); - let collection = "/features/merge-request/merge-requests"; + let collection = "/merge-requests"; let opened = coder .call( collection.into(), @@ -3085,7 +3792,7 @@ mod tests { } let mut reviewer_registry = WipMountRegistry::new(); let route = reviewer_registry - .allocate_feature_route("merge-request") + .allocate_namespace("merge-request", "merge-requests") .unwrap(); crate::feature::builtin::merge_request::mount_workspace_http_merge_request_wip( &mut reviewer_registry, @@ -3174,7 +3881,7 @@ mod tests { } let mut orchestrator_registry = WipMountRegistry::new(); let route = orchestrator_registry - .allocate_feature_route("merge-request") + .allocate_namespace("merge-request", "merge-requests") .unwrap(); crate::feature::builtin::merge_request::mount_workspace_http_merge_request_wip( &mut orchestrator_registry, @@ -3348,12 +4055,14 @@ mod tests { }], }); let mut registry = WipMountRegistry::new(); - let feature_route = registry.allocate_feature_route("objective").unwrap(); + let namespace_route = registry + .allocate_namespace("objective", "objectives") + .unwrap(); crate::feature::builtin::objective::mount_workspace_http_objective_wip( &mut registry, client.clone(), permissions.clone(), - &feature_route, + &namespace_route, ) .unwrap(); let runtime = @@ -3362,13 +4071,13 @@ mod tests { let discovered = runtime.discover("/".into(), 3, false).await.unwrap(); let discovered = discovered.content.unwrap(); - assert!(discovered.contains("/features/objective/objectives")); + assert!(discovered.contains("/objectives")); assert!(!discovered.contains("/tools/QueryObjective")); runtime .inspect("yoi.objective/collection/v1".into(), false) .await .unwrap(); - let collection_path = "/features/objective/objectives"; + let collection_path = "/objectives"; runtime .call( collection_path.into(), @@ -3401,14 +4110,9 @@ mod tests { ) .await .unwrap(); - assert!( - created - .content - .unwrap() - .contains("/features/objective/objectives/O-3") - ); + assert!(created.content.unwrap().contains("/objectives/O-3")); - let item_path = "/features/objective/objectives/O-3"; + let item_path = "/objectives/O-3"; runtime.discover(item_path.into(), 0, false).await.unwrap(); runtime .inspect("yoi.objective/item/v1".into(), false) diff --git a/docs/README.md b/docs/README.md index 91da1a8d8..3dbc518d7 100644 --- a/docs/README.md +++ b/docs/README.md @@ -35,6 +35,8 @@ It is not a dumping ground for external research, old plans, API inventories, or 27. [`development/subjektiv-product-cutover.md`](development/subjektiv-product-cutover.md) — legacy Workspace Memoryからsubjektivへのoperator cutover、explicit reset、backup/rollback runbook。 28. [`design/worker-wip-mode.md`](design/worker-wip-mode.md) — explicit-opt-in Worker WIP mode, Worldspace projection, authority, cache, and retry contracts. 29. [`design/objective-wip-projection.md`](design/objective-wip-projection.md) — Host-assigned Objective collection/item routes, operation mapping, Backend authority, bounds, and compatibility suppression. +30. [`design/ticket-wip-projection.md`](design/ticket-wip-projection.md) — Host-assigned Ticket collection/item routes, role-specific operations, route identity, and typed Backend preservation. +31. [`design/merge-request-wip-projection.md`](design/merge-request-wip-projection.md) — Host-assigned Merge Request routes and preserved review/integration authority gates. ## What belongs here diff --git a/docs/design/merge-request-wip-projection.md b/docs/design/merge-request-wip-projection.md index 3879b8054..4d314a7a2 100644 --- a/docs/design/merge-request-wip-projection.md +++ b/docs/design/merge-request-wip-projection.md @@ -4,13 +4,15 @@ When a Worker explicitly runs in `wip` mode and its Merge Request Feature has at least one enabled operation, the Worker Host projects that exact operation set as native Worldspace objects. The projection is an adapter over the existing scoped Workspace Merge Request tools and Backend routes. It does not create a second Merge Request store, provider integration path, review grant, approval rule, or completion authority. -The Host allocates `/features/merge-request` and the Feature mounts: +The Host assigns the Merge Request provider the root `merge-requests` namespace through `WipMountRegistry` and mounts: ```text -/features/merge-request/merge-requests -/features/merge-request/merge-requests/ +/merge-requests +/merge-requests/ ``` +There is no Feature Object or `/features/merge-request` compatibility alias. Legacy paths remain historical display data only and fail as current input with rediscovery guidance. + An item identity is a single canonical path segment. Nested paths and percent-encoded target substitution do not resolve. Item operations remove `merge_request_id` from their descriptor and reconstruct it only from the selected object route. Discovery does not mint authority: every call still uses the Worker's scoped `WorkspaceClient`, the enabled Feature flags, ordinary Tool permission rules, and the Backend's Workspace, repository, assignment, provider-ref, review, and integration checks. ## Current Feature inventory diff --git a/docs/design/objective-wip-projection.md b/docs/design/objective-wip-projection.md index a9316e5cc..20c0fe6e3 100644 --- a/docs/design/objective-wip-projection.md +++ b/docs/design/objective-wip-projection.md @@ -4,13 +4,15 @@ When a Worker explicitly runs in `wip` mode and the Objective Feature is enabled, the Worker Host projects Objectives as native Worldspace objects. The projection is an adapter over the existing scoped Workspace API. It does not introduce another Objective store, state machine, reference resolver, or audit path. -The Host allocates `/features/objective` to the Feature through `WipMountRegistry`. The Objective Feature then mounts: +The Host assigns the Objective provider the root `objectives` namespace through `WipMountRegistry` and mounts: ```text -/features/objective/objectives -/features/objective/objectives/ +/objectives +/objectives/ ``` +There is no Feature Object or `/features/objective` compatibility alias. Legacy paths are retained only as historical text and fail as current inputs with rediscovery guidance. + `` is either a canonical `O-` key or an existing canonical internal alphanumeric ID. Only one direct child segment is accepted. Nested suffixes, Ticket keys, and unrelated or hidden prefixes do not resolve as Objective objects. The Backend remains authoritative for existence, `O-*`/internal-ID resolution, Workspace scope, validation, audit events, revision generation, pagination, body/event bounds, and linked-Ticket validation. A discovered route is not independent authority; every operation still uses the Worker's current `WorkspaceClient`. diff --git a/docs/design/ticket-wip-projection.md b/docs/design/ticket-wip-projection.md index 34f021108..6a76194c2 100644 --- a/docs/design/ticket-wip-projection.md +++ b/docs/design/ticket-wip-projection.md @@ -4,13 +4,15 @@ When a Worker explicitly runs in `wip` mode and its Ticket Feature is enabled, the Worker Host projects the enabled Ticket surface as native Worldspace objects. The projection is an adapter over the existing scoped Workspace Ticket API and typed Ticket tools. It does not add a Ticket store, resolver, audit path, state machine, readiness rule, queue path, or relation implementation. -The Host allocates `/features/ticket` and the Feature mounts: +The Host assigns the Ticket provider the root `tickets` namespace through `WipMountRegistry` and mounts: ```text -/features/ticket/tickets -/features/ticket/tickets/ +/tickets +/tickets/ ``` +There is no Feature Object or `/features/ticket` compatibility alias. A legacy path supplied as current input fails with rediscovery guidance; saved history containing that text is left unchanged. + `` is either a canonical `T-` key or an existing canonical internal alphanumeric ID. Only a direct child is accepted. Nested suffixes, Objective keys, unrelated prefixes, and hidden routes do not resolve. Every call retains the Worker's scoped `WorkspaceClient`, current Ticket Feature access, and ordinary Tool permission policy. Discovery is not mutation authority. A direct call to an unpublished operation fails even when the caller knows its name. `deny` and interactive `ask` permission outcomes fail closed before Backend dispatch. diff --git a/docs/design/worker-wip-mode.md b/docs/design/worker-wip-mode.md index 012a5d768..6c7a472c2 100644 --- a/docs/design/worker-wip-mode.md +++ b/docs/design/worker-wip-mode.md @@ -52,21 +52,36 @@ The interface descriptor uses WIP `Json` for compatibility input and includes th ## Native projection extension and collisions -`worker::wip::WipMountRegistry` is the Host-owned mount allocator. A Feature can independently construct a `WipProjection` with an async `WipOperationHandler`, then pass its aggregate registry through `install_wip_mode_with_mounts` before compatibility finalization. A Feature first requests a Host-owned `/features/` namespace with `allocate_feature_route`; checked direct-child joins prevent a Feature from claiming an ambient global route. +`worker::wip::WipMountRegistry` is the Host-owned namespace, Object, and operation registry. A provider requests a root namespace with `allocate_namespace(owner, namespace)`; the owner is retained as Host metadata and is not inserted into the public path. It then mounts the collection Object directly at `/` and may install a bounded direct-child resolver. Features do not appear as intermediate Objects. -- Routes must be canonical absolute WIP paths. +Object resolution and operations are separate contracts. One mounted `WipProjection` owns a static Object route and its initial operations. Another Feature may add operations with `contribute_operations` without replacing that Object. Dynamic families likewise keep one `WipDynamicItemResolver`, while `contribute_dynamic_operations` supplies disjoint handlers for every resolved item without a second resolver. The Host merges only contributions with the same interface format, documentation, and type declarations; duplicate operation names, inconsistent interfaces/validators, missing targets, duplicate namespace owners, and unrelated route claims fail deterministically at registration. The merged interface validator changes with the effective operation set, so stale Known Space cannot authorize a newly contributed operation. + +- Routes must be canonical absolute WIP paths, and allocated namespaces are one lowercase root segment. - Object names must match the final route segment. -- A projected object exposes exactly its declared interface. -- Unrelated duplicate routes and conflicting interface descriptors or validators fail startup. +- Object existence and resolution have one owner; operation contributors cannot remount or substitute the Object. +- Multiple Features may contribute disjoint operations to the same Object interface. +- Unrelated duplicate routes, duplicate operation names, and conflicting interface shapes fail startup. - A bounded dynamic mount may resolve exactly one item segment below an already mounted native collection; deeper paths never fall through to the resolver. - A native projection replaces a compatibility projection either by the original same-route semantic capability or by an explicit registry capability claim owned by a mounted native route. - Conflicting native claims fail startup. When native is selected, the claimed compatibility tool is hidden rather than exposed through a second entry. Normal Tool mode is unaffected because it does not install the WIP registry. -`WipInspect` renders the complete descriptor, including descriptor-local named declarations and recursive record, list, enum, and union shapes with required flags and declaration documentation. The unit samples `mount_collision_and_native_replacement_are_explicit` and `native_projection_preserves_descriptor_and_decodes_typed_arguments_end_to_end` demonstrate independent native registration, deterministic replacement, collision rejection, complete descriptor inspection, and a descriptor-typed native invocation. +`WipInspect` renders the complete effective descriptor, including contributed operations, descriptor-local named declarations, and recursive record, list, enum, and union shapes with required flags and declaration documentation. The tests `host_namespace_object_resolution_and_operation_contributions_are_independent`, `operation_contributions_reject_interface_and_target_conflicts`, and `native_projection_preserves_descriptor_and_decodes_typed_arguments_end_to_end` demonstrate root allocation, unique resolution, contribution merging, collision rejection, current call-time authorization, complete descriptor inspection, and descriptor-typed invocation. + +The built-in native route migration is: + +| Historical route | Canonical route | +| --- | --- | +| `/features/ticket/tickets[/]` | `/tickets[/]` | +| `/features/objective/objectives[/]` | `/objectives[/]` | +| `/features/merge-request/merge-requests[/]` | `/merge-requests[/]` | + +`/repositories` and `/workdirs` are reserved for a later provider using the same registration contract; this layer does not implement those domains. `/checkouts` content projection is likewise outside this contract. ## Authority and execution -Discovery is not authority. The Worldspace contains only the tools already registered from enabled Features and the Worker's current execution context. Calling a compatibility operation: +Discovery is not authority. Object publication, operation implementation, target capability, and subject permission are independent checks. An Object may remain discoverable through an enabled read provider when a management Feature contributes no operations; conversely, publishing an operation never bypasses the target's current capability or the subject's current permission. Every handler re-evaluates permission at call time, so revocation after discovery is rejected before provider dispatch. Feature enablement selects implementations and does not itself grant read or mutation authority. + +The Worldspace contains only the operations registered from enabled Features and the Worker's current execution context. Calling a compatibility operation: 1. resolves the exact mounted route; 2. verifies object and interface membership and validators; @@ -85,6 +100,8 @@ The stateful `wip-client::Client` is owned by one Worker WIP runtime. Sessions a Known Space and interface observations are therefore not shared across Workers, endpoints, or security contexts. A restored Worker creates a new runtime/client and must explore again. `WipDiscover(reset = true)` drops all observations before reconnect/authority-change exploration; `refresh = true` explicitly supersedes one cached observation. A normal compaction keeps the live runtime but the three gateway schemas are supplied again on every LLM request, including the instruction to begin discovery at `/`; the compacted transcript is not treated as cache authority. +Saved Session/history entries remain append-only evidence and are never rewritten or replayed as calls. In particular, historical `/features//...` text stays displayable but is not a current route alias. Passing such a legacy path to discovery or call returns `NotFound` with guidance to rediscover from `/`; the Host does not translate it, resolve it under another namespace, or expose old and new routes in parallel. + ## Errors, cancellation, and retries The adapter keeps these outcomes distinct: