From e5f255b6fd7900afd6a5c34237d04a70be580e8f Mon Sep 17 00:00:00 2001 From: Hare Date: Sun, 4 Oct 2026 01:28:45 +0900 Subject: [PATCH] feat(worker): project objectives as native WIP objects --- crates/worker/src/controller.rs | 54 +- .../worker/src/feature/builtin/objective.rs | 1339 +++++++++++++++-- .../feature/builtin/resource_projection.rs | 14 +- crates/worker/src/wip.rs | 445 +++++- docs/README.md | 1 + docs/design/objective-wip-projection.md | 55 + docs/design/worker-wip-mode.md | 9 +- 7 files changed, 1756 insertions(+), 161 deletions(-) create mode 100644 docs/design/objective-wip-projection.md diff --git a/crates/worker/src/controller.rs b/crates/worker/src/controller.rs index 5632bdd1a..8d375c526 100644 --- a/crates/worker/src/controller.rs +++ b/crates/worker/src/controller.rs @@ -1551,13 +1551,16 @@ where } let host_worker_observation_provider = worker.worker_observation_provider(); + let wip_mode = worker.manifest().worker.mode == manifest::WorkerMode::Wip; + let wip_permissions = worker.manifest().permissions.clone(); + let mut wip_mount_registry = crate::wip::WipMountRegistry::new(); { let workspace_client = worker.workspace_client_handle(); let engine = worker.engine_mut(); - // Objective tools expose read-only project Objective context through the - // Backend Workspace API. Workers must not guess local `.yoi/objectives` - // paths or read Objective files directly. + // Objective tools expose scoped project Objective reads and mutations through + // the Backend Workspace API. Workers must not guess local `.yoi/objectives` + // paths or read/edit Objective files directly. if feature_config.objective.enabled { if workspace_client.is_available() && workspace_client.workspace_id().is_some() { for definition in crate::feature::builtin::objective::workspace_http_objective_tools( @@ -1565,6 +1568,28 @@ where ) { engine.register_tool(definition); } + if wip_mode { + let feature_route = wip_mount_registry + .allocate_feature_route("objective") + .map_err(|error| { + std::io::Error::new( + std::io::ErrorKind::InvalidInput, + format!("allocate Objective WIP route: {error}"), + ) + })?; + crate::feature::builtin::objective::mount_workspace_http_objective_wip( + &mut wip_mount_registry, + workspace_client.clone(), + wip_permissions.clone(), + &feature_route, + ) + .map_err(|error| { + std::io::Error::new( + std::io::ErrorKind::InvalidInput, + format!("mount Objective WIP projection: {error}"), + ) + })?; + } } else { return Err(std::io::Error::new( std::io::ErrorKind::InvalidInput, @@ -1631,22 +1656,25 @@ where if let Some((resident_summary, system_prompt_override)) = feature_prompt_contribution { worker.install_system_prompt_contribution(resident_summary, system_prompt_override); } - if worker.manifest().worker.mode == manifest::WorkerMode::Wip { - let permissions = worker.manifest().permissions.clone(); + if wip_mode { let security_context = format!( "workspace={};worker={};session={}", worker.workspace_id().map_or("standalone", |id| id.as_str()), worker.manifest().worker.name, worker.session_id() ); - crate::wip::install_wip_mode(worker.engine_mut(), permissions, security_context).map_err( - |error| { - std::io::Error::new( - std::io::ErrorKind::InvalidInput, - format!("install WIP Worker surface: {error}"), - ) - }, - )?; + crate::wip::install_wip_mode_with_mounts( + worker.engine_mut(), + wip_permissions, + security_context, + wip_mount_registry, + ) + .map_err(|error| { + std::io::Error::new( + std::io::ErrorKind::InvalidInput, + format!("install WIP Worker surface: {error}"), + ) + })?; } worker.attach_tracker(tracker); Ok(workdir_for_view) diff --git a/crates/worker/src/feature/builtin/objective.rs b/crates/worker/src/feature/builtin/objective.rs index 44dbceb08..6db67e5d3 100644 --- a/crates/worker/src/feature/builtin/objective.rs +++ b/crates/worker/src/feature/builtin/objective.rs @@ -5,13 +5,26 @@ //! local `.yoi/objectives` paths, so model-visible Objective tools go through //! the scoped Workspace API. -use std::sync::Arc; +use std::collections::{BTreeMap, HashMap}; +use std::sync::{Arc, Mutex}; use agen::tool::{Tool, ToolDefinition, ToolError, ToolExecutionContext, ToolMeta, ToolOutput}; use async_trait::async_trait; +use manifest::{ToolPermissionAction, ToolPermissionConfig}; use serde::{Deserialize, Serialize}; -use serde_json::json; +use serde_json::{Value as Json, json}; +use sha2::{Digest, Sha256}; +use wip_protocol::{ + Documentation, INTERFACE_FORMAT_V1, InterfaceDescriptor, Object, OperationDeclaration, + ParameterDeclaration, ProtocolError, ProtocolErrorCode, ReturnDeclaration, TypeExpr, Value, +}; +use crate::permission::permission_action_for; +use crate::wip::{ + WipCallContext, WipDynamicItem, WipDynamicItemResolver, WipDynamicMount, WipFeatureRoute, + WipMountError, WipMountRegistry, WipOperationError, WipOperationHandler, WipOperationOutput, + WipProjection, WipProjectionKind, json_to_wip, +}; use crate::worker::{WorkspaceClient, WorkspaceRequest, WorkspaceRequestMethod}; use super::resource_projection::{project_objective_detail, project_objective_query}; @@ -26,7 +39,10 @@ impl WorkspaceHttpObjectiveBackend { Self { client } } - async fn list(&self, input: QueryObjectiveInput) -> Result { + async fn query_value( + &self, + input: &QueryObjectiveInput, + ) -> Result { let url = format!( "/api/w/{}/objectives/query", self.client.workspace_id().unwrap_or_default() @@ -35,11 +51,16 @@ impl WorkspaceHttpObjectiveBackend { self.client.as_ref(), reqwest::Method::POST, &url, - &input, + input, ) - .await - .map_err(backend_error)?; - let response = project_objective_query(response).map_err(ToolError::ExecutionFailed)?; + .await?; + let response = project_objective_query(response) + .map_err(WorkspaceObjectiveBackendError::Projection)?; + serde_json::to_value(response).map_err(Into::into) + } + + async fn list(&self, input: QueryObjectiveInput) -> Result { + let response = self.query_value(&input).await.map_err(backend_error)?; Ok(ToolOutput { summary: "Queried Objectives".to_string(), content: Some(serde_json::to_string_pretty(&response).map_err(decode_error)?), @@ -47,22 +68,39 @@ impl WorkspaceHttpObjectiveBackend { }) } - async fn show(&self, input: ShowObjectiveInput) -> Result { - let id = validate_id(&input.id, "ShowObjective")?; + async fn read_value( + &self, + id: &str, + event_limit: Option, + event_cursor: Option, + ) -> Result { + let id = validate_backend_id(id)?; let url = format!("{}/show", self.objective_url(id)); let response = send_json::( self.client.as_ref(), reqwest::Method::POST, &url, &ObjectiveShowRequest { - event_limit: input.event_limit, - event_cursor: input.event_cursor, + event_limit, + event_cursor, }, ) - .await - .map_err(backend_error)?; - let response = project_objective_detail(response).map_err(ToolError::ExecutionFailed)?; - let objective_ref = response.objective_ref().to_string(); + .await?; + let response = project_objective_detail(response) + .map_err(WorkspaceObjectiveBackendError::Projection)?; + serde_json::to_value(response).map_err(Into::into) + } + + async fn show(&self, input: ShowObjectiveInput) -> Result { + let id = validate_id(&input.id, "ShowObjective")?; + let response = self + .read_value(id, input.event_limit, input.event_cursor) + .await + .map_err(backend_error)?; + let objective_ref = response + .get("objective") + .and_then(serde_json::Value::as_str) + .unwrap_or(id); Ok(ToolOutput { summary: format!("Read objective {objective_ref}"), content: Some(serde_json::to_string_pretty(&response).map_err(decode_error)?), @@ -70,9 +108,12 @@ impl WorkspaceHttpObjectiveBackend { }) } - async fn create(&self, input: ObjectiveCreateInput) -> Result { + async fn create_value( + &self, + input: &ObjectiveCreateInput, + ) -> Result { if input.title.trim().is_empty() { - return Err(ToolError::InvalidArgument( + return Err(WorkspaceObjectiveBackendError::InvalidArgument( "ObjectiveCreate requires non-empty title".to_string(), )); } @@ -80,92 +121,144 @@ impl WorkspaceHttpObjectiveBackend { "/api/w/{}/objectives", self.client.workspace_id().unwrap_or_default() ); - let response = send_json::( + let response = send_json::( self.client.as_ref(), reqwest::Method::POST, &url, - &input, + input, ) - .await - .map_err(backend_error)?; - Ok(objective_output( - format!("Created objective {}", &response.resource_key), - response, - )?) + .await?; + project_objective_value(response) + } + + async fn create(&self, input: ObjectiveCreateInput) -> Result { + let response = self.create_value(&input).await.map_err(backend_error)?; + let objective_ref = objective_ref(&response).map_err(ToolError::ExecutionFailed)?; + objective_output(format!("Created objective {objective_ref}"), &response) + } + + async fn edit_value( + &self, + id: &str, + input: &ObjectiveEditRequest, + ) -> Result { + let id = validate_backend_id(id)?; + if input.title.is_none() && input.old_string.is_none() && input.new_string.is_none() { + return Err(WorkspaceObjectiveBackendError::InvalidArgument( + "ObjectiveEdit requires title or old_string/new_string".to_string(), + )); + } + let response = send_json::( + self.client.as_ref(), + reqwest::Method::PATCH, + &self.objective_url(id), + input, + ) + .await?; + project_objective_value(response) } async fn edit(&self, input: ObjectiveEditInput) -> Result { let id = validate_id(&input.id, "ObjectiveEdit")?; - if input.title.is_none() && input.old_string.is_none() && input.new_string.is_none() { - return Err(ToolError::InvalidArgument( - "ObjectiveEdit requires title or old_string/new_string".to_string(), - )); - } - let url = self.objective_url(id); let body = ObjectiveEditRequest { title: input.title, old_string: input.old_string, new_string: input.new_string, replace_all: input.replace_all, }; - let response = send_json::( + let response = self.edit_value(id, &body).await.map_err(backend_error)?; + let objective_ref = objective_ref(&response).map_err(ToolError::ExecutionFailed)?; + objective_output(format!("Edited objective {objective_ref}"), &response) + } + + async fn set_state_value( + &self, + id: &str, + state: &str, + ) -> Result { + let id = validate_backend_id(id)?; + if state.trim().is_empty() { + return Err(WorkspaceObjectiveBackendError::InvalidArgument( + "ObjectiveSetState requires non-empty state".to_string(), + )); + } + let response = send_json::( self.client.as_ref(), - reqwest::Method::PATCH, - &url, - &body, + reqwest::Method::POST, + &format!("{}/state", self.objective_url(id)), + &ObjectiveSetStateRequest { + state: state.to_string(), + }, ) - .await - .map_err(backend_error)?; - Ok(objective_output( - format!("Edited objective {}", &response.resource_key), - response, - )?) + .await?; + project_objective_value(response) } async fn set_state(&self, input: ObjectiveSetStateInput) -> Result { let id = validate_id(&input.id, "ObjectiveSetState")?; - if input.state.trim().is_empty() { - return Err(ToolError::InvalidArgument( - "ObjectiveSetState requires non-empty state".to_string(), - )); - } - let url = format!("{}/state", self.objective_url(id)); - let response = send_json::( + let response = self + .set_state_value(id, &input.state) + .await + .map_err(backend_error)?; + let objective_ref = objective_ref(&response).map_err(ToolError::ExecutionFailed)?; + objective_output( + format!("Updated objective {objective_ref} state"), + &response, + ) + } + + async fn link_ticket_value( + &self, + id: &str, + ticket_id: &str, + ) -> Result<(String, serde_json::Value), WorkspaceObjectiveBackendError> { + let id = validate_backend_id(id)?; + let ticket_id = validate_backend_id(ticket_id)?; + let ticket_resource_key = self.ticket_resource_key_raw(ticket_id).await?; + let response = send_json::( self.client.as_ref(), reqwest::Method::POST, - &url, - &ObjectiveSetStateRequest { state: input.state }, + &format!("{}/ticket-links", self.objective_url(id)), + &ObjectiveLinkTicketRequest { + ticket_id: ticket_id.to_string(), + }, ) - .await - .map_err(backend_error)?; - Ok(objective_output( - format!("Updated objective {} state", &response.resource_key), - response, - )?) + .await?; + Ok((ticket_resource_key, project_objective_value(response)?)) } async fn link_ticket(&self, input: ObjectiveLinkTicketInput) -> Result { let id = validate_id(&input.id, "ObjectiveLinkTicket")?; let ticket_id = validate_id(&input.ticket_id, "ObjectiveLinkTicket")?; - let ticket_resource_key = self.ticket_resource_key(ticket_id).await?; - let url = format!("{}/ticket-links", self.objective_url(id)); - let response = send_json::( - self.client.as_ref(), - reqwest::Method::POST, - &url, - &ObjectiveLinkTicketRequest { - ticket_id: ticket_id.to_string(), - }, + let (ticket_resource_key, response) = self + .link_ticket_value(id, ticket_id) + .await + .map_err(backend_error)?; + let objective_ref = objective_ref(&response).map_err(ToolError::ExecutionFailed)?; + objective_output( + format!("Linked ticket {ticket_resource_key} to objective {objective_ref}"), + &response, ) - .await - .map_err(backend_error)?; - Ok(objective_output( - format!( - "Linked ticket {ticket_resource_key} to objective {}", - &response.resource_key + } + + async fn unlink_ticket_value( + &self, + id: &str, + ticket_id: &str, + ) -> Result<(String, serde_json::Value), WorkspaceObjectiveBackendError> { + let id = validate_backend_id(id)?; + let ticket_id = validate_backend_id(ticket_id)?; + let ticket_resource_key = self.ticket_resource_key_raw(ticket_id).await?; + let response = delete_json::( + self.client.as_ref(), + &format!( + "{}/ticket-links/{}", + self.objective_url(id), + encode_path_segment(ticket_id) ), - response, - )?) + ) + .await?; + Ok((ticket_resource_key, project_objective_value(response)?)) } async fn unlink_ticket( @@ -174,31 +267,27 @@ impl WorkspaceHttpObjectiveBackend { ) -> Result { let id = validate_id(&input.id, "ObjectiveUnlinkTicket")?; let ticket_id = validate_id(&input.ticket_id, "ObjectiveUnlinkTicket")?; - let ticket_resource_key = self.ticket_resource_key(ticket_id).await?; - let url = format!("{}/ticket-links/{}", self.objective_url(id), ticket_id); - let response = delete_json::(self.client.as_ref(), &url) + let (ticket_resource_key, response) = self + .unlink_ticket_value(id, ticket_id) .await .map_err(backend_error)?; - Ok(objective_output( - format!( - "Unlinked ticket {ticket_resource_key} from objective {}", - &response.resource_key - ), - response, - )?) + let objective_ref = objective_ref(&response).map_err(ToolError::ExecutionFailed)?; + objective_output( + format!("Unlinked ticket {ticket_resource_key} from objective {objective_ref}"), + &response, + ) } - async fn ticket_resource_key(&self, ticket_reference: &str) -> Result { + async fn ticket_resource_key_raw( + &self, + ticket_reference: &str, + ) -> Result { let workspace_id = self.client.workspace_id().unwrap_or_default(); - let response: serde_json::Value = decode_response( - self.client - .execute(WorkspaceRequest::get(format!( - "/api/w/{workspace_id}/tickets/{ticket_reference}" - ))) - .map_err(WorkspaceObjectiveBackendError::from) - .map_err(backend_error)?, - ) - .map_err(backend_error)?; + let response: serde_json::Value = + decode_response(self.client.execute(WorkspaceRequest::get(format!( + "/api/w/{workspace_id}/tickets/{}", + encode_path_segment(ticket_reference) + )))?)?; response .get("resource_key") .or_else(|| { @@ -209,17 +298,26 @@ impl WorkspaceHttpObjectiveBackend { .and_then(serde_json::Value::as_str) .filter(|key| is_canonical_resource_key(key, "T-")) .map(ToOwned::to_owned) - .ok_or_else(|| ToolError::ExecutionFailed("required T- key is unavailable".to_string())) + .ok_or_else(|| { + WorkspaceObjectiveBackendError::Projection( + "required T- key is unavailable".to_string(), + ) + }) } fn objective_url(&self, id: &str) -> String { let workspace_id = self.client.workspace_id().unwrap_or_default(); - format!("/api/w/{workspace_id}/objectives/{id}") + format!( + "/api/w/{workspace_id}/objectives/{}", + encode_path_segment(id) + ) } } #[derive(Debug, thiserror::Error)] pub enum WorkspaceObjectiveBackendError { + #[error("invalid Objective operation: {0}")] + InvalidArgument(String), #[error("workspace objective backend request failed: {0}")] Request(#[from] crate::worker::WorkspaceClientError), #[error("workspace objective backend returned HTTP {status}")] @@ -229,6 +327,8 @@ pub enum WorkspaceObjectiveBackendError { }, #[error("decode objective backend response: {0}")] Decode(#[from] serde_json::Error), + #[error("project objective backend response: {0}")] + Projection(String), } fn decode_error(error: serde_json::Error) -> ToolError { @@ -236,7 +336,12 @@ fn decode_error(error: serde_json::Error) -> ToolError { } fn backend_error(error: WorkspaceObjectiveBackendError) -> ToolError { - ToolError::ExecutionFailed(error.to_string()) + match error { + WorkspaceObjectiveBackendError::InvalidArgument(message) => { + ToolError::InvalidArgument(message) + } + error => ToolError::ExecutionFailed(error.to_string()), + } } async fn send_json Deserialize<'de>>( @@ -290,25 +395,70 @@ fn is_canonical_resource_key(resource_key: &str, prefix: &str) -> bool { }) } -fn objective_output(summary: String, response: ObjectiveDetail) -> Result { - if !is_canonical_resource_key(&response.resource_key, "O-") { - return Err(ToolError::ExecutionFailed( - "required O- key is unavailable".to_string(), - )); - } +fn project_objective_value( + response: serde_json::Value, +) -> Result { + let projected = + project_objective_detail(response).map_err(WorkspaceObjectiveBackendError::Projection)?; + serde_json::to_value(projected).map_err(Into::into) +} + +fn objective_ref(response: &serde_json::Value) -> Result<&str, String> { + response + .get("objective") + .and_then(serde_json::Value::as_str) + .filter(|key| is_canonical_resource_key(key, "O-")) + .ok_or_else(|| "required O- key is unavailable".to_string()) +} + +fn objective_output( + summary: String, + response: &serde_json::Value, +) -> Result { + let objective = objective_ref(response).map_err(ToolError::ExecutionFailed)?; + let title = response + .get("title") + .and_then(serde_json::Value::as_str) + .ok_or_else(|| ToolError::ExecutionFailed("Objective title is unavailable".into()))?; + let state = response + .get("state") + .and_then(serde_json::Value::as_str) + .ok_or_else(|| ToolError::ExecutionFailed("Objective state is unavailable".into()))?; let projected = serde_json::json!({ - "objective": &response.resource_key, - "title": response.title, - "state": response.state, + "objective": objective, + "title": title, + "state": state, }); Ok(ToolOutput { summary, content: Some(serde_json::to_string_pretty(&projected).map_err(decode_error)?), - attachments: Vec::new(), }) } +fn validate_backend_id(id: &str) -> Result<&str, WorkspaceObjectiveBackendError> { + let id = id.trim(); + if id.is_empty() || id.contains('/') { + return Err(WorkspaceObjectiveBackendError::InvalidArgument( + "Objective reference must be non-empty and contain no '/'".into(), + )); + } + Ok(id) +} + +fn encode_path_segment(value: &str) -> String { + let mut encoded = String::with_capacity(value.len()); + for byte in value.bytes() { + if byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'.' | b'_' | b'~') { + encoded.push(char::from(byte)); + } else { + use std::fmt::Write as _; + write!(&mut encoded, "%{byte:02X}").expect("write to String"); + } + } + encoded +} + fn validate_id<'a>(id: &'a str, tool_name: &str) -> Result<&'a str, ToolError> { let id = id.trim(); if id.is_empty() || id.contains('/') { @@ -374,6 +524,673 @@ pub fn workspace_http_objective_tools(client: Arc) -> Vec>>; + +/// Contribute the Objective Feature's native collection and item projections to +/// the Host-owned WIP registry. Ordinary Objective tools remain registered for +/// Tool mode and are claimed only from the WIP compatibility projection. +pub fn mount_workspace_http_objective_wip( + registry: &mut WipMountRegistry, + client: Arc, + permissions: Option, + feature_route: &WipFeatureRoute, +) -> Result<(), WipMountError> { + let collection_route = feature_route.child("objectives")?; + let backend = WorkspaceHttpObjectiveBackend::new(client); + let revisions = Arc::new(Mutex::new(HashMap::new())); + let collection_descriptor = objective_collection_descriptor(); + let collection_validator = descriptor_validator(&collection_descriptor); + let collection_handler = Arc::new(ObjectiveCollectionWipHandler { + backend: backend.clone(), + permissions: permissions.clone(), + collection_route: collection_route.clone(), + revisions: Arc::clone(&revisions), + }); + registry.mount(WipProjection { + route: collection_route.clone(), + capability: "objective:collection".into(), + kind: WipProjectionKind::Native, + object: Object { + name: collection_route + .rsplit('/') + .next() + .unwrap_or_default() + .to_string(), + description: Some( + "Authoritative Objective collection; search and create through Backend authority" + .into(), + ), + interfaces: vec![OBJECTIVE_COLLECTION_INTERFACE.into()], + r#ref: Some("objective:collection".into()), + validator: Some(route_validator(&collection_route, "collection")), + }, + interface: OBJECTIVE_COLLECTION_INTERFACE.into(), + descriptor: collection_descriptor, + interface_validator: Some(collection_validator), + handler: collection_handler, + })?; + + let item_descriptor = objective_item_descriptor(); + let item_validator = descriptor_validator(&item_descriptor); + registry.mount_dynamic(WipDynamicMount { + collection_route: collection_route.clone(), + capability: "objective:item".into(), + interface: OBJECTIVE_ITEM_INTERFACE.into(), + descriptor: item_descriptor, + interface_validator: Some(item_validator), + resolver: Arc::new(ObjectiveItemResolver { + backend, + permissions, + collection_route: collection_route.clone(), + revisions, + }), + })?; + registry.replace_compatibility_tools(&collection_route, OBJECTIVE_TOOL_NAMES)?; + Ok(()) +} + +struct ObjectiveCollectionWipHandler { + backend: WorkspaceHttpObjectiveBackend, + permissions: Option, + collection_route: String, + revisions: ObjectiveRevisions, +} + +#[async_trait] +impl WipOperationHandler for ObjectiveCollectionWipHandler { + async fn call( + &self, + operation: &str, + arguments: &BTreeMap, + _context: WipCallContext, + ) -> Result { + let value = match operation { + "query" => { + let limit = optional_usize_argument(arguments, "limit")?; + ensure_optional_range("limit", limit, 1, 100)?; + let sort = optional_string_argument(arguments, "sort")?; + if sort.as_deref().is_some_and(|sort| { + !matches!( + sort, + "relevance" | "updated_desc" | "created_desc" | "title" + ) + }) { + return Err(protocol_failure( + ProtocolErrorCode::InvalidArguments, + "`sort` is not an Objective query ordering", + )); + } + let input = QueryObjectiveInput { + query: optional_string_argument(arguments, "query")?, + states: string_list_argument(arguments, "states")?.unwrap_or_default(), + linked_ticket_id: optional_string_argument(arguments, "linked_ticket_id")?, + updated_after: optional_string_argument(arguments, "updated_after")?, + updated_before: optional_string_argument(arguments, "updated_before")?, + sort, + limit, + cursor: optional_string_argument(arguments, "cursor")?, + }; + let permission_input = serde_json::to_value(&input) + .map_err(|error| WipOperationError::OutcomeUnknown(error.to_string()))?; + authorize_native(&self.permissions, "QueryObjective", &permission_input)?; + let mut response = self + .backend + .query_value(&input) + .await + .map_err(map_backend_error)?; + if let Some(objectives) = + response.get_mut("objectives").and_then(Json::as_array_mut) + { + for objective in objectives { + if let Some(reference) = objective + .get("objective") + .and_then(Json::as_str) + .map(ToOwned::to_owned) + { + objective + .as_object_mut() + .expect("projected Objective is an object") + .insert( + "path".into(), + Json::String(format!( + "{}/{}", + self.collection_route, reference + )), + ); + } + } + } + response + } + "create" => { + let input = ObjectiveCreateInput { + title: required_string_argument(arguments, "title")?, + body_md: optional_string_argument(arguments, "body_md")?.unwrap_or_default(), + state: optional_string_argument(arguments, "state")? + .unwrap_or_else(default_state), + linked_tickets: string_list_argument(arguments, "linked_tickets")? + .unwrap_or_default(), + }; + let permission_input = serde_json::to_value(&input) + .map_err(|error| WipOperationError::OutcomeUnknown(error.to_string()))?; + authorize_native(&self.permissions, "ObjectiveCreate", &permission_input)?; + let mut response = self + .backend + .create_value(&input) + .await + .map_err(map_backend_error)?; + record_revision(&self.revisions, None, &response); + if let Some(reference) = response + .get("objective") + .and_then(Json::as_str) + .map(ToOwned::to_owned) + { + response + .as_object_mut() + .expect("projected Objective is an object") + .insert( + "path".into(), + Json::String(format!("{}/{}", self.collection_route, reference)), + ); + } + response + } + _ => return Err(operation_not_found()), + }; + Ok(WipOperationOutput::native( + json_to_wip(&value).map_err(WipOperationError::OutcomeUnknown)?, + )) + } +} + +struct ObjectiveItemResolver { + backend: WorkspaceHttpObjectiveBackend, + permissions: Option, + collection_route: String, + revisions: ObjectiveRevisions, +} + +impl WipDynamicItemResolver for ObjectiveItemResolver { + fn resolve(&self, item_reference: &str) -> Option { + if !is_objective_route_reference(item_reference) { + return None; + } + let revision = self + .revisions + .lock() + .unwrap_or_else(|error| error.into_inner()) + .get(item_reference) + .cloned() + .unwrap_or_else(|| "unobserved".into()); + let route = format!("{}/{}", self.collection_route, item_reference); + Some(WipDynamicItem { + object: Object { + name: item_reference.to_string(), + description: Some("Authoritative Objective bound to this object route".into()), + interfaces: vec![OBJECTIVE_ITEM_INTERFACE.into()], + r#ref: Some(format!("objective:{item_reference}")), + validator: Some(route_validator(&route, &revision)), + }, + handler: Arc::new(ObjectiveItemWipHandler { + backend: self.backend.clone(), + permissions: self.permissions.clone(), + objective_reference: item_reference.to_string(), + revisions: Arc::clone(&self.revisions), + }), + }) + } +} + +struct ObjectiveItemWipHandler { + backend: WorkspaceHttpObjectiveBackend, + permissions: Option, + objective_reference: String, + revisions: ObjectiveRevisions, +} + +#[async_trait] +impl WipOperationHandler for ObjectiveItemWipHandler { + async fn call( + &self, + operation: &str, + arguments: &BTreeMap, + _context: WipCallContext, + ) -> Result { + let (tool_name, permission_input, response) = match operation { + "read" => { + let event_limit = optional_usize_argument(arguments, "event_limit")?; + ensure_optional_range("event_limit", event_limit, 1, 50)?; + let event_cursor = optional_string_argument(arguments, "event_cursor")?; + let permission_input = json!({ + "id": self.objective_reference, + "event_limit": event_limit, + "event_cursor": event_cursor, + }); + authorize_native(&self.permissions, "ShowObjective", &permission_input)?; + let response = self + .backend + .read_value(&self.objective_reference, event_limit, event_cursor) + .await + .map_err(map_backend_error)?; + ("ShowObjective", permission_input, response) + } + "edit" => { + let input = ObjectiveEditRequest { + title: optional_string_argument(arguments, "title")?, + old_string: optional_string_argument(arguments, "old_string")?, + new_string: optional_string_argument(arguments, "new_string")?, + replace_all: optional_bool_argument(arguments, "replace_all")?.unwrap_or(false), + }; + let permission_input = json!({ + "id": self.objective_reference, + "title": input.title, + "old_string": input.old_string, + "new_string": input.new_string, + "replace_all": input.replace_all, + }); + authorize_native(&self.permissions, "ObjectiveEdit", &permission_input)?; + let response = self + .backend + .edit_value(&self.objective_reference, &input) + .await + .map_err(map_backend_error)?; + ("ObjectiveEdit", permission_input, response) + } + "set_state" => { + let state = required_string_argument(arguments, "state")?; + let permission_input = json!({"id": self.objective_reference, "state": state}); + authorize_native(&self.permissions, "ObjectiveSetState", &permission_input)?; + let response = self + .backend + .set_state_value(&self.objective_reference, &state) + .await + .map_err(map_backend_error)?; + ("ObjectiveSetState", permission_input, response) + } + "link_ticket" | "unlink_ticket" => { + let ticket_id = required_string_argument(arguments, "ticket_id")?; + let permission_input = json!({ + "id": self.objective_reference, + "ticket_id": ticket_id, + }); + let (tool_name, response) = if operation == "link_ticket" { + authorize_native(&self.permissions, "ObjectiveLinkTicket", &permission_input)?; + let (_, response) = self + .backend + .link_ticket_value(&self.objective_reference, &ticket_id) + .await + .map_err(map_backend_error)?; + ("ObjectiveLinkTicket", response) + } else { + authorize_native( + &self.permissions, + "ObjectiveUnlinkTicket", + &permission_input, + )?; + let (_, response) = self + .backend + .unlink_ticket_value(&self.objective_reference, &ticket_id) + .await + .map_err(map_backend_error)?; + ("ObjectiveUnlinkTicket", response) + }; + (tool_name, permission_input, response) + } + _ => return Err(operation_not_found()), + }; + let _ = (tool_name, permission_input); + record_revision(&self.revisions, Some(&self.objective_reference), &response); + Ok(WipOperationOutput::native( + json_to_wip(&response).map_err(WipOperationError::OutcomeUnknown)?, + )) + } +} + +fn authorize_native( + permissions: &Option, + tool_name: &str, + arguments: &Json, +) -> Result<(), WipOperationError> { + let Some(permissions) = permissions else { + return Ok(()); + }; + match permission_action_for(permissions, tool_name, arguments) { + ToolPermissionAction::Allow => Ok(()), + ToolPermissionAction::Deny => Err(protocol_failure( + ProtocolErrorCode::PermissionDenied, + format!("permission denied for projected tool `{tool_name}`"), + )), + ToolPermissionAction::Ask => Err(protocol_failure( + ProtocolErrorCode::PermissionDenied, + format!( + "permission approval is unavailable for projected tool `{tool_name}`; denied fail-closed" + ), + )), + } +} + +fn map_backend_error(error: WorkspaceObjectiveBackendError) -> WipOperationError { + match error { + WorkspaceObjectiveBackendError::InvalidArgument(message) => { + protocol_failure(ProtocolErrorCode::InvalidArguments, message) + } + WorkspaceObjectiveBackendError::Http { status, body } + if status == reqwest::StatusCode::NOT_FOUND => + { + protocol_failure(ProtocolErrorCode::NotFound, body) + } + WorkspaceObjectiveBackendError::Http { status, body } + if status == reqwest::StatusCode::UNAUTHORIZED + || status == reqwest::StatusCode::FORBIDDEN => + { + protocol_failure(ProtocolErrorCode::PermissionDenied, body) + } + WorkspaceObjectiveBackendError::Http { status, body } if status.is_client_error() => { + protocol_failure( + ProtocolErrorCode::InvalidArguments, + format!("workspace objective backend returned HTTP {status}: {body}"), + ) + } + error => WipOperationError::OutcomeUnknown(error.to_string()), + } +} + +fn protocol_failure(code: ProtocolErrorCode, message: impl Into) -> WipOperationError { + WipOperationError::Protocol(ProtocolError { + code, + message: message.into(), + }) +} + +fn operation_not_found() -> WipOperationError { + protocol_failure( + ProtocolErrorCode::OperationNotFound, + "operation is not published by the Objective interface", + ) +} + +fn required_string_argument( + arguments: &BTreeMap, + name: &str, +) -> Result { + optional_string_argument(arguments, name)?.ok_or_else(|| { + protocol_failure( + ProtocolErrorCode::InvalidArguments, + format!("missing required `{name}` argument"), + ) + }) +} + +fn optional_string_argument( + arguments: &BTreeMap, + name: &str, +) -> Result, WipOperationError> { + match arguments.get(name) { + None | Some(Value::Unit) => Ok(None), + Some(Value::String(value)) => Ok(Some(value.clone())), + Some(_) => Err(protocol_failure( + ProtocolErrorCode::InvalidArguments, + format!("`{name}` must be a string"), + )), + } +} + +fn optional_bool_argument( + arguments: &BTreeMap, + name: &str, +) -> Result, WipOperationError> { + match arguments.get(name) { + None | Some(Value::Unit) => Ok(None), + Some(Value::Boolean(value)) => Ok(Some(*value)), + Some(_) => Err(protocol_failure( + ProtocolErrorCode::InvalidArguments, + format!("`{name}` must be a boolean"), + )), + } +} + +fn optional_usize_argument( + arguments: &BTreeMap, + name: &str, +) -> Result, WipOperationError> { + match arguments.get(name) { + None | Some(Value::Unit) => Ok(None), + Some(Value::Integer(value)) => usize::try_from(*value).map(Some).map_err(|_| { + protocol_failure( + ProtocolErrorCode::InvalidArguments, + format!("`{name}` must be a non-negative integer"), + ) + }), + Some(_) => Err(protocol_failure( + ProtocolErrorCode::InvalidArguments, + format!("`{name}` must be an integer"), + )), + } +} + +fn ensure_optional_range( + name: &str, + value: Option, + minimum: usize, + maximum: usize, +) -> Result<(), WipOperationError> { + if value.is_some_and(|value| !(minimum..=maximum).contains(&value)) { + return Err(protocol_failure( + ProtocolErrorCode::InvalidArguments, + format!("`{name}` must be between {minimum} and {maximum}"), + )); + } + Ok(()) +} + +fn string_list_argument( + arguments: &BTreeMap, + name: &str, +) -> Result>, WipOperationError> { + match arguments.get(name) { + None | Some(Value::Unit) => Ok(None), + Some(Value::List(values)) => values + .iter() + .map(|value| match value { + Value::String(value) => Ok(value.clone()), + _ => Err(protocol_failure( + ProtocolErrorCode::InvalidArguments, + format!("`{name}` entries must be strings"), + )), + }) + .collect::, _>>() + .map(Some), + Some(_) => Err(protocol_failure( + ProtocolErrorCode::InvalidArguments, + format!("`{name}` must be a list"), + )), + } +} + +fn record_revision(revisions: &ObjectiveRevisions, bound_reference: Option<&str>, response: &Json) { + let Some(revision) = response.get("revision").and_then(Json::as_str) else { + return; + }; + let mut revisions = revisions.lock().unwrap_or_else(|error| error.into_inner()); + if let Some(reference) = bound_reference { + revisions.insert(reference.to_string(), revision.to_string()); + } + if let Some(reference) = response.get("objective").and_then(Json::as_str) { + revisions.insert(reference.to_string(), revision.to_string()); + } +} + +fn is_objective_route_reference(reference: &str) -> bool { + is_canonical_resource_key(reference, "O-") + || (reference.len() >= 8 + && reference.len() <= 128 + && reference.bytes().all(|byte| byte.is_ascii_alphanumeric())) +} + +fn route_validator(route: &str, revision: &str) -> Vec { + let mut digest = Sha256::new(); + digest.update(route.as_bytes()); + digest.update([0]); + digest.update(revision.as_bytes()); + digest.finalize().to_vec() +} + +fn descriptor_validator(descriptor: &InterfaceDescriptor) -> Vec { + let mut digest = Sha256::new(); + digest.update(format!("{descriptor:?}").as_bytes()); + digest.finalize().to_vec() +} + +fn objective_collection_descriptor() -> InterfaceDescriptor { + InterfaceDescriptor { + format: INTERFACE_FORMAT_V1.into(), + documentation: Some(Documentation { + summary: "Search and create authoritative Workspace Objectives".into(), + details: Some( + "Query results are bounded and include canonical item paths. Ticket references are data only and confer no Ticket authority." + .into(), + ), + }), + types: Vec::new(), + operations: vec![ + OperationDeclaration { + name: "query".into(), + documentation: Some(Documentation { + summary: LIST_DESCRIPTION.into(), + details: None, + }), + parameters: vec![ + parameter("query", false, TypeExpr::String), + parameter("states", false, list_of(TypeExpr::String)), + parameter("linked_ticket_id", false, TypeExpr::String), + parameter("updated_after", false, TypeExpr::String), + parameter("updated_before", false, TypeExpr::String), + parameter("sort", false, TypeExpr::String), + parameter("limit", false, TypeExpr::Integer), + parameter("cursor", false, TypeExpr::String), + ], + returns: json_return("Bounded Objective query with cursor metadata and item paths"), + }, + OperationDeclaration { + name: "create".into(), + documentation: Some(Documentation { + summary: CREATE_DESCRIPTION.into(), + details: None, + }), + parameters: vec![ + parameter("title", true, TypeExpr::String), + parameter("body_md", false, TypeExpr::String), + parameter("state", false, TypeExpr::String), + parameter("linked_tickets", false, list_of(TypeExpr::String)), + ], + returns: json_return("Created Objective with revision and bounded context"), + }, + ], + } +} + +fn objective_item_descriptor() -> InterfaceDescriptor { + InterfaceDescriptor { + format: INTERFACE_FORMAT_V1.into(), + documentation: Some(Documentation { + summary: "Read and mutate the Objective bound to this object route".into(), + details: Some( + "The Objective identity is taken exclusively from the target path; operations do not accept a second Objective id." + .into(), + ), + }), + types: Vec::new(), + operations: vec![ + operation( + "read", + SHOW_DESCRIPTION, + vec![ + parameter("event_limit", false, TypeExpr::Integer), + parameter("event_cursor", false, TypeExpr::String), + ], + ), + operation( + "edit", + EDIT_DESCRIPTION, + vec![ + parameter("title", false, TypeExpr::String), + parameter("old_string", false, TypeExpr::String), + parameter("new_string", false, TypeExpr::String), + parameter("replace_all", false, TypeExpr::Boolean), + ], + ), + operation( + "set_state", + SET_STATE_DESCRIPTION, + vec![parameter("state", true, TypeExpr::String)], + ), + operation( + "link_ticket", + LINK_TICKET_DESCRIPTION, + vec![parameter("ticket_id", true, TypeExpr::String)], + ), + operation( + "unlink_ticket", + UNLINK_TICKET_DESCRIPTION, + vec![parameter("ticket_id", true, TypeExpr::String)], + ), + ], + } +} + +fn operation( + name: &str, + summary: &str, + parameters: Vec, +) -> OperationDeclaration { + OperationDeclaration { + name: name.into(), + documentation: Some(Documentation { + summary: summary.into(), + details: None, + }), + parameters, + returns: json_return("Authoritative Objective detail with revision and bounded context"), + } +} + +fn parameter(name: &str, required: bool, r#type: TypeExpr) -> ParameterDeclaration { + ParameterDeclaration { + name: name.into(), + required, + documentation: None, + r#type, + } +} + +fn list_of(items: TypeExpr) -> TypeExpr { + TypeExpr::List { + items: Box::new(items), + } +} + +fn json_return(summary: &str) -> ReturnDeclaration { + ReturnDeclaration { + documentation: Some(Documentation { + summary: summary.into(), + details: None, + }), + r#type: TypeExpr::Json, + } +} + #[derive(Clone, Copy)] enum ObjectiveOperation { List, @@ -646,13 +1463,6 @@ fn default_state() -> String { "active".to_string() } -#[derive(Debug, Serialize, Deserialize, PartialEq, Eq)] -struct ObjectiveDetail { - resource_key: String, - title: String, - state: String, -} - #[cfg(test)] mod tests { use super::*; @@ -726,7 +1536,9 @@ mod tests { "resource_key": "O-3", "title": "Objective", "body": "Body", + "body_truncated": false, "state": "active", + "revision": "rev-1", "created_at": null, "updated_at": null, "linked_ticket_summaries": [], @@ -789,9 +1601,18 @@ mod tests { "{mutation} /api/w/workspace/objectives/O-3/ticket-links" ))); let response_body = serde_json::json!({ + "id": "00001OBJECTIVE", "resource_key": "O-3", "title": "Objective", - "state": "active" + "body": "Body", + "body_truncated": false, + "state": "active", + "revision": format!("rev-{mutation}"), + "created_at": null, + "updated_at": null, + "linked_ticket_summaries": [], + "events": [], + "event_page": {"next_cursor": null, "has_more": false} }) .to_string(); write!( @@ -831,12 +1652,290 @@ mod tests { } #[test] - fn objective_output_rejects_noncanonical_keys() { - let response = ObjectiveDetail { - resource_key: "O-internal".to_string(), - title: "Objective".to_string(), - state: "active".to_string(), + fn native_objective_contract_is_route_bound_and_host_allocated() { + let collection = objective_collection_descriptor(); + assert_eq!( + collection + .operations + .iter() + .map(|operation| operation.name.as_str()) + .collect::>(), + ["query", "create"] + ); + let item = objective_item_descriptor(); + assert_eq!( + item.operations + .iter() + .map(|operation| operation.name.as_str()) + .collect::>(), + ["read", "edit", "set_state", "link_ticket", "unlink_ticket"] + ); + assert!(item.operations.iter().all(|operation| { + operation + .parameters + .iter() + .all(|parameter| parameter.name != "id") + })); + + let mut registry = WipMountRegistry::new(); + let feature_route = registry.allocate_feature_route("objective").unwrap(); + mount_workspace_http_objective_wip( + &mut registry, + Arc::new(crate::worker::TestWorkspaceHttpClient::new( + "workspace", + "http://backend", + )), + None, + &feature_route, + ) + .unwrap(); + assert_eq!( + registry.routes().collect::>(), + ["/features/objective/objectives"] + ); + assert_eq!( + feature_route.child("objectives").unwrap(), + "/features/objective/objectives" + ); + assert!(feature_route.child("../tickets").is_err()); + } + + #[test] + fn dynamic_objective_routes_accept_only_objective_references_and_refresh_revision() { + let revisions = Arc::new(Mutex::new(HashMap::new())); + let resolver = ObjectiveItemResolver { + backend: WorkspaceHttpObjectiveBackend::new(Arc::new( + crate::worker::TestWorkspaceHttpClient::new("workspace", "http://backend"), + )), + permissions: None, + collection_route: "/features/objective/objectives".into(), + revisions: Arc::clone(&revisions), }; - assert!(objective_output("created".to_string(), response).is_err()); + let initial = resolver.resolve("O-3").unwrap(); + assert_eq!(initial.object.name, "O-3"); + assert!(resolver.resolve("00001OBJECTIVE").is_some()); + assert!(resolver.resolve("T-3").is_none()); + assert!(resolver.resolve("O-3/other").is_none()); + + record_revision( + &revisions, + Some("O-3"), + &json!({"objective": "O-3", "revision": "rev-2"}), + ); + let refreshed = resolver.resolve("O-3").unwrap(); + assert_ne!(initial.object.validator, refreshed.object.validator); + } + + #[tokio::test] + async fn native_permissions_and_bounds_fail_before_backend_dispatch() { + let backend = WorkspaceHttpObjectiveBackend::new(Arc::new( + crate::worker::TestWorkspaceHttpClient::new("workspace", "http://unreachable.invalid"), + )); + let denied = ObjectiveItemWipHandler { + backend: backend.clone(), + permissions: Some(ToolPermissionConfig { + default_action: ToolPermissionAction::Deny, + rules: Vec::new(), + }), + objective_reference: "O-3".into(), + revisions: Arc::new(Mutex::new(HashMap::new())), + }; + let result = denied + .call( + "read", + &BTreeMap::new(), + WipCallContext { + execution: ToolExecutionContext::direct(), + security_context: "test".into(), + }, + ) + .await; + assert!(matches!( + result, + Err(WipOperationError::Protocol(ProtocolError { + code: ProtocolErrorCode::PermissionDenied, + .. + })) + )); + + let collection = ObjectiveCollectionWipHandler { + backend, + permissions: None, + collection_route: "/features/objective/objectives".into(), + revisions: Arc::new(Mutex::new(HashMap::new())), + }; + let result = collection + .call( + "query", + &BTreeMap::from([("limit".into(), Value::Integer(101))]), + WipCallContext { + execution: ToolExecutionContext::direct(), + security_context: "test".into(), + }, + ) + .await; + assert!(matches!( + result, + Err(WipOperationError::Protocol(ProtocolError { + code: ProtocolErrorCode::InvalidArguments, + .. + })) + )); + } + + #[tokio::test(flavor = "multi_thread")] + async fn native_query_preserves_cursor_and_returns_canonical_item_paths() { + let listener = TcpListener::bind("127.0.0.1:0").unwrap(); + let base_url = format!("http://{}", listener.local_addr().unwrap()); + let server = thread::spawn(move || { + let (mut stream, _) = listener.accept().unwrap(); + let mut buffer = [0_u8; 8192]; + let len = stream.read(&mut buffer).unwrap(); + let request = String::from_utf8_lossy(&buffer[..len]); + assert!(request.starts_with("POST /api/w/workspace/objectives/query HTTP/1.1")); + let response_body = serde_json::json!({ + "items": [{ + "id": "00001OBJECTIVE", + "resource_key": "O-3", + "title": "Objective", + "state": "active", + "created_at": null, + "updated_at": null, + "matched_fields": ["title"], + "snippet": "Objective", + "linked_ticket_count": 1, + "linked_tickets": ["00001TICKET"], + "linked_ticket_keys": ["T-7"] + }], + "page": {"next_cursor": "next-page", "has_more": true}, + "record_authority": "workspace_sqlite" + }) + .to_string(); + write!( + stream, + "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\n\r\n{}", + response_body.len(), + response_body + ) + .unwrap(); + }); + let handler = ObjectiveCollectionWipHandler { + backend: WorkspaceHttpObjectiveBackend::new(Arc::new( + crate::worker::TestWorkspaceHttpClient::new("workspace", base_url), + )), + permissions: None, + collection_route: "/features/objective/objectives".into(), + revisions: Arc::new(Mutex::new(HashMap::new())), + }; + let output = match handler + .call( + "query", + &BTreeMap::new(), + WipCallContext { + execution: ToolExecutionContext::direct(), + security_context: "test".into(), + }, + ) + .await + { + Ok(output) => output, + Err(_) => panic!("native query should succeed"), + }; + server.join().unwrap(); + 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]["linked_tickets"][0], "T-7"); + assert!(!output.to_string().contains("00001OBJECTIVE")); + assert!(!output.to_string().contains("00001TICKET")); + } + + #[tokio::test(flavor = "multi_thread")] + async fn native_edit_binds_target_from_route_and_publishes_new_revision() { + let listener = TcpListener::bind("127.0.0.1:0").unwrap(); + let base_url = format!("http://{}", listener.local_addr().unwrap()); + let server = thread::spawn(move || { + let (mut stream, _) = listener.accept().unwrap(); + let mut buffer = [0_u8; 8192]; + let len = stream.read(&mut buffer).unwrap(); + let request = String::from_utf8_lossy(&buffer[..len]); + assert!(request.starts_with("PATCH /api/w/workspace/objectives/O-3 HTTP/1.1")); + let body = request.split("\r\n\r\n").nth(1).unwrap_or_default(); + assert!(body.contains("Changed")); + assert!(!body.contains("\"id\"")); + let response_body = serde_json::json!({ + "id": "00001OBJECTIVE", + "resource_key": "O-3", + "title": "Changed", + "body": "Body", + "body_truncated": false, + "state": "active", + "revision": "rev-2", + "created_at": null, + "updated_at": null, + "linked_ticket_summaries": [], + "events": [], + "event_page": {"next_cursor": null, "has_more": false} + }) + .to_string(); + write!( + stream, + "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\n\r\n{}", + response_body.len(), + response_body + ) + .unwrap(); + }); + let revisions = Arc::new(Mutex::new(HashMap::new())); + let backend = WorkspaceHttpObjectiveBackend::new(Arc::new( + crate::worker::TestWorkspaceHttpClient::new("workspace", base_url), + )); + let resolver = ObjectiveItemResolver { + backend: backend.clone(), + permissions: None, + collection_route: "/features/objective/objectives".into(), + revisions: Arc::clone(&revisions), + }; + let before = resolver.resolve("O-3").unwrap().object.validator; + let handler = ObjectiveItemWipHandler { + backend, + permissions: None, + objective_reference: "O-3".into(), + revisions, + }; + let output = match handler + .call( + "edit", + &BTreeMap::from([("title".into(), Value::String("Changed".into()))]), + WipCallContext { + execution: ToolExecutionContext::direct(), + security_context: "test".into(), + }, + ) + .await + { + Ok(output) => output, + Err(_) => panic!("native edit should succeed"), + }; + server.join().unwrap(); + let output = crate::wip::wip_to_json(&output.value).unwrap(); + assert_eq!(output["objective"], "O-3"); + assert_eq!(output["revision"], "rev-2"); + let after = resolver.resolve("O-3").unwrap().object.validator; + assert_ne!(before, after); + } + + #[test] + fn objective_output_rejects_noncanonical_keys() { + let response = json!({ + "objective": "O-internal", + "title": "Objective", + "state": "active" + }); + assert!(objective_output("created".to_string(), &response).is_err()); } } diff --git a/crates/worker/src/feature/builtin/resource_projection.rs b/crates/worker/src/feature/builtin/resource_projection.rs index a63e6174b..53623f84e 100644 --- a/crates/worker/src/feature/builtin/resource_projection.rs +++ b/crates/worker/src/feature/builtin/resource_projection.rs @@ -78,7 +78,9 @@ pub(super) struct ModelObjectiveDetail { objective: String, title: String, body: String, + body_truncated: bool, state: String, + revision: String, created_at: Option, updated_at: Option, linked_tickets: Vec, @@ -86,12 +88,6 @@ pub(super) struct ModelObjectiveDetail { event_page: ModelObjectiveEventPage, } -impl ModelObjectiveDetail { - pub(super) fn objective_ref(&self) -> &str { - &self.objective - } -} - #[derive(Debug, Serialize)] struct ModelWorkerSummary { worker: String, @@ -363,7 +359,9 @@ pub(super) fn project_objective_detail(value: Value) -> Result, } +/// 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. +pub struct WipDynamicItem { + pub object: Object, + pub handler: Arc, +} + +pub trait WipDynamicItemResolver: Send + Sync { + fn resolve(&self, item_reference: &str) -> Option; +} + +pub struct WipDynamicMount { + pub collection_route: String, + pub capability: String, + pub interface: String, + pub descriptor: InterfaceDescriptor, + pub interface_validator: Option>, + pub resolver: Arc, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct WipFeatureRoute { + root: String, +} + +impl WipFeatureRoute { + 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, } @@ -145,6 +195,9 @@ struct MountedProjection { #[derive(Default)] pub struct WipMountRegistry { mounts: BTreeMap, + dynamic_mounts: Vec, + feature_routes: BTreeSet, + replaced_compatibility_capabilities: BTreeMap, } impl WipMountRegistry { @@ -152,11 +205,47 @@ 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( + &mut self, + feature: &str, + ) -> Result { + if feature.is_empty() + || !feature + .bytes() + .all(|byte| byte.is_ascii_lowercase() || byte.is_ascii_digit() || byte == b'-') + { + return Err(WipMountError::InvalidRoute { + route: format!("/features/{feature}"), + message: "Feature name must use lowercase ASCII letters, digits, or '-'".into(), + }); + } + let root = format!("/features/{feature}"); + if !self.feature_routes.insert(root.clone()) { + return Err(WipMountError::RouteCollision { + route: root, + existing: format!("feature:{feature}"), + }); + } + Ok(WipFeatureRoute { root }) + } + pub fn mount( &mut self, projection: WipProjection, ) -> Result { validate_projection(&projection)?; + if self.dynamic_mounts.iter().any(|dynamic| { + projection + .route + .starts_with(&format!("{}/", dynamic.collection_route)) + }) { + return Err(WipMountError::RouteCollision { + route: projection.route, + existing: "dynamic Feature collection".into(), + }); + } if let Some(existing) = self.mounts.get(&projection.route) { if existing.projection.capability != projection.capability { return Err(WipMountError::RouteCollision { @@ -188,16 +277,150 @@ impl WipMountRegistry { Ok(WipMountDisposition::Mounted) } + /// 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. + pub fn mount_dynamic(&mut self, mount: WipDynamicMount) -> Result<(), WipMountError> { + wip_protocol::validate_path(&mount.collection_route).map_err(|error| { + WipMountError::InvalidRoute { + route: mount.collection_route.clone(), + message: error.to_string(), + } + })?; + let Some(collection) = self.mounts.get(&mount.collection_route) else { + return Err(WipMountError::InvalidProjection { + route: mount.collection_route, + message: "dynamic item mount requires an existing collection projection".into(), + }); + }; + if collection.projection.kind != WipProjectionKind::Native { + return Err(WipMountError::InvalidProjection { + route: mount.collection_route, + message: "dynamic item mount requires a native collection projection".into(), + }); + } + mount + .descriptor + .validate() + .map_err(|error| WipMountError::InvalidProjection { + route: mount.collection_route.clone(), + message: error.to_string(), + })?; + if let Some(existing) = self.dynamic_mounts.iter().find(|existing| { + existing.collection_route == mount.collection_route + || mount + .collection_route + .starts_with(&format!("{}/", existing.collection_route)) + || existing + .collection_route + .starts_with(&format!("{}/", mount.collection_route)) + }) { + return Err(WipMountError::RouteCollision { + route: mount.collection_route, + existing: existing.capability.clone(), + }); + } + if let Some((_, existing)) = self + .mounts + .iter() + .find(|(route, _)| route.starts_with(&format!("{}/", mount.collection_route))) + { + return Err(WipMountError::RouteCollision { + route: mount.collection_route, + existing: existing.projection.capability.clone(), + }); + } + if self.interface_conflicts( + &mount.interface, + &mount.descriptor, + mount.interface_validator.as_deref(), + None, + ) { + return Err(WipMountError::InterfaceCollision { + interface: mount.interface, + }); + } + self.dynamic_mounts.push(mount); + Ok(()) + } + + /// 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. + pub fn replace_compatibility_tools<'a>( + &mut self, + owner_route: &str, + tool_names: impl IntoIterator, + ) -> Result<(), WipMountError> { + if !self.mounts.contains_key(owner_route) + && !self + .dynamic_mounts + .iter() + .any(|mount| mount.collection_route == owner_route) + { + return Err(WipMountError::InvalidProjection { + route: owner_route.to_string(), + message: "compatibility replacement owner is not mounted".into(), + }); + } + let capabilities = tool_names + .into_iter() + .map(|name| format!("tool:{name}")) + .collect::>(); + for capability in &capabilities { + if let Some(existing) = self + .replaced_compatibility_capabilities + .get(capability) + .filter(|existing| existing.as_str() != owner_route) + { + return Err(WipMountError::RouteCollision { + route: owner_route.to_string(), + existing: existing.clone(), + }); + } + } + for capability in capabilities { + self.replaced_compatibility_capabilities + .insert(capability, owner_route.to_string()); + } + Ok(()) + } + + fn replaces_compatibility(&self, capability: &str) -> bool { + self.replaced_compatibility_capabilities + .contains_key(capability) + } + + fn interface_conflicts( + &self, + interface: &str, + descriptor: &InterfaceDescriptor, + validator: Option<&[u8]>, + replacing_route: Option<&str>, + ) -> bool { + self.mounts.iter().any(|(route, mounted)| { + Some(route.as_str()) != replacing_route + && mounted.projection.interface == interface + && (mounted.projection.descriptor != *descriptor + || mounted.projection.interface_validator.as_deref() != validator) + }) || self.dynamic_mounts.iter().any(|mounted| { + mounted.interface == interface + && (mounted.descriptor != *descriptor + || mounted.interface_validator.as_deref() != validator) + }) + } + fn ensure_interface_available( &self, candidate: &WipProjection, replacing_route: Option<&str>, ) -> Result<(), WipMountError> { - if self.mounts.iter().any(|(route, mounted)| { - Some(route.as_str()) != replacing_route - && mounted.projection.interface == candidate.interface - && mounted.projection.descriptor != candidate.descriptor - }) { + if self.interface_conflicts( + &candidate.interface, + &candidate.descriptor, + candidate.interface_validator.as_deref(), + replacing_route, + ) { return Err(WipMountError::InterfaceCollision { interface: candidate.interface.clone(), }); @@ -274,13 +497,38 @@ impl WipHost { mounted.projection.interface_validator.as_deref(), ) }) + .or_else(|| { + self.registry + .dynamic_mounts + .iter() + .find(|mounted| mounted.interface == reference) + .map(|mounted| (&mounted.descriptor, mounted.interface_validator.as_deref())) + }) } - fn projection(&self, path: &str) -> Option<&WipProjection> { - self.registry - .mounts - .get(path) - .map(|mounted| &mounted.projection) + fn projection(&self, path: &str) -> Option { + if let Some(mounted) = self.registry.mounts.get(path) { + return Some(mounted.projection.clone()); + } + self.registry.dynamic_mounts.iter().find_map(|mounted| { + let item_reference = path.strip_prefix(&format!("{}/", mounted.collection_route))?; + if item_reference.is_empty() || item_reference.contains('/') { + return None; + } + let item = mounted.resolver.resolve(item_reference)?; + let projection = WipProjection { + route: path.to_string(), + capability: mounted.capability.clone(), + kind: WipProjectionKind::Native, + object: item.object, + interface: mounted.interface.clone(), + descriptor: mounted.descriptor.clone(), + interface_validator: mounted.interface_validator.clone(), + handler: item.handler, + }; + validate_projection(&projection).ok()?; + Some(projection) + }) } fn object_at(&self, path: &str) -> Option { @@ -1358,6 +1606,7 @@ fn wip_tool_definitions(runtime: Arc) -> Vec { /// Replace all currently registered ordinary tools with one WIP client surface. /// Call this once after every enabled Feature has contributed its tools. +#[cfg_attr(not(test), allow(dead_code))] pub(crate) fn install_wip_mode( engine: &mut Engine, permissions: Option, @@ -1403,12 +1652,16 @@ pub fn install_wip_mode_with_mounts( let (meta, tool) = handle .get_tool(&definition.name) .ok_or_else(|| format!("registered tool `{}` disappeared", definition.name))?; - let projection = compatibility_projection(meta, tool, permissions.clone()) - .map_err(|error| error.to_string())?; - registry - .mount(projection) - .map_err(|error| error.to_string())?; - names.push(definition.name); + let capability = format!("tool:{}", meta.name); + let name = meta.name.clone(); + if !registry.replaces_compatibility(&capability) { + let projection = compatibility_projection(meta, tool, permissions.clone()) + .map_err(|error| error.to_string())?; + registry + .mount(projection) + .map_err(|error| error.to_string())?; + } + names.push(name); } let runtime = Arc::new(WipRuntime::new( WipHost::new(registry), @@ -1632,7 +1885,7 @@ fn decode_native_arguments( .map_err(|error| format!("native WIP arguments do not match the descriptor: {error}")) } -fn json_to_wip(value: &Json) -> Result { +pub(crate) fn json_to_wip(value: &Json) -> Result { match value { Json::Null => Ok(Value::Unit), Json::Bool(value) => Ok(Value::Boolean(*value)), @@ -1670,7 +1923,7 @@ fn json_to_wip(value: &Json) -> Result { } } -fn wip_to_json(value: &Value) -> Result { +pub(crate) fn wip_to_json(value: &Value) -> Result { match value { Value::Unit => Ok(Json::Null), Value::Boolean(value) => Ok(Json::Bool(*value)), @@ -2227,6 +2480,162 @@ mod tests { assert_eq!(state.session.security_context().as_str(), "worker-a"); } + struct DynamicResolver { + revision: Arc, + calls: Arc, + } + + struct DynamicHandler { + calls: Arc, + } + + #[async_trait] + impl WipOperationHandler for DynamicHandler { + async fn call( + &self, + _operation: &str, + _arguments: &BTreeMap, + _context: WipCallContext, + ) -> Result { + self.calls.fetch_add(1, Ordering::SeqCst); + Ok(WipOperationOutput::native(Value::Unit)) + } + } + + impl WipDynamicItemResolver for DynamicResolver { + fn resolve(&self, item_reference: &str) -> Option { + let sequence = item_reference.strip_prefix("O-")?; + if sequence.is_empty() || !sequence.bytes().all(|byte| byte.is_ascii_digit()) { + return None; + } + Some(WipDynamicItem { + object: Object { + name: item_reference.into(), + description: None, + interfaces: vec!["test.dynamic/v1".into()], + r#ref: Some(format!("objective:{item_reference}")), + validator: Some(vec![self.revision.load(Ordering::SeqCst) as u8]), + }, + handler: Arc::new(DynamicHandler { + calls: Arc::clone(&self.calls), + }), + }) + } + } + + 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 mut collection = compatibility_projection( + meta("Dynamic"), + Arc::new(EchoTool { + calls: Arc::clone(&calls), + }), + None, + ) + .unwrap(); + collection.route = collection_route.clone(); + 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.handler = Arc::new(DynamicHandler { + calls: Arc::clone(&calls), + }); + let descriptor = collection.descriptor.clone(); + let interface_validator = collection.interface_validator.clone(); + registry.mount(collection).unwrap(); + registry + .mount_dynamic(WipDynamicMount { + collection_route, + capability: "objective:item".into(), + interface: "test.dynamic/v1".into(), + descriptor, + interface_validator, + resolver: Arc::new(DynamicResolver { revision, calls }), + }) + .unwrap(); + registry + } + + #[tokio::test] + async fn dynamic_routes_resolve_only_direct_children_and_reject_stale_calls() { + 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() + ); + assert!(host.projection("/hidden/objectives/O-3").is_none()); + + revision.store(2, Ordering::SeqCst); + let result = host + .call( + CallOperationRequest { + target: wip_protocol::Target { + path: "/features/objective/objectives/O-3".into(), + validator: initial.object.validator, + }, + interface: wip_protocol::InterfaceTarget { + reference: initial.interface, + validator: initial.interface_validator, + }, + operation: "call".into(), + arguments: BTreeMap::from([( + "input".into(), + json_to_wip(&json!({"message": "ok"})).unwrap(), + )]), + }, + WipCallContext { + execution: ToolExecutionContext::direct(), + security_context: "worker-a".into(), + }, + ) + .await; + assert!(matches!( + result, + Err(WipOperationError::Protocol(ProtocolError { + code: ProtocolErrorCode::ValidatorMismatch, + .. + })) + )); + assert_eq!(calls.load(Ordering::SeqCst), 0); + } + + #[test] + fn native_capability_claim_hides_only_claimed_compatibility_route() { + let calls = Arc::new(AtomicUsize::new(0)); + let mut engine = Engine::<_, Mutable, ()>::new_annotated(DummyClient); + engine.register_tool(echo_definition("Echo".into(), Arc::clone(&calls))); + engine.register_tool(echo_definition("Other".into(), Arc::clone(&calls))); + + let revision = Arc::new(AtomicUsize::new(1)); + let mut registry = dynamic_registry(revision, calls); + registry + .replace_compatibility_tools("/features/objective/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() + ); + } + struct FailingTool { cancelled: bool, } diff --git a/docs/README.md b/docs/README.md index 433d6b42e..91da1a8d8 100644 --- a/docs/README.md +++ b/docs/README.md @@ -34,6 +34,7 @@ It is not a dumping ground for external research, old plans, API inventories, or 26. [`design/subjektiv-surface.md`](design/subjektiv-surface.md) — confirmed Memoryから生成するresident surface、availability、append-only restore injection。 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. ## What belongs here diff --git a/docs/design/objective-wip-projection.md b/docs/design/objective-wip-projection.md new file mode 100644 index 000000000..a9316e5cc --- /dev/null +++ b/docs/design/objective-wip-projection.md @@ -0,0 +1,55 @@ +# Objective native WIP projection + +## Purpose and authority + +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: + +```text +/features/objective/objectives +/features/objective/objectives/ +``` + +`` 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`. + +## Current operation inventory + +The collection interface is `yoi.objective/collection/v1`: + +| Native operation | Existing Tool permission identity | Parameters | +| --- | --- | --- | +| `query` | `QueryObjective` | `query`, `states`, `linked_ticket_id`, `updated_after`, `updated_before`, `sort`, `limit`, `cursor` | +| `create` | `ObjectiveCreate` | `title`, `body_md`, `state`, `linked_tickets` | + +The item interface is `yoi.objective/item/v1`: + +| Native operation | Existing Tool permission identity | Parameters | +| --- | --- | --- | +| `read` | `ShowObjective` | `event_limit`, `event_cursor` | +| `edit` | `ObjectiveEdit` | `title`, `old_string`, `new_string`, `replace_all` | +| `set_state` | `ObjectiveSetState` | `state` | +| `link_ticket` | `ObjectiveLinkTicket` | `ticket_id` | +| `unlink_ticket` | `ObjectiveUnlinkTicket` | `ticket_id` | + +The item operations intentionally do not accept an Objective `id`. The handler is created from the resolved item route and reconstructs the equivalent ordinary Tool permission arguments with that route-bound identity. Descriptor validation rejects a caller attempting to supply a second Objective target. + +`deny` and `ask` permission results fail closed before Backend dispatch. Backend 401/403 map to permission denial, 404 to missing target, and deterministic client validation failures to invalid arguments. Transport, server, or response-projection failures after dispatch retain the common unknown-outcome contract; mutating operations must not be retried automatically when the outcome is unknown. + +## Bounded reads and references + +`query` retains the ordinary Worker limit of 1–100 and returns stable snippets, canonical `O-*` references, linked `T-*` references, `next_cursor`, and `has_more`. Each result also includes its canonical item path. It does not expand Objective bodies, complete events, or Ticket records. + +`read` retains the ordinary Worker event limit of 1–50 and projects the Backend-bounded body, `body_truncated`, authoritative `revision`, linked-Ticket summaries, bounded events, and event pagination. Internal Objective, Ticket, and event-storage IDs are removed from model-visible results. + +`create` returns the authoritative Objective detail plus its canonical item path. Mutations return the authoritative post-operation detail. The revision observed in a successful read or mutation updates the item's object validator. The common Host validator contract rejects stale object/interface calls before dispatch, and the call response carries the refreshed route validator. + +Linked Ticket references remain strings passed to the existing Objective Backend operation. Their presence does not publish a Ticket object, grant Ticket read or mutation authority, or require a Ticket-native WIP projection. + +## Compatibility and Tool mode + +The native collection/item pair claims all seven Objective compatibility capabilities in WIP mode. Therefore `/tools/QueryObjective`, `/tools/ShowObjective`, and `/tools/Objective*` are not published alongside the native routes. Unrelated enabled tools keep their compatibility projections. + +Normal `tools` mode is unchanged: it still exposes the same seven Objective tools with their existing names and input schemas. Both surfaces share the same `WorkspaceHttpObjectiveBackend` and scoped Backend endpoints. diff --git a/docs/design/worker-wip-mode.md b/docs/design/worker-wip-mode.md index 0b688d955..012a5d768 100644 --- a/docs/design/worker-wip-mode.md +++ b/docs/design/worker-wip-mode.md @@ -52,14 +52,15 @@ 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. +`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. - Routes must be canonical absolute WIP paths. - Object names must match the final route segment. - A projected object exposes exactly its declared interface. -- Unrelated duplicate routes and conflicting interface descriptors fail startup. -- A native projection replaces a compatibility projection only when both declare the same semantic capability (`tool:` for compatibility tools). -- When native is already selected, the compatibility tool is hidden rather than exposed through a second entry. +- Unrelated duplicate routes and conflicting interface descriptors or validators 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.