diff --git a/Cargo.lock b/Cargo.lock index a288e1c66..e0f7cc259 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -64,6 +64,7 @@ dependencies = [ "dotenv", "eventsource-stream", "futures", + "imagesize", "reqwest", "schemars", "serde", @@ -2188,6 +2189,12 @@ dependencies = [ "winapi-util", ] +[[package]] +name = "imagesize" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09e54e57b4c48b40f7aec75635392b12b3421fa26fe8b4332e63138ed278459c" + [[package]] name = "include_dir" version = "0.7.4" diff --git a/crates/agen/Cargo.toml b/crates/agen/Cargo.toml index febf6df18..e01b2ca40 100644 --- a/crates/agen/Cargo.toml +++ b/crates/agen/Cargo.toml @@ -32,6 +32,7 @@ reqwest = { version = "0.13", default-features = false, features = ["stream", "j eventsource-stream = "0.2" zstd = "0.13" base64 = "0.22.1" +imagesize = "0.14" chrono = { version = "0.4", default-features = false, features = ["serde", "clock"], optional = true } agen-macros = { workspace = true } diff --git a/crates/agen/src/engine.rs b/crates/agen/src/engine.rs index decbfbc9b..d35c1778b 100644 --- a/crates/agen/src/engine.rs +++ b/crates/agen/src/engine.rs @@ -626,6 +626,10 @@ pub struct Engine { /// engine history. An error rejects the item and aborts the turn, allowing /// upper layers to make durable storage the commit gate. history_append_cbs: Vec Result<(), String> + Send + Sync>>, + /// Durable host commit gate for image-result corrections. Runs before the + /// correction becomes live or a replacement request can be sent. + image_rejection_handler: + Option, &Item) -> Result<(), String> + Send + Sync>>, /// Request configuration (max_tokens, temperature, etc.) request_config: RequestConfig, /// Cancel notification channel (for interrupting execution) @@ -971,6 +975,16 @@ impl Engine { self.history_append_cbs.push(Box::new(callback)); } + /// Set the host's durable commit gate for correcting a rejected ViewImage + /// result. The original annotation/identity must be preserved. A failed + /// commit leaves both history and the outgoing request untouched. + pub fn set_image_rejection_handler( + &mut self, + handler: impl Fn(&HistoryEntry, &Item) -> Result<(), String> + Send + Sync + 'static, + ) { + self.image_rejection_handler = Some(Box::new(handler)); + } + fn emit_history_append(&self, item: &Item) -> Result<(), EngineError> { for cb in &self.history_append_cbs { cb(item).map_err(EngineError::HistoryAppend)?; @@ -3205,7 +3219,34 @@ impl Engine { "Sending request to LLM" ); - let stream = self.open_stream_with_retry(request, turn, llm_call).await?; + let mut request = request; + let stream = loop { + match self + .open_stream_with_retry(request.clone(), turn, llm_call) + .await + { + Ok(stream) => break stream, + Err(EngineError::Client(error)) if error.is_image_size_rejection() => { + let Some((history_index, request_index)) = + crate::image_recovery::largest_candidate(history, &request) + else { + return Err(EngineError::Client(error)); + }; + let entry = &history.entries()[history_index]; + let replacement = crate::image_recovery::rejected_result(&entry.item); + if let Some(commit) = &self.image_rejection_handler { + commit(entry, &replacement).map_err(EngineError::HistoryAppend)?; + } + history.entries_mut()[history_index].item = replacement.clone(); + // Rebuild the provider request from the corrected projection, + // retaining prior prune/interceptor decisions. No tool runs + // or assistant output have occurred for this failed request. + request.items[request_index] = replacement; + self.emit_warning(crate::image_recovery::REJECTION_MESSAGE); + } + Err(error) => return Err(error), + } + }; self.tool_call_collector.begin_response(); let mut stream = ResponseStreamPump::start(stream); let mut early_tools: Option = None; @@ -3567,6 +3608,7 @@ impl Engine { warning_cbs: Vec::new(), tool_result_cbs: Vec::new(), history_append_cbs: Vec::new(), + image_rejection_handler: None, request_config: RequestConfig::default(), cancel_tx, cancel_rx, @@ -3856,6 +3898,7 @@ impl Engine { warning_cbs: self.warning_cbs, tool_result_cbs: self.tool_result_cbs, history_append_cbs: self.history_append_cbs, + image_rejection_handler: self.image_rejection_handler, request_config: self.request_config, cancel_tx: self.cancel_tx, @@ -4040,6 +4083,7 @@ impl Engine { warning_cbs: self.warning_cbs, tool_result_cbs: self.tool_result_cbs, history_append_cbs: self.history_append_cbs, + image_rejection_handler: self.image_rejection_handler, request_config: self.request_config, cancel_tx: self.cancel_tx, diff --git a/crates/agen/src/image_recovery.rs b/crates/agen/src/image_recovery.rs new file mode 100644 index 000000000..dc38014fa --- /dev/null +++ b/crates/agen/src/image_recovery.rs @@ -0,0 +1,106 @@ +//! Recovery is driven by provider rejection, never a locally chosen image limit. +use crate::{History, Item, Role, ToolResultDisposition, llm_client::Request, tool::Attachment}; + +pub(crate) const REJECTION_MESSAGE: &str = "ViewImage image omitted from model input after the provider rejected image size. This is a recovery candidate, not proof that this particular image caused the rejection. Resize or crop the image and retry ViewImage; do not read the unchanged image again."; + +/// Keep the most recent response's tool results eligible, including early tool +/// results interleaved with that response's later text/calls. Empty boundaries +/// from older versions' failed requests are not evidence of accepted input. +pub(crate) fn accepted_input_end<'a>(items: impl Iterator) -> Option { + let mut response_start = None; + let mut accepted_prefix = None; + for (index, item) in items.enumerate() { + match item { + Item::AssistantResponseBoundary { .. } => response_start = Some(index), + Item::Message { + role: Role::Assistant, + .. + } + | Item::ToolCall { .. } + | Item::Reasoning { .. } => { + accepted_prefix = Some(response_start.unwrap_or(index)); + } + Item::Message { + role: Role::User, .. + } => response_start = None, + _ => {} + } + } + accepted_prefix +} + +/// Select only a ViewImage result actually sent in the rejected request. The +/// tool contract returns exactly one image, so each correction removes one +/// image and preserves the tool call/result pair. Other tools and user uploads +/// are deliberately not rewritten by this recovery policy. +pub(crate) fn largest_candidate( + history: &History, + request: &Request, +) -> Option<(usize, usize)> { + let start = accepted_input_end(history.items()).unwrap_or(0); + let mut largest = None; + for (index, entry) in history.entries().iter().enumerate().skip(start) { + let Item::ToolResult { + call_id, + attachments, + .. + } = &entry.item + else { + continue; + }; + let [Attachment::Image(image)] = attachments.as_slice() else { + continue; + }; + let from_view_image = history.items().any(|item| { + matches!( + item, + Item::ToolCall { call_id: id, name, .. } if id == call_id && name == "ViewImage" + ) + }); + if !from_view_image { + continue; + } + let sent = request.items.iter().position(|item| { + matches!( + item, + Item::ToolResult { call_id: id, attachments: sent, .. } + if id == call_id && sent == attachments + ) + }); + let Some(request_index) = sent else { continue }; + // Header inspection only; no raster allocation or size threshold. + // Unknown dimensions sort by bytes as a deterministic fallback. + let pixels = imagesize::blob_size(image.data()) + .ok() + .map(|size| (size.width as u64).saturating_mul(size.height as u64)) + .unwrap_or(0); + let rank = (pixels, image.data().len(), index); + if largest + .as_ref() + .is_none_or(|(previous, _)| rank > *previous) + { + largest = Some((rank, (index, request_index))); + } + } + largest.map(|(_, candidate)| candidate) +} + +pub(crate) fn rejected_result(item: &Item) -> Item { + let mut item = item.clone(); + if let Item::ToolResult { + summary, + content, + attachments, + disposition, + is_error, + .. + } = &mut item + { + *summary = REJECTION_MESSAGE.to_owned(); + *content = None; + attachments.clear(); + *disposition = ToolResultDisposition::Error; + *is_error = true; + } + item +} diff --git a/crates/agen/src/lib.rs b/crates/agen/src/lib.rs index 869d5c322..fca7069f1 100644 --- a/crates/agen/src/lib.rs +++ b/crates/agen/src/lib.rs @@ -3,6 +3,7 @@ mod engine; mod handler; mod history; +mod image_recovery; mod message; pub(crate) mod callback; diff --git a/crates/agen/src/llm_client/error.rs b/crates/agen/src/llm_client/error.rs index 355940fa1..69334b8a2 100644 --- a/crates/agen/src/llm_client/error.rs +++ b/crates/agen/src/llm_client/error.rs @@ -90,6 +90,35 @@ impl ClientError { } } + /// A provider-declared image size rejection, not a generic invalid input. + /// Some providers (including OpenAI) expose only `invalid_value` here, so + /// narrowly recognize their image-size diagnostic without hardcoded limits. + pub fn is_image_size_rejection(&self) -> bool { + let Self::Api { + status: Some(400 | 413 | 422), + code, + message, + .. + } = self + else { + return false; + }; + if matches!( + code.as_deref(), + Some("image_too_large" | "image_size_exceeded") + ) { + return true; + } + let message = message.to_ascii_lowercase(); + message.contains("image") + && ((message.contains("patches") + && message.contains("exceed") + && message.contains("limit")) + || message.contains("image is too large") + || message.contains("image too large") + || (message.contains("image dimensions") && message.contains("exceed"))) + } + pub fn retry_after(&self) -> Option { match self { ClientError::Api { retry_after, .. } => *retry_after, diff --git a/crates/agen/src/prune.rs b/crates/agen/src/prune.rs index 2ee018904..37af648da 100644 --- a/crates/agen/src/prune.rs +++ b/crates/agen/src/prune.rs @@ -136,14 +136,13 @@ pub fn project(items: &mut [Item], indices: &[usize]) -> usize { /// Remove binary attachments after the first model request that could consume them. /// /// Attachments on tool results following the latest model output are fresh outputs for the -/// immediate follow-up request and remain visible. Once a later assistant/reasoning/tool-call -/// item exists, the model has already had an opportunity to inspect the attachment. Subsequent -/// requests keep the durable tool summary and can call the originating tool again when the image -/// is still needed. Only the request-context clone is modified. +/// immediate follow-up request and remain visible. With early dispatch, later +/// text/calls in the same response have not consumed its tool results: use the +/// confirmed response boundary, not that response's last individual item. +/// Subsequent requests keep the durable tool summary and can call the originating +/// tool again when the image is still needed. Only the request-context clone is modified. pub fn project_consumed_attachments(items: &mut [Item]) -> usize { - let latest_model_output = items.iter().rposition(|item| { - item.is_assistant_message() || item.is_reasoning() || item.is_tool_call() - }); + let latest_model_output = crate::image_recovery::accepted_input_end(items.iter()); let mut count = 0; for (index, item) in items.iter_mut().enumerate() { let is_fresh = latest_model_output.is_some_and(|boundary| index > boundary); diff --git a/crates/agen/tests/image_recovery_test.rs b/crates/agen/tests/image_recovery_test.rs new file mode 100644 index 000000000..2fa11ee06 --- /dev/null +++ b/crates/agen/tests/image_recovery_test.rs @@ -0,0 +1,350 @@ +use agen::llm_client::event::{Event, ResponseStatus, StatusEvent}; +use agen::llm_client::{ClientError, LlmClient, Request, ResponseStream}; +use agen::tool::{Attachment, ImageAttachment}; +use agen::{Engine, EngineRunExit, History, Item, ToolResultDisposition}; +use async_trait::async_trait; +use std::sync::{Arc, Mutex}; + +fn rejection() -> ClientError { + ClientError::Api { status: Some(400), code: Some("invalid_value".into()), message: "The image you provided requires 52150 patches after processing, exceeding the limit of 30000. Please resize the image and try again.".into(), retry_after: None } +} + +fn image_result(id: &str, width: u32, height: u32, padding: usize) -> Item { + // PNG header: dimension inspection never decodes/allocates the pixel plane. + let mut bytes = b"\x89PNG\r\n\x1a\n\0\0\0\rIHDR".to_vec(); + bytes.extend(width.to_be_bytes()); + bytes.extend(height.to_be_bytes()); + bytes.resize(40 + padding, 0); + Item::ToolResult { + id: None, + call_id: id.into(), + summary: "Attached image".into(), + content: None, + disposition: ToolResultDisposition::Success, + is_error: false, + attachments: vec![Attachment::Image(ImageAttachment::new("image/png", bytes))], + } +} +fn history() -> History { + History::from_items(vec![ + Item::user_message("inspect"), + Item::tool_call("small", "ViewImage", "{}"), + Item::tool_call("large", "ViewImage", "{}"), + image_result("small", 10, 10, 2000), + image_result("large", 1600, 33349, 0), + ]) +} +fn images(request: &Request) -> Vec { + request + .items + .iter() + .filter_map(|item| match item { + Item::ToolResult { + call_id, + attachments, + .. + } if !attachments.is_empty() => Some(call_id.clone()), + _ => None, + }) + .collect() +} +#[derive(Clone)] +struct Client { + requests: Arc>>, + failures: usize, + other_error: bool, +} +impl Client { + fn new(failures: usize) -> Self { + Self { + requests: Arc::default(), + failures, + other_error: false, + } + } +} +#[async_trait] +impl LlmClient for Client { + fn clone_boxed(&self) -> Box { + Box::new(self.clone()) + } + async fn stream(&self, request: Request) -> Result { + let mut requests = self.requests.lock().unwrap(); + requests.push(request); + if requests.len() <= self.failures { + return Err(rejection()); + } + if self.other_error { + return Err(ClientError::Api { + status: Some(400), + code: Some("invalid_value".into()), + message: "invalid tool arguments".into(), + retry_after: None, + }); + } + Ok(Box::pin(futures::stream::iter(vec![ + Ok(Event::text_block_start(0)), + Ok(Event::text_delta(0, "resize and retry")), + Ok(Event::text_block_stop(0, None)), + Ok(Event::Status(StatusEvent { + status: ResponseStatus::Completed, + })), + ]))) + } +} + +#[tokio::test] +async fn removes_largest_pixels_not_largest_file_and_keeps_pairing() { + let client = Client::new(1); + let observer = client.clone(); + let mut history = history(); + let mut engine = Engine::new(client); + let committed = Arc::new(Mutex::new(Vec::new())); + let commits = committed.clone(); + engine.set_image_rejection_handler(move |original, replacement| { + commits + .lock() + .unwrap() + .push((original.item.clone(), replacement.clone())); + Ok(()) + }); + let output = engine.run(&mut history, "continue").await; + assert!( + matches!(output.result, EngineRunExit::Finished), + "{:?}", + output.result + ); + let requests = observer.requests.lock().unwrap(); + assert_eq!(requests.len(), 2); + assert_eq!(images(&requests[0]), ["small", "large"]); + assert_eq!(images(&requests[1]), ["small"]); + assert!(requests[1].items.iter().any(|item| matches!(item, Item::ToolResult { call_id, is_error: true, disposition: ToolResultDisposition::Error, summary, .. } if call_id == "large" && summary.contains("retry ViewImage")))); + assert_eq!(committed.lock().unwrap().len(), 1); + assert_eq!( + history + .items() + .filter(|item| matches!(item, Item::ToolResult {call_id, ..} if call_id == "large")) + .count(), + 1 + ); +} + +#[tokio::test] +async fn multiple_rejections_remove_one_image_at_a_time_and_stop_at_exhaustion() { + let client = Client::new(99); + let observer = client.clone(); + let mut history = history(); + let out = Engine::new(client).run(&mut history, "continue").await; + assert!(matches!(out.result, EngineRunExit::Interrupted(_))); + let requests = observer.requests.lock().unwrap(); + assert_eq!(requests.len(), 3); + assert_eq!(images(&requests[1]), ["small"]); + assert!(images(&requests[2]).is_empty()); +} + +#[tokio::test] +async fn unrelated_error_stops_recovery_without_removing_another_image() { + let mut client = Client::new(1); + client.other_error = true; + let observer = client.clone(); + let mut history = history(); + let out = Engine::new(client).run(&mut history, "continue").await; + assert!(matches!(out.result, EngineRunExit::Interrupted(_))); + assert_eq!(observer.requests.lock().unwrap().len(), 2); + assert!(history.items().any(|item| matches!(item, Item::ToolResult {call_id, attachments, ..} if call_id == "small" && !attachments.is_empty()))); +} + +#[tokio::test] +async fn persistence_failure_does_not_change_history_or_retry() { + let client = Client::new(99); + let observer = client.clone(); + let mut history = history(); + let original = history.items_cloned(); + let mut engine = Engine::new(client); + engine.set_image_rejection_handler(|_, _| Err("disk full".into())); + let out = engine.run(&mut history, "continue").await; + assert!(matches!(out.result, EngineRunExit::Interrupted(_))); + assert_eq!( + &history.items_cloned()[..original.len()], + original.as_slice() + ); + assert_eq!(observer.requests.lock().unwrap().len(), 1); +} + +#[tokio::test] +async fn previously_accepted_images_are_not_recovery_candidates() { + let client = Client::new(99); + let observer = client.clone(); + let mut history = history(); + history.push(Item::assistant_message("I saw those images")); + history.push(Item::tool_call("new", "ViewImage", "{}")); + history.push(image_result("new", 1, 1, 0)); + let out = Engine::new(client).run(&mut history, "continue").await; + assert!(matches!(out.result, EngineRunExit::Interrupted(_))); + let requests = observer.requests.lock().unwrap(); + assert_eq!(requests.len(), 2); + // Already-consumed images are projected out, but their durable records + // must not be rewritten as failures. + assert!(images(&requests[1]).is_empty()); + assert_eq!(history.items().filter(|item| matches!(item, Item::ToolResult {call_id, is_error: false, attachments, ..} if (call_id == "small" || call_id == "large") && !attachments.is_empty())).count(), 2); +} + +#[tokio::test] +async fn legacy_empty_failed_request_boundaries_do_not_hide_candidates() { + let client = Client::new(1); + let observer = client.clone(); + let mut history = history(); + history.push(Item::assistant_response_boundary("failed-1")); + history.push(Item::assistant_response_boundary("failed-2")); + let out = Engine::new(client).run(&mut history, "continue").await; + assert!(matches!(out.result, EngineRunExit::Finished)); + assert_eq!(images(&observer.requests.lock().unwrap()[1]), ["small"]); +} + +#[tokio::test] +async fn early_results_remain_candidates_despite_later_text_in_same_response() { + let client = Client::new(1); + let observer = client.clone(); + let mut history = History::from_items(vec![ + Item::user_message("inspect"), + Item::assistant_response_boundary("response-1"), + Item::tool_call("image", "ViewImage", "{}"), + image_result("image", 99, 99, 0), + Item::assistant_message("waiting for image"), + ]); + let out = Engine::new(client).run(&mut history, "continue").await; + assert!( + matches!(out.result, EngineRunExit::Finished), + "{:?}; requests: {:?}", + out.result, + observer.requests.lock().unwrap() + ); + assert!(images(&observer.requests.lock().unwrap()[1]).is_empty()); +} + +#[derive(Clone)] +struct CountingTool { + image: bool, + count: Arc, +} +#[async_trait] +impl agen::tool::Tool for CountingTool { + async fn execute( + &self, + _: &str, + _: agen::tool::ToolExecutionContext, + ) -> Result { + self.count.fetch_add(1, std::sync::atomic::Ordering::SeqCst); + let attachments = if self.image { + let Item::ToolResult { attachments, .. } = image_result("image", 1600, 33349, 0) else { + unreachable!() + }; + attachments + } else { + Vec::new() + }; + Ok(agen::tool::ToolOutput { + summary: "executed once".into(), + content: None, + attachments, + }) + } +} +#[derive(Clone)] +struct ToolClient(Client); +#[async_trait] +impl LlmClient for ToolClient { + fn clone_boxed(&self) -> Box { + Box::new(self.clone()) + } + fn tool_call_completion_support(&self) -> agen::llm_client::ToolCallCompletionSupport { + agen::llm_client::ToolCallCompletionSupport::PerBlock + } + async fn stream(&self, request: Request) -> Result { + if self.0.requests.lock().unwrap().is_empty() { + self.0.requests.lock().unwrap().push(request); + return Ok(Box::pin(futures::stream::iter( + vec![ + Event::tool_use_start(0, "image", "ViewImage"), + Event::tool_input_delta(0, "{}"), + Event::tool_use_stop(0), + Event::tool_use_start(1, "side-effect", "Bash"), + Event::tool_input_delta(1, "{}"), + Event::tool_use_stop(1), + Event::Status(StatusEvent { + status: ResponseStatus::Completed, + }), + ] + .into_iter() + .map(Ok), + ))); + } + self.0.stream(request).await + } +} + +#[tokio::test] +async fn fresh_image_recovery_does_not_repeat_tools_in_either_dispatch_mode() { + for mode in [ + agen::ToolCallDispatchMode::AfterResponse, + agen::ToolCallDispatchMode::OnToolCallComplete, + ] { + let client = Client::new(2); + let mut engine = Engine::new(ToolClient(client.clone())); + engine.set_tool_call_dispatch_mode(mode); + let mut counts = Vec::new(); + for (name, image) in [("ViewImage", true), ("Bash", false)] { + let tool = CountingTool { + image, + count: Arc::default(), + }; + counts.push(tool.count.clone()); + engine.register_tool(Arc::new(move || { + ( + agen::tool::ToolMeta::new(name) + .description("test") + .input_schema(serde_json::json!({"type":"object"})), + Arc::new(tool.clone()), + ) + })); + } + let mut history = History::new(); + let out = engine.run(&mut history, "inspect").await; + assert!( + matches!(out.result, EngineRunExit::Finished), + "{:?}", + out.result + ); + assert!( + counts + .iter() + .all(|count| count.load(std::sync::atomic::Ordering::SeqCst) == 1) + ); + let requests = client.requests.lock().unwrap(); + assert_eq!(requests.len(), 3); + assert_eq!(images(&requests[1]), ["image"]); + assert!(images(&requests[2]).is_empty()); + } +} + +#[test] +fn image_error_classifier_is_not_a_generic_400_retry() { + assert!(rejection().is_image_size_rejection()); + for (status, message) in [ + (400, "invalid image format"), + (400, "image URL not found"), + (400, "context window exceeds token limit"), + (401, "image is too large"), + (500, "image is too large"), + ] { + assert!( + !ClientError::Api { + status: Some(status), + code: Some("invalid_value".into()), + message: message.into(), + retry_after: None + } + .is_image_size_rejection() + ); + } +} diff --git a/crates/manifest/README.md b/crates/manifest/README.md index 7832b9ead..82ccd0aa7 100644 --- a/crates/manifest/README.md +++ b/crates/manifest/README.md @@ -25,6 +25,8 @@ Does not own: Profiles are reusable recipes; resolved Manifests are runtime contracts. Keep runtime-bound fields such as Worker name, concrete delegated scope, sockets, session pointers, and raw secrets out of reusable Profiles. +`feature.workdir_catalog.enabled` defaults to `true` for read-only Repository/Workdir/attachment reference discovery in WIP mode. It is independent of `feature.manage_workdir` (which defaults disabled), registers no normal Tools, and does not grant filesystem, attachment, or Workspace authority. Permissions remain independent. Standalone Tools-mode profiles may retain this default; only WIP installation consumes the flag. Set it to `false` to opt out. + ## See also - [`../../docs/design/profiles-manifests-prompts.md`](../../docs/design/profiles-manifests-prompts.md) diff --git a/crates/manifest/src/config.rs b/crates/manifest/src/config.rs index 910eacfe1..f75441f36 100644 --- a/crates/manifest/src/config.rs +++ b/crates/manifest/src/config.rs @@ -51,8 +51,9 @@ pub struct WorkerManifestConfig { /// is disabled; `Some` requires `default_action` during final resolve. #[serde(default)] pub permissions: Option, - /// Explicit built-in feature/tool-surface enablement. Absent flags resolve - /// disabled after cascade merge. + /// Built-in feature/tool-surface enablement. Absent tool flags resolve + /// disabled after cascade merge; the read-only WIP workdir catalog defaults + /// enabled without granting authority. #[serde(default)] pub feature: FeatureConfigPartial, /// Explicit Model Context Protocol provider declarations. Config parsing @@ -95,6 +96,8 @@ pub struct FeatureConfigPartial { #[serde(default)] pub manage_workdir: Option, #[serde(default)] + pub workdir_catalog: Option, + #[serde(default)] pub ticket: Option, #[serde(default)] pub merge_request: Option, @@ -136,6 +139,11 @@ impl FeatureConfigPartial { other.manage_workdir, FeatureFlagConfigPartial::merge, ), + workdir_catalog: merge_option( + self.workdir_catalog, + other.workdir_catalog, + FeatureFlagConfigPartial::merge, + ), ticket: merge_option(self.ticket, other.ticket, TicketFeatureConfigPartial::merge), merge_request: merge_option( self.merge_request, @@ -388,6 +396,12 @@ impl From for FeatureConfig { .manage_workdir .map(FeatureFlagConfig::from) .unwrap_or_default(), + workdir_catalog: FeatureFlagConfig { + enabled: value + .workdir_catalog + .and_then(|flag| flag.enabled) + .unwrap_or(defaults::WORKDIR_CATALOG_ENABLED), + }, ticket: value .ticket .map(TicketFeatureConfig::from) @@ -579,6 +593,7 @@ impl From for FeatureConfigPartial { workspace_worker_discovery: Some(value.workspace_worker_discovery.into()), objective: Some(value.objective.into()), manage_workdir: Some(value.manage_workdir.into()), + workdir_catalog: Some(value.workdir_catalog.into()), ticket: Some(value.ticket.into()), merge_request: Some(value.merge_request.into()), orchestration: Some(value.orchestration.into()), @@ -766,7 +781,8 @@ impl WorkerManifestConfig { /// Base config populated with the in-code per-field defaults listed in /// [`crate::defaults`]. This is not a selectable Profile and does not - /// enable a launch capability surface. Profile and one-file Manifest + /// enable launch authority or normal Tools. The read-only WIP workdir + /// catalog defaults enabled independently. Profile and one-file Manifest /// resolvers start from this layer so every per-field default lives at /// exactly one call site (the `defaults` module). /// @@ -2232,7 +2248,7 @@ worker_max_turns = 7 } #[test] - fn feature_flags_default_disabled_in_resolved_manifest() { + fn tool_feature_flags_default_disabled_except_read_only_workdir_catalog() { let manifest: WorkerManifest = minimal_valid().try_into().unwrap(); assert!(!manifest.feature.task.enabled); assert!(!manifest.feature.memory.profile.enabled); @@ -2240,10 +2256,106 @@ worker_max_turns = 7 assert!(!manifest.feature.sub_worker.enabled); assert!(!manifest.feature.objective.enabled); assert!(!manifest.feature.manage_workdir.enabled); + assert!(manifest.feature.workdir_catalog.enabled); assert!(!manifest.feature.ticket.enabled); assert!(!manifest.feature.merge_request.any()); } + #[test] + fn workdir_catalog_defaults_agree_for_partial_and_resolved_configs() { + assert!(FeatureConfig::default().workdir_catalog.enabled); + assert!(FeatureConfigPartial::default().workdir_catalog.is_none()); + for source in ["", "[feature]\n", "[feature.workdir_catalog]\n"] { + let partial = WorkerManifestConfig::from_toml(source).unwrap(); + let feature = FeatureConfig::from(partial.feature); + assert!(feature.workdir_catalog.enabled, "{source}"); + let value: toml::Value = toml::from_str(source).unwrap(); + let resolved: FeatureConfig = serde_json::from_value( + serde_json::to_value( + value + .get("feature") + .cloned() + .unwrap_or_else(|| toml::Value::Table(Default::default())), + ) + .unwrap(), + ) + .unwrap(); + assert_eq!(feature, resolved); + } + for source in ["{}", r#"{"workdir_catalog": {}}"#] { + let feature: FeatureConfig = serde_json::from_str(source).unwrap(); + assert!(feature.workdir_catalog.enabled); + assert!(!feature.manage_workdir.enabled); + } + } + + #[test] + fn workdir_catalog_is_independent_and_merges_partial_layers() { + for catalog in [false, true] { + for manage in [false, true] { + let source = format!( + "[feature.workdir_catalog]\nenabled = {catalog}\n\ + [feature.manage_workdir]\nenabled = {manage}\n" + ); + let lower = WorkerManifestConfig::from_toml(&source).unwrap(); + let empty = WorkerManifestConfig::from_toml("[feature.workdir_catalog]").unwrap(); + let cfg = minimal_valid().merge(lower.clone()).merge(empty); + let manifest: WorkerManifest = cfg.try_into().unwrap(); + assert_eq!(manifest.feature.workdir_catalog.enabled, catalog); + assert_eq!(manifest.feature.manage_workdir.enabled, manage); + + let override_source = + format!("[feature.workdir_catalog]\nenabled = {}\n", !catalog); + let upper = WorkerManifestConfig::from_toml(&override_source).unwrap(); + let manifest: WorkerManifest = minimal_valid() + .merge(lower) + .merge(upper) + .try_into() + .unwrap(); + assert_eq!(manifest.feature.workdir_catalog.enabled, !catalog); + assert_eq!(manifest.feature.manage_workdir.enabled, manage); + } + } + } + + #[test] + fn workdir_catalog_roundtrips_config_and_persisted_snapshots() { + for enabled in [false, true] { + let mut cfg = minimal_valid(); + cfg.feature.workdir_catalog = Some(FeatureFlagConfigPartial { + enabled: Some(enabled), + }); + let manifest: WorkerManifest = cfg.try_into().unwrap(); + let partial = FeatureConfigPartial::from(manifest.feature.clone()); + assert_eq!( + partial.workdir_catalog.as_ref().unwrap().enabled, + Some(enabled) + ); + let serialized = toml::to_string(&partial).unwrap(); + let partial: FeatureConfigPartial = toml::from_str(&serialized).unwrap(); + assert_eq!(FeatureConfig::from(partial), manifest.feature); + let serialized = serde_json::to_value(&manifest.feature).unwrap(); + assert_eq!(serialized["workdir_catalog"]["enabled"], enabled); + let feature: FeatureConfig = serde_json::from_value(serialized).unwrap(); + assert_eq!(feature, manifest.feature); + + let snapshot = crate::write_persisted_worker_manifest_snapshot(&manifest).unwrap(); + let restored = + crate::read_persisted_worker_manifest_snapshot(snapshot.clone()).unwrap(); + assert_eq!(restored.feature.workdir_catalog.enabled, enabled); + assert!(!restored.feature.manage_workdir.enabled); + + let mut legacy = snapshot; + legacy["manifest"]["feature"] + .as_object_mut() + .unwrap() + .remove("workdir_catalog"); + let restored = crate::read_persisted_worker_manifest_snapshot(legacy).unwrap(); + assert!(restored.feature.workdir_catalog.enabled); + assert!(!restored.feature.manage_workdir.enabled); + } + } + #[test] fn from_toml_parses_explicit_feature_flags() { let cfg = WorkerManifestConfig::from_toml( diff --git a/crates/manifest/src/defaults.rs b/crates/manifest/src/defaults.rs index bf4755b82..8b5b9619e 100644 --- a/crates/manifest/src/defaults.rs +++ b/crates/manifest/src/defaults.rs @@ -6,6 +6,10 @@ //! `pub const`. Both paths read from this module, so changing a //! default requires editing exactly one line. +/// Read-only Repository/Workdir/attachment reference discovery in WIP mode. +/// This enables a reference provider, not normal Tools or any authority grant. +pub const WORKDIR_CATALOG_ENABLED: bool = true; + /// Byte-size cap applied to any tool's `content` output when no /// per-tool override is set. See [`crate::ToolOutputLimits`]. pub const TOOL_OUTPUT_MAX_BYTES: usize = 64 * 1024; diff --git a/crates/manifest/src/lib.rs b/crates/manifest/src/lib.rs index 62634b34f..b37ce3b9a 100644 --- a/crates/manifest/src/lib.rs +++ b/crates/manifest/src/lib.rs @@ -65,8 +65,9 @@ pub struct WorkerManifest { /// permission layer is disabled and tool calls run as before. #[serde(default)] pub permissions: Option, - /// Explicit built-in feature/tool-surface enablement. Omitted feature flags - /// resolve disabled so Profile authors choose the exposed built-in surfaces. + /// Built-in feature/tool-surface enablement. Omitted tool features resolve + /// disabled; the read-only WIP workdir catalog defaults enabled without + /// granting authority or registering normal Tools. #[serde(default)] pub feature: FeatureConfig, /// Explicit external Model Context Protocol provider configuration. This @@ -131,6 +132,15 @@ pub struct FeatureConfig { pub objective: FeatureFlagConfig, #[serde(default)] pub manage_workdir: FeatureFlagConfig, + /// Read-only Repository/Workdir/attachment reference discovery. Defaults + /// enabled, is installed only in WIP mode, and exposes no normal Tools. + /// Independent of `manage_workdir`; permissions and host authority remain + /// separate, and discovering a reference grants no attachment or file access. + #[serde( + default = "default_workdir_catalog", + deserialize_with = "deserialize_workdir_catalog" + )] + pub workdir_catalog: FeatureFlagConfig, #[serde(default)] pub ticket: TicketFeatureConfig, #[serde(default)] @@ -139,6 +149,22 @@ pub struct FeatureConfig { pub orchestration: FeatureFlagConfig, } +fn default_workdir_catalog() -> FeatureFlagConfig { + FeatureFlagConfig { + enabled: defaults::WORKDIR_CATALOG_ENABLED, + } +} + +fn deserialize_workdir_catalog<'de, D>(deserializer: D) -> Result +where + D: serde::Deserializer<'de>, +{ + let flag = config::FeatureFlagConfigPartial::deserialize(deserializer)?; + Ok(FeatureFlagConfig { + enabled: flag.enabled.unwrap_or(defaults::WORKDIR_CATALOG_ENABLED), + }) +} + impl Default for FeatureConfig { fn default() -> Self { Self { @@ -153,6 +179,7 @@ impl Default for FeatureConfig { workspace_worker_discovery: FeatureFlagConfig::disabled(), objective: FeatureFlagConfig::disabled(), manage_workdir: FeatureFlagConfig::disabled(), + workdir_catalog: default_workdir_catalog(), ticket: TicketFeatureConfig::default(), merge_request: MergeRequestFeatureConfig::default(), orchestration: FeatureFlagConfig::disabled(), diff --git a/crates/manifest/src/profile.rs b/crates/manifest/src/profile.rs index c71fe2e7b..aba2a5a9f 100644 --- a/crates/manifest/src/profile.rs +++ b/crates/manifest/src/profile.rs @@ -181,6 +181,8 @@ pub fn validate_profile_execution_target( if feature.flow.enabled { requirements.insert(WorkspaceAuthorityRequirement::Flow); } + // workdir_catalog is a WIP-only reference provider, not Workspace authority + // or normal Tools. It must not prevent standalone Tools-mode launches. if feature.manage_workdir.enabled { requirements.insert(WorkspaceAuthorityRequirement::ManageWorkdir); } @@ -1586,6 +1588,59 @@ mod tests { assert!(!resolved.manifest.feature.manage_workdir.enabled); } + #[test] + fn workdir_catalog_does_not_require_workspace_authority_or_grant_scope() { + let tmp = TempDir::new().unwrap(); + for catalog in [None, Some(false), Some(true)] { + let mut artifact = serde_json::json!({ + "worker": { "mode": "tools" }, + "feature": { "manage_workdir": { "enabled": false } }, + "permissions": { "default_action": "deny" } + }); + if let Some(enabled) = catalog { + artifact["feature"]["workdir_catalog"] = serde_json::json!({ "enabled": enabled }); + } + let resolved = resolve_profile_artifact_value( + artifact, + ProfileSource::Archive { + archive_id: "catalog-test".into(), + source: "test".into(), + }, + tmp.path(), + "catalog-worker", + ) + .unwrap(); + let manifest = resolved.manifest; + assert_eq!( + manifest.feature.workdir_catalog.enabled, + catalog.unwrap_or(true) + ); + assert!(!manifest.feature.manage_workdir.enabled); + assert_eq!(manifest.worker.mode, crate::WorkerMode::Tools); + assert!(manifest.scope.allow.is_empty()); + assert!(manifest.delegation_scope.allow.is_empty()); + assert_eq!( + manifest.permissions.as_ref().unwrap().default_action, + crate::ToolPermissionAction::Deny + ); + validate_profile_execution_target(&manifest, ProfileExecutionTarget::Standalone) + .unwrap(); + + let mut manage_manifest = manifest; + manage_manifest.feature.manage_workdir.enabled = true; + let error = validate_profile_execution_target( + &manage_manifest, + ProfileExecutionTarget::Standalone, + ) + .unwrap_err(); + assert!(matches!( + error, + ProfileError::UnsupportedExecutionTarget { requirements, .. } + if requirements == vec![WorkspaceAuthorityRequirement::ManageWorkdir] + )); + } + } + #[test] fn standalone_rejects_profiles_that_require_workspace_authority() { let tmp = TempDir::new().unwrap(); diff --git a/crates/server-api/src/lib.rs b/crates/server-api/src/lib.rs index f908e3e53..4cf41c3ec 100644 --- a/crates/server-api/src/lib.rs +++ b/crates/server-api/src/lib.rs @@ -190,8 +190,14 @@ impl_openapi_schema!( WorkingDirectoryListResponse, WorkingDirectoryDetailResponse, WorkingDirectoryCreateResponse, + CurrentWorkerWorkdirCatalogQuery, + CurrentWorkerWorkdirCatalogResponse, CurrentWorkerWorkdirAttachRequest, CurrentWorkerWorkdirAttachmentResponse, + CurrentWorkerWorkdirAttachmentItem, + CurrentWorkerWorkdirAttachmentListQuery, + CurrentWorkerWorkdirAttachmentListResponse, + CurrentWorkerWorkdirDetachQuery, CurrentWorkerWorkdirOperationRequest, CurrentWorkerWorkdirOperationResponse, ExternalWorkdirGrantCreateRequest, @@ -237,6 +243,7 @@ impl_openapi_schema!( MemoryConsolidateStagingRequest, MemoryConsolidationResponse, SubjektivSubjectCreateRequest, + SubjektivSubjectBehaviorUpdateRequest, SubjektivSubjectListQuery, SubjektivSubjectListResponse, SubjektivSubjectResponse, @@ -1163,6 +1170,20 @@ pub trait ServerApi { #[path] workspace_id: String, #[path] subject_id: String, ) -> Result; + #[patch( + "/api/w/{workspace_id}/subjektiv/subjects/{subject_id}/behavior", + status = 200, + error_status = 400, + additional_error_statuses = [401, 403, 404, 409, 500], + bearer_auth = true, + browser_auth = true, + )] + async fn subjektiv_subject_behavior_update( + &self, + #[path] workspace_id: String, + #[path] subject_id: String, + #[body] request: SubjektivSubjectBehaviorUpdateRequest, + ) -> Result; #[get( "/api/w/{workspace_id}/subjektiv/subjects/{subject_id}/surface", status = 200, @@ -2549,6 +2570,32 @@ pub trait ServerApi { #[path] working_directory_id: String, #[body] request: WorkingDirectoryRemovalRequest, ) -> Result; + #[get( + "/api/w/{workspace_id}/workers/self/workdir-catalog", + status = 200, + error_status = 400, + additional_error_statuses = [401, 403, 404, 500], + openapi = false, + )] + async fn current_worker_workdir_catalog( + &self, + #[extension] context: ServerRequestContext, + #[path] workspace_id: String, + #[query] query: CurrentWorkerWorkdirCatalogQuery, + ) -> Result; + #[get( + "/api/w/{workspace_id}/workers/self/workdir-attachments", + status = 200, + error_status = 400, + additional_error_statuses = [401, 403, 404, 500], + openapi = false, + )] + async fn current_worker_workdir_attachment_list( + &self, + #[extension] context: ServerRequestContext, + #[path] workspace_id: String, + #[query] query: CurrentWorkerWorkdirAttachmentListQuery, + ) -> Result; #[post( "/api/w/{workspace_id}/workers/self/workdir-attachments", status = 200, @@ -2574,6 +2621,7 @@ pub trait ServerApi { #[extension] context: ServerRequestContext, #[path] workspace_id: String, #[path] alias: String, + #[query] query: CurrentWorkerWorkdirDetachQuery, ) -> Result; #[post( "/api/w/{workspace_id}/workers/self/workdir-session/operations", @@ -4579,9 +4627,20 @@ pub enum SubjektivSubjectState { #[serde(deny_unknown_fields)] pub struct SubjektivSubjectCreateRequest { pub role: String, + #[serde(default)] + pub behavior_md: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)] +#[serde(deny_unknown_fields)] +pub struct SubjektivSubjectBehaviorUpdateRequest { + #[schemars(range(min = 0, max = 9_007_199_254_740_991_u64))] + pub expected_behavior_revision: u64, + pub behavior_md: String, } pub const SUBJEKTIV_BROWSER_MAX_LIST_LIMIT: usize = 100; +pub const SUBJEKTIV_MAX_BEHAVIOR_BYTES: usize = 16 * 1024; #[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq, JsonSchema)] #[serde(deny_unknown_fields)] @@ -4609,6 +4668,9 @@ pub struct SubjektivSubjectListResponse { pub struct SubjektivSubjectResponse { pub id: String, pub role: String, + pub behavior_md: String, + #[schemars(range(min = 0, max = 9_007_199_254_740_991_u64))] + pub behavior_revision: u64, pub state: SubjektivSubjectState, #[schemars(range(min = 0, max = 9_007_199_254_740_991_u64))] pub store_revision: u64, @@ -5442,10 +5504,22 @@ pub struct SubjektivSurfaceFailureResponse { pub status: String, } +#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] +#[serde(deny_unknown_fields)] +pub struct SubjektivResidentContextOutput { + /// Exact user-managed document. Empty means explicitly unset. + pub behavior_md: String, + #[schemars(range(min = 0, max = 9_007_199_254_740_991_u64))] + pub behavior_revision: u64, + /// Independently-fresh generated Memory projection. + pub memory_surface: memory::backend::MemoryResidentSummaryOutput, +} + #[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] #[serde(tag = "operation", content = "input", rename_all = "snake_case")] pub enum SubjektivMemoryBackendOperation { ResidentSummary(memory::backend::MemoryResidentSummaryOperation), + ResidentContext(memory::backend::MemoryResidentSummaryOperation), Query(SubjektivMemoryQueryRequest), Read(SubjektivMemoryReadRequest), ListRevisions(SubjektivMemoryListRevisionsRequest), @@ -5618,6 +5692,7 @@ pub struct SubjektivMemoryStageExplicitResponse { #[serde(tag = "result", content = "data", rename_all = "snake_case")] pub enum SubjektivMemoryBackendResponse { ResidentSummary(memory::backend::MemoryResidentSummaryOutput), + ResidentContext(SubjektivResidentContextOutput), Query(SubjektivMemoryQueryResponse), Read(SubjektivMemoryReadResponse), ListRevisions(SubjektivMemoryListRevisionsResponse), @@ -6754,6 +6829,69 @@ pub struct CurrentWorkerWorkdirAttachRequest { pub working_directory_id: String, } +/// Stable Workdir-ID keyset paging over the authoritative inventory, not a Runtime snapshot. +#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq, JsonSchema)] +#[serde(deny_unknown_fields)] +pub struct CurrentWorkerWorkdirCatalogQuery { + /// Defaults to 50; valid range is 1..=100 scanned registry records. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub limit: Option, + /// Exclusive stable Workdir ID. An empty policy-filtered page can still have a next cursor. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub cursor: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)] +#[serde(deny_unknown_fields)] +pub struct CurrentWorkerWorkdirCatalogResponse { + pub workspace_id: String, + pub items: Vec, + pub next_cursor: Option, + /// Opaque digest of the complete authoritative inventory and its occupancy. + pub revision: String, +} + +/// Bounded, alias-ordered paging over the current Worker's active attachments. +#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq, JsonSchema)] +#[serde(deny_unknown_fields)] +pub struct CurrentWorkerWorkdirAttachmentListQuery { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub limit: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub offset: Option, + /// Exact caller-scoped connection lookup, applied before paging; 1..=128 bytes. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub connection_id: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)] +#[serde(deny_unknown_fields)] +pub struct CurrentWorkerWorkdirAttachmentItem { + pub alias: String, + pub working_directory_id: String, + pub capabilities: workdir::WorkdirSessionCapabilities, + /// Opaque durable identity of this attachment lifetime, not a Workdir ID. + pub connection_id: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)] +#[serde(deny_unknown_fields)] +pub struct CurrentWorkerWorkdirAttachmentListResponse { + pub workspace_id: String, + pub items: Vec, + pub next_offset: Option, + /// Opaque digest of this caller's complete active connection set, independent of query paging/filtering. + pub revision: String, +} + +#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq, JsonSchema)] +#[serde(deny_unknown_fields)] +pub struct CurrentWorkerWorkdirDetachQuery { + /// When omitted, retain the normal Tool's unconditional detach contract. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub expected_connection_id: Option, +} + #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)] #[serde(deny_unknown_fields)] pub struct CurrentWorkerWorkdirAttachmentResponse { @@ -9869,9 +10007,11 @@ pub fn memory_api_typescript() -> String { // `ts-rs` solely for this read-only projection. let subjektiv_browser_declarations = r#"export type SubjektivSubjectState = "active" | "retired"; -export type SubjektivSubjectCreateRequest = { role: string, }; +export type SubjektivSubjectCreateRequest = { role: string, behavior_md?: string, }; -export type SubjektivSubjectResponse = { id: string, role: string, state: SubjektivSubjectState, store_revision: number, created_at: string, updated_at: string, current_worker?: | import("./worker-launch-api").WorkerLaunchWorkerSummary | null, }; +export type SubjektivSubjectBehaviorUpdateRequest = { expected_behavior_revision: number, behavior_md: string, }; + +export type SubjektivSubjectResponse = { id: string, role: string, behavior_md: string, behavior_revision: number, state: SubjektivSubjectState, store_revision: number, created_at: string, updated_at: string, current_worker?: | import("./worker-launch-api").WorkerLaunchWorkerSummary | null, }; export type SubjektivSubjectListResponse = { limit: number, items: Array, next_cursor?: string | null, has_more: boolean, }; @@ -9904,8 +10044,11 @@ export type SubjektivMemoryListRevisionsResponse = { memory_id: string, current_ let limits = format!( "export const MEMORY_API_LIMITS = {{\n maxResponseBytes: {MEMORY_API_MAX_RESPONSE_BYTES},\n maxDocumentBytes: {MEMORY_API_MAX_DOCUMENT_BYTES},\n maxCollectionItems: {MEMORY_API_MAX_COLLECTION_ITEMS},\n maxStringBytes: {MEMORY_API_MAX_STRING_BYTES},\n maxIdentifierBytes: {MEMORY_API_MAX_IDENTIFIER_BYTES},\n}} as const;" ); + let subjektiv_limits = format!( + "export const SUBJEKTIV_API_LIMITS = {{\n maxBehaviorBytes: {SUBJEKTIV_MAX_BEHAVIOR_BYTES},\n}} as const;" + ); format!( - "// Generated from server-api. Do not edit by hand.\n// Regenerate: cargo run -q -p server-api --features typescript --example generate_memory_api_types > web/workspace/src/lib/generated/memory-api.ts\n\n{limits}\n\n{}\n\n{subjektiv_browser_declarations}\n", + "// Generated from server-api. Do not edit by hand.\n// Regenerate: cargo run -q -p server-api --features typescript --example generate_memory_api_types > web/workspace/src/lib/generated/memory-api.ts\n\n{limits}\n\n{subjektiv_limits}\n\n{}\n\n{subjektiv_browser_declarations}\n", declarations .into_iter() .map(|declaration| format!("export {declaration}")) @@ -11938,6 +12081,135 @@ mod tests { ); } + #[test] + fn current_worker_attachment_contract_keeps_normal_tool_response_closed() { + let lookup = CurrentWorkerWorkdirAttachmentListQuery { + limit: Some(1), + offset: None, + connection_id: Some("opaque-connection".to_string()), + }; + let value = serde_json::json!({"limit": 1, "connection_id": "opaque-connection"}); + assert_eq!(serde_json::to_value(&lookup).unwrap(), value); + assert_eq!( + serde_json::from_value::(value).unwrap(), + lookup + ); + assert_eq!( + serde_json::to_value(CurrentWorkerWorkdirAttachmentListQuery::default()).unwrap(), + serde_json::json!({}) + ); + assert!( + serde_json::from_value::( + serde_json::json!({"connection_id": 123}) + ) + .is_err() + ); + assert!( + serde_json::from_value::( + serde_json::json!({"expected_connection_id": "wrong-field"}) + ) + .is_err() + ); + let query: CurrentWorkerWorkdirDetachQuery = + serde_json::from_value(serde_json::json!({})).unwrap(); + assert_eq!(query.expected_connection_id, None); + assert_eq!(serde_json::to_value(query).unwrap(), serde_json::json!({})); + assert!( + serde_json::from_value::( + serde_json::json!({"connection_id":"wrong-field"}) + ) + .is_err() + ); + let response = CurrentWorkerWorkdirAttachmentResponse { + workspace_id: "workspace".to_string(), + alias: "checkout".to_string(), + working_directory_id: "workdir".to_string(), + capabilities: workdir::WorkdirSessionCapabilities::EMPTY, + attached: false, + }; + let value = serde_json::to_value(&response).unwrap(); + assert_eq!(value.as_object().unwrap().len(), 5); + assert!(value.get("connection_id").is_none()); + assert_eq!( + serde_json::from_value::(value).unwrap(), + response + ); + let list = CurrentWorkerWorkdirAttachmentListResponse { + workspace_id: "workspace".to_string(), + revision: "opaque-revision".to_string(), + next_offset: Some(1), + items: vec![CurrentWorkerWorkdirAttachmentItem { + alias: "checkout".to_string(), + working_directory_id: "workdir".to_string(), + capabilities: workdir::WorkdirSessionCapabilities::READ_ONLY, + connection_id: "opaque".to_string(), + }], + }; + assert_eq!( + serde_json::from_value::( + serde_json::to_value(&list).unwrap() + ) + .unwrap(), + list + ); + } + + #[test] + fn current_worker_workdir_catalog_contract_is_bounded_and_revision_is_required() { + let query = CurrentWorkerWorkdirCatalogQuery { + limit: Some(100), + cursor: Some("stable-id".to_string()), + }; + assert_eq!( + serde_json::from_value::( + serde_json::to_value(&query).unwrap() + ) + .unwrap(), + query + ); + assert_eq!( + serde_json::to_value(CurrentWorkerWorkdirCatalogQuery::default()).unwrap(), + serde_json::json!({}) + ); + assert!( + serde_json::from_value::( + serde_json::json!({"offset": 0}) + ) + .is_err() + ); + assert!( + serde_json::from_value::( + serde_json::json!({"limit": -1}) + ) + .is_err() + ); + let response = CurrentWorkerWorkdirCatalogResponse { + workspace_id: "workspace".to_string(), + items: Vec::new(), + next_cursor: Some("scanned-legacy-row".to_string()), + revision: "opaque".to_string(), + }; + assert_eq!( + serde_json::from_value::( + serde_json::to_value(&response).unwrap() + ) + .unwrap(), + response + ); + assert!( + serde_json::from_value::( + serde_json::json!({"workspace_id": "workspace", "items": [], "next_cursor": null}) + ) + .is_err() + ); + assert!( + serde_json::from_value::( + serde_json::json!({"workspace_id": "workspace", "items": [], "next_offset": null}) + ) + .is_err() + ); + } + #[test] fn workspace_and_repository_response_shapes_round_trip() { let workspace = serde_json::json!({ @@ -12942,6 +13214,8 @@ mod openapi_artifact_tests { // identity, or a one-use Reviewer capability. They remain generated Rust client/Axum // operations, but must not appear as unauthenticated operations in the public OpenAPI. const SIGNED_INTERNAL: &[&str] = &[ + "current_worker_workdir_catalog", + "current_worker_workdir_attachment_list", "current_worker_workdir_attach", "current_worker_workdir_detach", "current_worker_workdir_operation", diff --git a/crates/session-store/src/public_index.rs b/crates/session-store/src/public_index.rs index d569c09b7..1e1406d16 100644 --- a/crates/session-store/src/public_index.rs +++ b/crates/session-store/src/public_index.rs @@ -1071,6 +1071,7 @@ fn persisted_entry_units(entries: &[LogEntry]) -> usize { | LogEntry::AnnotatedUserInput { history, .. } => history.len(), LogEntry::AnnotatedAssistantItem { .. } | LogEntry::AnnotatedToolResult { .. } + | LogEntry::ToolResultCorrected { .. } | LogEntry::AnnotatedSystemItem { .. } => 1, _ => 0, }; diff --git a/crates/session-store/src/public_snapshot.rs b/crates/session-store/src/public_snapshot.rs index bc7659c12..3f7f8e53c 100644 --- a/crates/session-store/src/public_snapshot.rs +++ b/crates/session-store/src/public_snapshot.rs @@ -7,7 +7,7 @@ use protocol::{ use serde::{Deserialize, Serialize}; use sha2::{Digest, Sha256}; -use std::collections::HashSet; +use std::collections::{HashMap, HashSet}; use std::path::Path; use thiserror::Error; @@ -139,6 +139,17 @@ struct RetainedHistoryCursor { before_offset: u64, record_end_offset: Option, seed_entry_index: Option, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + corrections: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(deny_unknown_fields)] +struct ToolCorrectionPosition { + entry_id: String, + segment_id: SegmentId, + start: u64, + end: u64, } #[derive(Debug)] @@ -445,6 +456,7 @@ pub fn read_retained_session_history_page( limits, &mut scanned_bytes, &mut scanned_entries, + &mut Vec::new(), )?; Some(SessionHistoryLineageBoundary { lineage_id: lineage_identity(active.session_id, &lineage[1..]), @@ -456,6 +468,10 @@ pub fn read_retained_session_history_page( } else { None }; + let mut correction_positions = cursor + .as_ref() + .map(|c| c.corrections.clone()) + .unwrap_or_default(); let mut turns = read_history_turns_backward( &store, active.session_id, @@ -465,11 +481,23 @@ pub fn read_retained_session_history_page( limits, &mut scanned_bytes, &mut scanned_entries, + &mut correction_positions, )?; let has_more = turns.len() > page_limit; if has_more { turns.truncate(page_limit); } + // Carry only corrections whose original results are still on older pages. + let returned_ids: HashSet<_> = turns + .iter() + .flat_map(|turn| { + turn.turn + .entries + .iter() + .map(|entry| entry.entry_id.as_str()) + }) + .collect(); + correction_positions.retain(|position| !returned_ids.contains(position.entry_id.as_str())); let next_cursor = if has_more { let oldest = turns .last() @@ -485,6 +513,7 @@ pub fn read_retained_session_history_page( before_offset: oldest.position.before_offset, record_end_offset: oldest.position.record_end_offset, seed_entry_index: oldest.position.seed_entry_index, + corrections: correction_positions, })?) } else { None @@ -702,6 +731,7 @@ fn persisted_entry_units(entries: &[LogEntry]) -> usize { | LogEntry::AnnotatedUserInput { history, .. } => history.len(), LogEntry::AnnotatedAssistantItem { .. } | LogEntry::AnnotatedToolResult { .. } + | LogEntry::ToolResultCorrected { .. } | LogEntry::AnnotatedSystemItem { .. } => 1, _ => 0, }; @@ -814,10 +844,56 @@ fn read_history_turns_backward( limits: RetainedHistoryReadLimits, scanned_bytes: &mut u64, scanned_entries: &mut usize, + correction_positions: &mut Vec, ) -> Result, RetainedHistoryReadError> { let mut turns = Vec::new(); let mut pending_entries = Vec::new(); let mut seen_entries = HashSet::new(); + let mut corrections = HashMap::::new(); + + // Carry only bounded log positions, never correction text or image bodies, + // across pages. Re-read and validate the referenced records from adopted + // lineage rather than rescanning an arbitrarily large newer suffix. + for position in correction_positions.iter() { + let segment = lineage + .iter() + .find(|s| s.segment_id == position.segment_id) + .ok_or(RetainedHistoryReadError::InvalidCursor)?; + let adopted_end = locate_adopted_segment_end( + store, + session_id, + segment, + limits, + scanned_bytes, + scanned_entries, + )?; + if position.start >= position.end || position.end > adopted_end { + return Err(RetainedHistoryReadError::InvalidCursor); + } + let record = store + .read_log_record_range_read_only_bounded( + session_id, + position.segment_id, + position.start, + position.end, + limits.max_scan_bytes.saturating_sub(*scanned_bytes), + ) + .map_err(map_history_cursor_store_error)?; + *scanned_bytes = scanned_bytes + .checked_add(record.end_offset - record.start_offset) + .ok_or(RetainedHistoryReadError::ResourceLimit)?; + *scanned_entries = scanned_entries + .checked_add(persisted_entry_units(std::slice::from_ref(&record.entry))) + .ok_or(RetainedHistoryReadError::ResourceLimit)?; + if *scanned_entries > limits.max_entries { + return Err(RetainedHistoryReadError::ResourceLimit); + } + if !matches!(&record.entry, LogEntry::ToolResultCorrected { entry, .. } if entry.metadata.entry_id.0 == position.entry_id) + || !capture_tool_correction(&record.entry, &mut corrections) + { + return Err(RetainedHistoryReadError::InvalidCursor); + } + } let (start_segment_index, start_position, cursor_turn_id) = match start { Some((position, turn_id)) => { @@ -878,32 +954,6 @@ fn read_history_turns_backward( for (segment_index, segment) in lineage.iter().enumerate().skip(start_segment_index) { let starting_here = segment_index == start_segment_index; - if starting_here - && start_position - .as_ref() - .and_then(|position| position.seed_entry_index) - .is_some() - { - let seed_index = start_position - .as_ref() - .and_then(|position| position.seed_entry_index) - .expect("checked seed cursor"); - if collect_history_entries_backward( - &segment.seed_entries[..seed_index], - segment.segment_id, - 0, - None, - true, - turn_limit, - &mut seen_entries, - &mut pending_entries, - &mut turns, - ) { - return Ok(turns); - } - continue; - } - let adopted_end = locate_adopted_segment_end( store, session_id, @@ -923,6 +973,32 @@ fn read_history_turns_backward( if before_offset > adopted_end { return Err(RetainedHistoryReadError::InvalidCursor); } + if starting_here + && start_position + .as_ref() + .and_then(|position| position.seed_entry_index) + .is_some() + { + let seed_index = start_position + .as_ref() + .and_then(|position| position.seed_entry_index) + .expect("checked seed cursor"); + if collect_history_entries_backward( + &corrected_history_entries(&segment.seed_entries[..seed_index], &corrections), + segment.segment_id, + 0, + None, + true, + turn_limit, + &mut seen_entries, + &mut pending_entries, + &mut turns, + ) { + return Ok(turns); + } + continue; + } + let mut reader = store .open_retained_segment_reader(session_id, segment.segment_id, before_offset) .map_err(map_history_store_error)?; @@ -949,7 +1025,25 @@ fn read_history_turns_backward( } continue; } - let entries = project_history_record(session_id, segment.segment_id, &record.entry); + if capture_tool_correction(&record.entry, &mut corrections) { + let LogEntry::ToolResultCorrected { entry, .. } = &record.entry else { + unreachable!() + }; + let position = ToolCorrectionPosition { + entry_id: entry.metadata.entry_id.0.clone(), + segment_id: segment.segment_id, + start: record.start_offset, + end: record.end_offset, + }; + if !correction_positions.contains(&position) { + correction_positions.push(position); + } + continue; + } + let entries = corrected_history_entries( + &project_history_record(session_id, segment.segment_id, &record.entry), + &corrections, + ); if collect_history_entries_backward( &entries, segment.segment_id, @@ -967,7 +1061,7 @@ fn read_history_turns_backward( if segment_index + 1 == lineage.len() && collect_history_entries_backward( - &segment.seed_entries, + &corrected_history_entries(&segment.seed_entries, &corrections), segment.segment_id, 0, None, @@ -984,6 +1078,38 @@ fn read_history_turns_backward( Ok(turns) } +fn capture_tool_correction( + record: &LogEntry, + corrections: &mut HashMap, +) -> bool { + let LogEntry::ToolResultCorrected { entry, .. } = record else { + return false; + }; + if let Some(data) = project_item(&entry.metadata.entry_id.0, &entry.item) { + // We read backwards: the first correction seen is the latest one. + corrections + .entry(entry.metadata.entry_id.0.clone()) + .or_insert(data); + } + true +} + +fn corrected_history_entries( + entries: &[SessionSnapshotEntry], + corrections: &HashMap, +) -> Vec { + entries + .iter() + .cloned() + .map(|mut entry| { + if let Some(data) = corrections.get(&entry.entry_id) { + entry.data = data.clone(); + } + entry + }) + .collect() +} + #[allow(clippy::too_many_arguments)] fn collect_history_entries_backward( entries: &[SessionSnapshotEntry], @@ -1052,9 +1178,13 @@ fn is_user_entry(entry: &SessionSnapshotEntry) -> bool { fn encode_history_cursor( cursor: &RetainedHistoryCursor, ) -> Result { - serde_json::to_vec(cursor) + let encoded = serde_json::to_vec(cursor) .map(|bytes| URL_SAFE_NO_PAD.encode(bytes)) - .map_err(|_| RetainedHistoryReadError::CorruptLog) + .map_err(|_| RetainedHistoryReadError::CorruptLog)?; + if encoded.len() > 4096 { + return Err(RetainedHistoryReadError::ResourceLimit); + } + Ok(encoded) } fn decode_history_cursor(cursor: &str) -> Result { @@ -1252,6 +1382,19 @@ pub(crate) fn project_session_snapshot_for_segment( entries.push(history_entry(entry, *ts, data)); } } + LogEntry::ToolResultCorrected { ts, entry } => { + if let Some(data) = project_item(&entry.metadata.entry_id.0, &entry.item) { + if let Some(existing) = entries + .iter_mut() + .find(|existing| existing.entry_id == entry.metadata.entry_id.0) + { + // The original result keeps its place/time in the conversation. + existing.data = data; + } else { + entries.push(history_entry(entry, *ts, data)); + } + } + } LogEntry::AnnotatedSystemItem { ts, entry, .. } => entries.push(system_entry( &entry.item, entry.metadata.entry_id.0.clone(), @@ -1782,6 +1925,129 @@ mod tests { .collect() } + #[test] + fn image_correction_keeps_original_position_across_history_pages() { + let session_id = crate::new_session_id(); + let segment_id = crate::new_segment_id(); + let mut image = assistant_message(1, "image"); + image.item = agen::Item::tool_result_item_with_disposition_and_attachments( + "image-call", + "original image", + None, + agen::ToolResultDisposition::Success, + vec![agen::tool::Attachment::Image( + agen::tool::ImageAttachment::new("image/png", vec![1_u8, 2, 3]), + )], + ) + .into(); + let mut corrected = image.clone(); + corrected.item = agen::Item::tool_result_item_with_disposition_and_attachments( + "image-call", + "Resize or crop and retry ViewImage", + None, + agen::ToolResultDisposition::Error, + Vec::new(), + ) + .into(); + let mut log = vec![segment_start(session_id, Vec::new(), None, None)]; + append_turn(&mut log, 1); + log.push(LogEntry::AnnotatedToolResult { + ts: 15, + entry: image.clone(), + }); + append_turn(&mut log, 2); + append_turn(&mut log, 3); + log.push(LogEntry::ToolResultCorrected { + ts: 35, + entry: corrected, + }); + let (_root, aggregate_root) = persist_history_fixture( + "corrected-image", + session_id, + segment_id, + vec![(segment_id, log.clone())], + ); + let first = read_retained_session_history_page( + &aggregate_root, + "corrected-image", + None, + Some(1), + RetainedHistoryReadLimits::default(), + ) + .unwrap(); + assert_eq!(turn_ids(&first), ["user-3"]); + assert!( + !first.turns[0] + .entries + .iter() + .any(|entry| entry.entry_id == image.metadata.entry_id.0) + ); + let second = read_retained_session_history_page( + &aggregate_root, + "corrected-image", + first.next_cursor.as_deref(), + Some(1), + RetainedHistoryReadLimits::default(), + ) + .unwrap(); + assert_eq!(turn_ids(&second), ["user-2"]); + let third = read_retained_session_history_page( + &aggregate_root, + "corrected-image", + second.next_cursor.as_deref(), + Some(1), + RetainedHistoryReadLimits::default(), + ) + .unwrap(); + assert_eq!(turn_ids(&third), ["user-1"]); + let entry = third.turns[0] + .entries + .iter() + .find(|entry| entry.entry_id == image.metadata.entry_id.0) + .unwrap(); + assert_eq!(entry.timestamp, 15); + assert!( + matches!(&entry.data, SessionSnapshotEntryData::ToolResult { is_error: true, attachments, .. } if attachments.is_empty()) + ); + assert_eq!( + *entry, + *project_session_snapshot(session_id, &log) + .entries + .iter() + .find(|entry| entry.entry_id == image.metadata.entry_id.0) + .unwrap() + ); + let restored = crate::collect_state(&log); + assert_eq!( + restored + .annotated_history + .iter() + .filter(|entry| entry.metadata.entry_id == image.metadata.entry_id) + .count(), + 1 + ); + assert!( + matches!(&restored.annotated_history.iter().find(|entry| entry.metadata.entry_id == image.metadata.entry_id).unwrap().item, LoggedItem::ToolResult { is_error: true, attachments, .. } if attachments.is_empty()) + ); + + // Cursor pointers are only hints: the referenced record is read under + // current lineage authority and must actually be a correction record. + let mut cursor = decode_history_cursor(first.next_cursor.as_deref().unwrap()).unwrap(); + cursor.corrections[0].segment_id = crate::new_segment_id(); + let forged = encode_history_cursor(&cursor).unwrap(); + assert_eq!( + read_retained_session_history_page( + &aggregate_root, + "corrected-image", + Some(&forged), + Some(1), + RetainedHistoryReadLimits::default() + ) + .unwrap_err(), + RetainedHistoryReadError::InvalidCursor + ); + } + #[test] fn retained_history_pages_twelve_turns_as_five_five_two_across_compaction() { let worker_name = "worker-history-12"; diff --git a/crates/session-store/src/segment_log.rs b/crates/session-store/src/segment_log.rs index fb61009b0..0201a0b3a 100644 --- a/crates/session-store/src/segment_log.rs +++ b/crates/session-store/src/segment_log.rs @@ -107,6 +107,9 @@ pub enum LogEntry { /// Canonical tool output and metadata committed as one journal record. AnnotatedToolResult { ts: u64, entry: LoggedHistoryEntry }, + /// Append-only correction of a provider-rejected image result. Retains the + /// original stable identity; replay replaces rather than appends the item. + ToolResultCorrected { ts: u64, entry: LoggedHistoryEntry }, /// Canonical typed system event and model-visible metadata committed /// together. @@ -399,6 +402,16 @@ pub fn collect_state(entries: &[LogEntry]) -> RestoredState { state.annotated_history.push(entry.clone()); state.history.push(Item::from(entry.item.clone())); } + LogEntry::ToolResultCorrected { entry, .. } => { + if let Some(index) = state + .annotated_history + .iter() + .position(|existing| existing.metadata.entry_id == entry.metadata.entry_id) + { + state.annotated_history[index] = entry.clone(); + state.history[index] = Item::from(entry.item.clone()); + } + } LogEntry::AnnotatedSystemItem { entry, extensions, .. } => { diff --git a/crates/session-store/src/system_item.rs b/crates/session-store/src/system_item.rs index 4f1b68975..0f00465cd 100644 --- a/crates/session-store/src/system_item.rs +++ b/crates/session-store/src/system_item.rs @@ -175,6 +175,15 @@ pub enum SystemItem { prompt_provenance: Option, }, + /// A Host-fetched change to user-managed Subject behavior. Each changed + /// revision is appended rather than rewriting prior prompt/session content. + SubjectBehaviorRefresh { + body: String, + behavior_revision: u64, + #[serde(default, skip_serializing_if = "Option::is_none")] + prompt_provenance: Option, + }, + /// Synthetic note inserted after an interrupted turn before the next /// user input. `body` is the exact LLM-context text explaining that the /// previous turn was cut short. @@ -201,6 +210,7 @@ impl SystemItem { } SystemItem::TaskReminder { body, .. } => body.clone(), SystemItem::ResidentSummaryRefresh { body, .. } => body.clone(), + SystemItem::SubjectBehaviorRefresh { body, .. } => body.clone(), SystemItem::Interrupt { body, .. } => body.clone(), } } @@ -224,6 +234,7 @@ impl SystemItem { SystemItem::LegacyIgnored { .. } => "legacy_ignored", SystemItem::TaskReminder { .. } => "task_reminder", SystemItem::ResidentSummaryRefresh { .. } => "resident_summary_refresh", + SystemItem::SubjectBehaviorRefresh { .. } => "subject_behavior_refresh", SystemItem::Interrupt { .. } => "interrupt", } } @@ -280,6 +291,25 @@ mod tests { )); } + #[test] + fn subject_behavior_refresh_round_trips_with_revision_and_exact_body() { + let item = SystemItem::SubjectBehaviorRefresh { + body: "current Subject context".to_string(), + behavior_revision: 7, + prompt_provenance: None, + }; + let raw = serde_json::to_string(&item).unwrap(); + let parsed: SystemItem = serde_json::from_str(&raw).unwrap(); + assert_eq!(parsed.history_text(), "current Subject context"); + assert!(matches!( + parsed, + SystemItem::SubjectBehaviorRefresh { + behavior_revision: 7, + .. + } + )); + } + #[test] fn notification_history_text_returns_stored_body() { let item = SystemItem::Notification { diff --git a/crates/tui/src/app.rs b/crates/tui/src/app.rs index 750a30ba7..b856c0867 100644 --- a/crates/tui/src/app.rs +++ b/crates/tui/src/app.rs @@ -3042,6 +3042,7 @@ impl App { | session_store::SystemItem::FileAttachment { body, .. } | session_store::SystemItem::SkillActivation { body, .. } | session_store::SystemItem::ResidentSummaryRefresh { body, .. } + | session_store::SystemItem::SubjectBehaviorRefresh { body, .. } | session_store::SystemItem::Interrupt { body, .. } => { self.task_store.apply_system_message_text(&body); self.blocks.push(Block::SystemMessage { text: body }); diff --git a/crates/worker-runtime/src/worker_source.rs b/crates/worker-runtime/src/worker_source.rs index bb700ae41..089ed9f8f 100644 --- a/crates/worker-runtime/src/worker_source.rs +++ b/crates/worker-runtime/src/worker_source.rs @@ -381,10 +381,10 @@ impl RuntimeOwnedWorkspaceClient { self } - fn execute_with_permission( + fn execute_with_optional_timeout( &self, request: WorkspaceRequest, - permission: &'static str, + timeout: Option, ) -> Result { let method = match request.method { WorkspaceRequestMethod::Get => reqwest::Method::GET, @@ -409,9 +409,9 @@ impl RuntimeOwnedWorkspaceClient { path_and_query: request.path, body, headers, - permission: permission.to_string(), + permission: WORKSPACE_REQUEST_PERMISSION.to_string(), worker_id: Some(self.worker_id.clone()), - timeout: self.request_timeout, + timeout, max_response_bytes: 8 * 1024 * 1024, }) .map_err(|error| { @@ -478,7 +478,15 @@ impl WorkspaceClient for RuntimeOwnedWorkspaceClient { &self, request: WorkspaceRequest, ) -> Result { - self.execute_with_permission(request, WORKSPACE_REQUEST_PERMISSION) + self.execute_with_optional_timeout(request, self.request_timeout) + } + + fn execute_with_timeout( + &self, + request: WorkspaceRequest, + timeout: Duration, + ) -> Result { + self.execute_with_optional_timeout(request, Some(timeout)) } fn execute_server_operation( @@ -1341,6 +1349,25 @@ mod tests { server.join().unwrap(); } + #[test] + fn runtime_owned_workspace_client_honors_per_request_timeouts() { + let (base_url, server) = delayed_workspace_response(Duration::from_millis(75)); + let client = + RuntimeOwnedWorkspaceClient::new("workspace-a", base_url, "runtime-a", "worker-a"); + + let error = client + .execute_with_timeout( + WorkspaceRequest::json(WorkspaceRequestMethod::Post, "/api/test", "{}"), + Duration::from_millis(20), + ) + .unwrap_err(); + assert_eq!(client.request_timeout, None); + let message = error.to_string(); + assert!(message.contains("POST /api/test"), "{message}"); + assert!(message.contains("timed out"), "{message}"); + server.join().unwrap(); + } + #[test] fn runtime_owned_workspace_client_reports_request_timeouts() { let (base_url, server) = delayed_workspace_response(Duration::from_millis(75)); diff --git a/crates/worker/src/controller.rs b/crates/worker/src/controller.rs index b0f34fca4..1c4584345 100644 --- a/crates/worker/src/controller.rs +++ b/crates/worker/src/controller.rs @@ -489,11 +489,29 @@ fn stage_pending_notifications( count } +/// The durable input append is the activation boundary. Clear live state in +/// the same poll, before invocation preparation can checkpoint it again; the +/// oneshot only publishes the already-completed transition to the Controller. +pub(crate) fn input_committed_hook( + pending: crate::worker::PendingSubmissionHandle, + submission_id: String, + sender: oneshot::Sender<()>, +) -> impl FnOnce() { + move || { + pending.finish_activation(&submission_id); + let _ = sender.send(()); + } +} + fn notification_coalesce_remaining( pending_submissions: &crate::worker::PendingSubmissionHandle, notify_buffer: &NotifyBuffer, delay: Duration, + can_schedule_run: bool, ) -> Option { + if !can_schedule_run { + return None; + } let Some(accepted_at_ms) = pending_submissions.oldest_pending_notification_accepted_at_ms() else { return notify_buffer @@ -1434,6 +1452,7 @@ where crate::feature::builtin::memory::ordinary_subjektiv_resident_summary_source( worker.manifest(), worker.workspace_client_handle(), + worker.prompts(), )? { let workspace_id = worker @@ -1563,7 +1582,13 @@ where ), ); } - if feature_config.manage_workdir.enabled { + let wip_mode = worker.manifest().worker.mode == manifest::WorkerMode::Wip; + let mut workdir_wip_feature = None; + if feature_config.manage_workdir.enabled + || (wip_mode + && feature_config.workdir_catalog.enabled + && worker.workspace_client_handle().is_available()) + { // Workdir lifecycle is Workspace control-plane authority. The Worker // receives only the injected WorkspaceClient and never Runtime URLs, // repository paths, materializer handles, or cleanup sessions. @@ -1579,7 +1604,7 @@ where } let shutdown_registry = spawned_registry.clone(); let reopen_registry = spawned_registry.clone(); - feature_registry.add_module( + let module = crate::feature::builtin::manage_workdir::ManageWorkdirFeature::with_child_lifecycle( workspace_client, worker.workdir_sessions(), @@ -1588,8 +1613,11 @@ where Box::pin(async move { child_registry.shutdown_internal().await }) }), Arc::new(move || reopen_registry.reopen_internal()), - ), - ); + ); + if feature_config.manage_workdir.enabled { + feature_registry.add_module(module.clone()); + } + workdir_wip_feature = Some(module); } if feature_config.workspace_worker_discovery.enabled { let workspace_client = worker.workspace_client_handle(); @@ -1644,20 +1672,34 @@ 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(); + if wip_mode && let Some(feature) = &workdir_wip_feature { + crate::feature::builtin::manage_workdir::wip::mount_workspace_workdir_wip( + &mut wip_mount_registry, + feature, + feature_config.workdir_catalog.enabled, + feature_config.manage_workdir.enabled, + wip_permissions.clone(), + ) + .map_err(|error| { + std::io::Error::new( + std::io::ErrorKind::InvalidInput, + format!("mount Workdir WIP projection: {error}"), + ) + })?; + } { let workspace_client = worker.workspace_client_handle(); 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( @@ -1665,7 +1707,7 @@ where workspace_client.clone(), feature_config.merge_request, wip_permissions.clone(), - &feature_route, + &namespace_route, ) .map_err(|error| { std::io::Error::new( @@ -1682,12 +1724,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( @@ -1695,7 +1737,7 @@ where workspace_client.clone(), ticket_access, wip_permissions.clone(), - &feature_route, + &namespace_route, ) .map_err(|error| { std::io::Error::new( @@ -1716,19 +1758,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( @@ -2140,9 +2182,11 @@ async fn controller_loop( submission.input, vec![extension], submission.provenance, - move || { - let _ = input_commit_tx.send(()); - }, + input_committed_hook( + pending_submissions.clone(), + committed_submission.submission_id.clone(), + input_commit_tx, + ), ), &mut method_rx, &working_event_tx, @@ -2199,10 +2243,6 @@ async fn controller_loop( .await } }; - if notify_buffer.is_empty() { - pending_submissions.finish_notification_batch(); - } - if !shutdown && may_drain_pending && new_status == WorkerStatus::Idle { match prepare_pending_run(&pending_submissions, ¬ify_buffer, None, false) { Ok(Some(next)) => { @@ -2251,12 +2291,16 @@ async fn controller_loop( continue; } + // An expired Notify deadline must not repeatedly schedule a run that + // cannot cross the invocation fence. Keep the accepted notification for + // a fresh input/recovery boundary instead of retrying business effects. let notification_delay = (shared_state.catalog_status() == WorkerStatus::Idle) .then(|| { notification_coalesce_remaining( &pending_submissions, ¬ify_buffer, notification_coalesce_delay, + worker.can_schedule_notification_run(), ) }) .flatten(); @@ -3930,6 +3974,40 @@ mod tests { assert!(notify_buffer.has_notification_pending()); } + #[test] + fn invocation_fence_suspends_expired_notification_deadline_without_dropping_input() { + let temp = TempDir::new().unwrap(); + let pending = crate::worker::PendingSubmissionHandle::for_test(temp.path()); + let buffer = NotifyBuffer::new(); + pending + .accept_notification("held".into(), "held".into()) + .unwrap(); + let before = pending.persisted_entries_for_test().len(); + assert_eq!( + notification_coalesce_remaining(&pending, &buffer, Duration::ZERO, true), + Some(Duration::ZERO) + ); + for _ in 0..4 { + assert_eq!( + notification_coalesce_remaining(&pending, &buffer, Duration::ZERO, false), + None + ); + } + assert_eq!(pending.persisted_entries_for_test().len(), before); + assert_eq!(pending.snapshot().notification_count, 1); + // A busy-time staged notification is equally retained behind the fence. + stage_pending_notifications(&pending, &buffer); + assert_eq!( + notification_coalesce_remaining(&pending, &buffer, Duration::ZERO, false), + None + ); + assert!(buffer.has_notification_pending()); + assert_eq!( + notification_coalesce_remaining(&pending, &buffer, Duration::ZERO, true), + Some(Duration::ZERO) + ); + } + #[test] fn restored_mixed_activations_preserve_global_fifo_order() { let temp = TempDir::new().unwrap(); diff --git a/crates/worker/src/feature/builtin/manage_workdir.rs b/crates/worker/src/feature/builtin/manage_workdir.rs index 54514dd75..537739187 100644 --- a/crates/worker/src/feature/builtin/manage_workdir.rs +++ b/crates/worker/src/feature/builtin/manage_workdir.rs @@ -5,6 +5,8 @@ //! endpoints, credentials, materializer handles, and operation sessions stay //! behind [`WorkspaceClient`]. +pub mod wip; + use std::future::Future; use std::pin::Pin; use std::sync::Arc; @@ -67,6 +69,7 @@ pub struct ManageWorkdirFeature { session_router: Arc, before_workdir_release: Option, after_workdir_attach: Option, + attachment_mutation_lock: Arc>, } impl std::fmt::Debug for ManageWorkdirFeature { @@ -86,6 +89,7 @@ impl ManageWorkdirFeature { session_router: Arc::new(workdir::WorkdirSessionRouter::new()), before_workdir_release: None, after_workdir_attach: None, + attachment_mutation_lock: Arc::new(tokio::sync::Mutex::new(())), } } @@ -100,8 +104,19 @@ impl ManageWorkdirFeature { session_router, before_workdir_release: Some(before_workdir_release), after_workdir_attach: Some(after_workdir_attach), + attachment_mutation_lock: Arc::new(tokio::sync::Mutex::new(())), } } + fn backend(&self) -> WorkspaceHttpWorkdirBackend { + let mut backend = WorkspaceHttpWorkdirBackend::new(self.client.clone()) + .with_session_router(self.session_router.clone()) + .with_child_lifecycle( + self.before_workdir_release.clone(), + self.after_workdir_attach.clone(), + ); + backend.attachment_mutation_lock = self.attachment_mutation_lock.clone(); + backend + } } pub fn manage_workdir_feature(client: Arc) -> ManageWorkdirFeature { @@ -120,12 +135,7 @@ impl FeatureModule for ManageWorkdirFeature { } fn install(&self, context: &mut FeatureInstallContext<'_>) -> Result<(), FeatureInstallError> { - let backend = WorkspaceHttpWorkdirBackend::new(self.client.clone()) - .with_session_router(self.session_router.clone()) - .with_child_lifecycle( - self.before_workdir_release.clone(), - self.after_workdir_attach.clone(), - ); + let backend = self.backend(); for (name, definition) in [ ( LIST_TOOL, @@ -581,11 +591,24 @@ impl WorkspaceHttpWorkdirBackend { } fn detach(&self, alias: &str) -> Result { + self.detach_expected(alias, None) + } + + fn detach_expected( + &self, + alias: &str, + expected_connection_id: Option<&str>, + ) -> Result { let workspace_id = encode_path_segment(self.workspace_id()?); let alias_path = encode_path_segment(alias); + let suffix = expected_connection_id + .map(|id| format!("?expected_connection_id={}", encode_path_segment(id))) + .unwrap_or_default(); let response = self.execute_json::(WorkspaceRequest { method: WorkspaceRequestMethod::Delete, - path: format!("/api/w/{workspace_id}/workers/self/workdir-attachments/{alias_path}"), + path: format!( + "/api/w/{workspace_id}/workers/self/workdir-attachments/{alias_path}{suffix}" + ), body: None, })?; workdir_output( @@ -622,6 +645,72 @@ impl WorkspaceHttpWorkdirBackend { ) } + async fn detach_managed( + &self, + alias: &str, + expected_connection_id: Option<&str>, + ) -> Result { + let _mutation_guard = self.attachment_mutation_lock.lock().await; + let alias = validate_identity(alias, DETACH_TOOL, "alias")?; + if let Some(expected) = expected_connection_id { + // Under the shared local mutation lock, check lifetime before any + // local route exclusion or child cleanup. Backend DELETE still performs + // the authoritative conditional mutation under its lifecycle lock. + let workspace = encode_path_segment(self.workspace_id()?); + let page: serde_json::Value = self.execute_json(WorkspaceRequest::get(format!( + "/api/w/{workspace}/workers/self/workdir-attachments?limit=1&connection_id={}", + encode_path_segment(expected) + )))?; + let current = page + .get("items") + .and_then(serde_json::Value::as_array) + .is_some_and(|items| { + items.iter().any(|item| { + item.get("connection_id") + .and_then(serde_json::Value::as_str) + == Some(expected) + && item.get("alias").and_then(serde_json::Value::as_str) == Some(alias) + }) + }); + if !current { + return Err(ToolError::StructuredConflict { + code: "workspace_http_409".into(), + message: "attachment lifetime changed before local cleanup".into(), + }); + } + } + let alias_key = workdir::WorkdirAttachmentAlias::new(alias) + .map_err(|error| ToolError::InvalidArgument(error.to_string()))?; + self.session_router + .begin_detach(&alias_key) + .await + .map_err(|error| ToolError::ExecutionFailed(error.to_string()))?; + if let Some(before_release) = &self.before_workdir_release + && let Err(error) = before_release().await + { + self.session_router.cancel_detach(&alias_key); + return Err(ToolError::ExecutionFailed(format!( + "stop Internal SubWorkers before Workdir detach: {error}" + ))); + } + let result = match self.detach_expected(alias, expected_connection_id) { + Ok(result) => result, + Err(error) => { + self.session_router.cancel_detach(&alias_key); + return Err(error); + } + }; + self.session_router + .finish_detach(&alias_key) + .await + .map_err(|error| { + ToolError::ExecutionFailed(format!( + "detach Workdir session route after Backend release: {error}" + )) + })?; + Ok(result) + } + fn execute_json Deserialize<'de>>( &self, request: WorkspaceRequest, @@ -697,42 +786,10 @@ impl Tool for WorkspaceHttpWorkdirTool { result } WorkdirOperation::Detach => { - let _mutation_guard = self.backend.attachment_mutation_lock.lock().await; let input = parse_input::(input_json)?; - let alias = validate_identity(&input.alias, DETACH_TOOL, "alias")?; - let alias_key = workdir::WorkdirAttachmentAlias::new(alias) - .map_err(|error| ToolError::InvalidArgument(error.to_string()))?; - self.backend - .session_router - .begin_detach(&alias_key) - .await - .map_err(|error| ToolError::ExecutionFailed(error.to_string()))?; - if let Some(before_release) = &self.backend.before_workdir_release - && let Err(error) = before_release().await - { - self.backend.session_router.cancel_detach(&alias_key); - return Err(ToolError::ExecutionFailed(format!( - "stop Internal SubWorkers before Workdir detach: {error}" - ))); - } - let result = match self.backend.detach(alias) { - Ok(result) => result, - Err(error) => { - self.backend.session_router.cancel_detach(&alias_key); - return Err(error); - } - }; - self.backend - .session_router - .finish_detach(&alias_key) - .await - .map_err(|error| { - ToolError::ExecutionFailed(format!( - "detach Workdir session route after Backend release: {error}" - )) - })?; - Ok(result) + self.backend.detach_managed(&input.alias, None).await } + WorkdirOperation::Delete => self .backend .delete(parse_input::(input_json)?), @@ -747,6 +804,12 @@ fn parse_input Deserialize<'de>>(input: &str) -> Result Deserialize<'de>>( response: WorkspaceResponse, ) -> Result { + if matches!(response.status, 401 | 403 | 404 | 409) { + return Err(ToolError::StructuredConflict { + code: format!("workspace_http_{}", response.status), + message: "Workspace lifecycle authority rejected the request before execution".into(), + }); + } if !response.is_success() { return Err(ToolError::ExecutionFailed(format!( "Workspace Workdir API returned HTTP {}: {}", diff --git a/crates/worker/src/feature/builtin/manage_workdir/wip.rs b/crates/worker/src/feature/builtin/manage_workdir/wip.rs new file mode 100644 index 000000000..0d3439578 --- /dev/null +++ b/crates/worker/src/feature/builtin/manage_workdir/wip.rs @@ -0,0 +1,1683 @@ +//! Host-owned Repository, Workdir and Worker attachment Objects. +//! Inventory is resolved from Workspace authority on demand, not a second catalog. + +use std::collections::BTreeMap; +use std::sync::Arc; + +use async_trait::async_trait; +use manifest::{ToolPermissionAction, ToolPermissionConfig}; +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 super::*; +use crate::permission::permission_action_for; +use crate::wip::{ + WipCallContext, WipDynamicItem, WipDynamicItemResolver, WipDynamicMount, + WipDynamicOperationContribution, WipDynamicOperationResolver, WipMountError, WipMountRegistry, + WipOperationContribution, WipOperationError, WipOperationHandler, WipOperationOutput, + WipProjection, WipProjectionKind, json_to_wip, wip_to_json, +}; + +const REPOSITORIES: &str = "/repositories"; +const WORKDIRS: &str = "/workdirs"; +const ATTACHMENTS: &str = "/workdir-attachments"; + +#[derive(Clone, Copy, Debug)] +enum Domain { + Repository, + Workdir, + Attachment, +} + +impl Domain { + fn route(self) -> &'static str { + match self { + Self::Repository => REPOSITORIES, + Self::Workdir => WORKDIRS, + Self::Attachment => ATTACHMENTS, + } + } + fn owner(self) -> &'static str { + match self { + Self::Repository => "repository", + Self::Workdir => "workdir", + Self::Attachment => "workdir-attachment", + } + } + fn list_permission(self) -> &'static str { + match self { + Self::Repository => "RepositoryList", + Self::Workdir => LIST_TOOL, + Self::Attachment => "WorkdirAttachmentList", + } + } + fn read_permission(self) -> &'static str { + match self { + Self::Repository => "RepositoryRead", + Self::Workdir => "WorkdirRead", + Self::Attachment => "WorkdirAttachmentRead", + } + } + fn identity_field(self) -> &'static str { + match self { + Self::Repository => "repository_key", + Self::Workdir => "working_directory_id", + Self::Attachment => "connection_id", + } + } +} + +#[derive(Clone)] +struct Provider { + backend: WorkspaceHttpWorkdirBackend, + permissions: Option, + catalog: bool, + manage: bool, +} + +/// Register Object ownership once, then contribute independent management operations. +/// The passed Feature shares its session router, mutation lock and child lifecycle with Tools. +pub fn mount_workspace_workdir_wip( + registry: &mut WipMountRegistry, + feature: &ManageWorkdirFeature, + catalog: bool, + manage: bool, + permissions: Option, +) -> Result<(), WipMountError> { + let provider = Provider { + backend: feature.backend(), + permissions, + catalog, + manage, + }; + for domain in [Domain::Repository, Domain::Workdir, Domain::Attachment] { + let namespace = + registry.allocate_namespace(domain.owner(), domain.route().trim_start_matches('/'))?; + let interface = format!("yoi.{}/collection/v1", domain.owner()); + let handler = Arc::new(CollectionHandler { + provider: provider.clone(), + domain, + }); + registry.mount(WipProjection { + route: namespace.root().into(), + capability: format!("{}:collection", domain.owner()), + kind: WipProjectionKind::Native, + object: object( + domain.route(), + domain.route(), + &interface, + &json!({"kind": "collection"}), + ), + interface: interface.clone(), + descriptor: collection_descriptor(domain, false), + interface_validator: None, + handler: handler.clone(), + })?; + if manage && matches!(domain, Domain::Workdir) { + registry.contribute_operations(WipOperationContribution { + route: domain.route().into(), + contributor: FEATURE_ID.into(), + interface, + descriptor: collection_descriptor(domain, true), + handler, + })?; + } + let item_interface = format!("yoi.{}/item/v1", domain.owner()); + let resolver = Arc::new(ItemResolver { + provider: provider.clone(), + domain, + }); + registry.mount_dynamic(WipDynamicMount { + collection_route: domain.route().into(), + capability: format!("{}:item", domain.owner()), + interface: item_interface.clone(), + descriptor: item_descriptor(domain, false), + interface_validator: None, + resolver: resolver.clone(), + })?; + if manage && !matches!(domain, Domain::Repository) { + registry.contribute_dynamic_operations(WipDynamicOperationContribution { + collection_route: domain.route().into(), + contributor: FEATURE_ID.into(), + interface: item_interface, + descriptor: item_descriptor(domain, true), + resolver, + })?; + } + } + registry.replace_compatibility_tools( + WORKDIRS, + [LIST_TOOL, CREATE_TOOL, ATTACH_TOOL, DELETE_TOOL], + )?; + registry.replace_compatibility_tools(ATTACHMENTS, [DETACH_TOOL])?; + Ok(()) +} + +impl Provider { + fn allowed(&self, name: &str, input: &Json) -> bool { + self.permissions + .as_ref() + .is_none_or(|p| permission_action_for(p, name, input) == ToolPermissionAction::Allow) + } + // An argument-dependent permission cannot be decided until call. Advertise only + // if the policy can allow this tool, then recheck the exact route-bound input. + fn may_allow(&self, name: &str) -> bool { + let Some(p) = &self.permissions else { + return true; + }; + for rule in p.rules.iter().filter(|r| r.tool.eq_ignore_ascii_case(name)) { + if rule.action == ToolPermissionAction::Allow { + return true; + } + if rule.pattern == "*" { + return false; + } + } + p.default_action == ToolPermissionAction::Allow + } + fn authorize(&self, name: &str, input: &Json) -> Result<(), WipOperationError> { + if self.allowed(name, input) { + Ok(()) + } else { + Err(failure( + ProtocolErrorCode::PermissionDenied, + format!("permission denied for `{name}`; approval-required policies fail closed"), + )) + } + } + fn request(&self, path: &str) -> Result { + let workspace = self.backend.workspace_id().map_err(map_tool_error)?; + let response = self + .backend + .client + .execute(WorkspaceRequest::get(format!( + "/api/w/{}/{path}", + encode_path_segment(workspace) + ))) + .map_err(|_| { + WipOperationError::OutcomeUnknown("Workspace inventory request unavailable".into()) + })?; + if !response.is_success() { + return Err(match response.status { + 401 | 403 => failure( + ProtocolErrorCode::PermissionDenied, + "Workspace authority denied this inventory request", + ), + 404 => failure( + ProtocolErrorCode::NotFound, + "inventory Object no longer exists", + ), + 409 => failure( + ProtocolErrorCode::ValidatorMismatch, + "inventory observation is stale; rediscover", + ), + _ => WipOperationError::OutcomeUnknown("Workspace inventory request failed".into()), + }); + } + serde_json::from_str(&response.body).map_err(|_| { + WipOperationError::OutcomeUnknown("invalid Workspace inventory response".into()) + }) + } + fn inventory(&self, domain: Domain) -> Result, WipOperationError> { + let response = self.request(match domain { + Domain::Repository => "repositories", + Domain::Workdir => "workers/self/workdir-catalog?limit=1", + Domain::Attachment => "workers/self/workdir-attachments?limit=1", + })?; + response + .get("items") + .and_then(Json::as_array) + .cloned() + .ok_or_else(|| { + WipOperationError::OutcomeUnknown("invalid Workspace inventory collection".into()) + }) + } + fn raw_item(&self, domain: Domain, id: &str) -> Result { + match domain { + Domain::Attachment => self + .request(&format!( + "workers/self/workdir-attachments?limit=1&connection_id={}", + encode_path_segment(id) + ))? + .get("items") + .and_then(Json::as_array) + .cloned() + .ok_or_else(|| { + WipOperationError::OutcomeUnknown("invalid attachment lookup response".into()) + })? + .into_iter() + .find(|v| v.get("connection_id").and_then(Json::as_str) == Some(id)) + .ok_or_else(|| { + failure( + ProtocolErrorCode::NotFound, + "attachment lifetime no longer exists; list current attachments", + ) + }), + Domain::Repository | Domain::Workdir => { + let prefix = if matches!(domain, Domain::Repository) { + "repositories" + } else { + "working-directories" + }; + self.request(&format!("{prefix}/{}", encode_path_segment(id)))? + .get("item") + .cloned() + .ok_or_else(|| { + WipOperationError::OutcomeUnknown("invalid Workspace inventory item".into()) + }) + } + } + } + fn read_input(&self, domain: Domain, id: &str) -> Json { + json!({domain.identity_field(): id}) + } + fn projected(&self, domain: Domain, raw: &Json) -> Result { + let id = raw + .get(domain.identity_field()) + .and_then(Json::as_str) + .ok_or_else(|| { + WipOperationError::OutcomeUnknown("inventory identity missing".into()) + })?; + let mut result = serde_json::Map::new(); + let fields: &[&str] = match domain { + Domain::Repository => &[ + "repository_key", + "kind", + "provider", + "display_name", + "description", + "default_selector", + ], + Domain::Workdir => &[ + "working_directory_id", + "display_name", + "creation_selector", + "creation_ref", + "creation_tree", + "current_selector", + "current_ref", + "current_tree", + "status", + "cleanliness", + "occupied_by", + ], + Domain::Attachment => &[ + "connection_id", + "alias", + "working_directory_id", + "capabilities", + ], + }; + for field in fields { + if let Some(value) = raw.get(*field) { + result.insert((*field).into(), value.clone()); + } + } + if matches!(domain, Domain::Repository) { + // A missing default is explicit. No guessed main/ref, source URI or credentials. + result.entry("default_selector").or_insert(Json::Null); + } + if matches!(domain, Domain::Workdir) { + if let Some(source) = raw.get("source") { + let kind = source + .get("kind") + .and_then(Json::as_str) + .unwrap_or("unknown"); + let mut projected = json!({"kind": kind}); + if kind == "repository" + && let Some(key) = source.get("repository_key") + { + projected["repository_key"] = key.clone(); + projected["repository_path"] = json!(item_path( + Domain::Repository, + key.as_str().unwrap_or_default() + )); + } + result.insert("source".into(), projected); + } + } + if matches!(domain, Domain::Attachment) { + if let Some(id) = raw.get("working_directory_id").and_then(Json::as_str) { + result.insert("workdir_path".into(), json!(item_path(Domain::Workdir, id))); + } + // Content projection belongs to T-699/T-695. No unconditional FS entrance. + } + result.insert("path".into(), json!(item_path(domain, id))); + Ok(Json::Object(result)) + } +} + +struct CollectionHandler { + provider: Provider, + domain: Domain, +} +#[async_trait] +impl WipOperationHandler for CollectionHandler { + fn contextual_interface(&self) -> bool { + true + } + fn object_validator(&self) -> Option> { + let mut digest = Sha256::new(); + digest.update(self.domain.route()); + if matches!(self.domain, Domain::Workdir | Domain::Attachment) { + // The complete-set revision is computed by the existing Backend + // registry/ledger in the same snapshot as a bounded page. Never infer + // collection freshness from just the first page's items. + let path = if matches!(self.domain, Domain::Workdir) { + "workers/self/workdir-catalog?limit=1" + } else { + "workers/self/workdir-attachments?limit=1" + }; + match self + .provider + .request(path) + .ok() + .and_then(|p| p.get("revision").and_then(Json::as_str).map(str::to_owned)) + { + Some(revision) => { + digest.update([0]); + digest.update(revision); + } + None => digest.update(b"inventory-revision-unavailable"), + } + return Some(digest.finalize().to_vec()); + } + match self.provider.inventory(self.domain) { + Ok(items) => { + for raw in items { + if let Some(id) = raw.get(self.domain.identity_field()).and_then(Json::as_str) + && self.provider.allowed( + self.domain.read_permission(), + &self.provider.read_input(self.domain, id), + ) + && let Ok(value) = self.provider.projected(self.domain, &raw) + { + digest.update(value.to_string()); + digest.update([0]); + } + } + } + Err(_) => digest.update(b"inventory-unavailable"), + } + Some(digest.finalize().to_vec()) + } + fn is_visible(&self) -> bool { + (self.provider.catalog + && (self.provider.may_allow(self.domain.list_permission()) + || self.provider.may_allow(self.domain.read_permission()))) + || (self.provider.manage + && matches!(self.domain, Domain::Workdir) + && self.provider.may_allow(CREATE_TOOL)) + } + fn operation_available(&self, operation: &str) -> bool { + match operation { + "list" => { + self.provider.catalog && self.provider.may_allow(self.domain.list_permission()) + } + "create" => { + self.provider.manage + && matches!(self.domain, Domain::Workdir) + && self.provider.may_allow(CREATE_TOOL) + } + _ => false, + } + } + async fn call( + &self, + operation: &str, + arguments: &BTreeMap, + context: WipCallContext, + ) -> Result { + let input = argument_json(arguments)?; + if !self.operation_available(operation) { + return Err(failure( + ProtocolErrorCode::PermissionDenied, + "operation is not available", + )); + } + let value = match operation { + "list" => { + self.provider + .authorize(self.domain.list_permission(), &input)?; + let limit = page_limit(&input)?; + let cursor = input.get("cursor").and_then(Json::as_str); + if matches!(self.domain, Domain::Workdir | Domain::Attachment) { + let attachment = matches!(self.domain, Domain::Attachment); + let path = if attachment { + let offset = cursor + .map(|c| { + c.strip_prefix("offset:") + .and_then(|s| s.parse::().ok()) + .ok_or_else(|| { + failure( + ProtocolErrorCode::InvalidArguments, + "invalid attachment page cursor", + ) + }) + }) + .transpose()? + .unwrap_or(0); + format!("workers/self/workdir-attachments?limit={limit}&offset={offset}") + } else { + let suffix = cursor + .map(|c| format!("&cursor={}", encode_path_segment(c))) + .unwrap_or_default(); + format!("workers/self/workdir-catalog?limit={limit}{suffix}") + }; + let page = self.provider.request(&path)?; + let raws = page + .get("items") + .and_then(Json::as_array) + .filter(|v| v.len() <= limit) + .ok_or_else(|| { + WipOperationError::OutcomeUnknown( + "invalid bounded inventory page".into(), + ) + })?; + if page.get("revision").and_then(Json::as_str).is_none() { + return Err(WipOperationError::OutcomeUnknown( + "inventory page revision missing".into(), + )); + } + let mut items = Vec::new(); + for raw in raws { + let id = raw + .get(self.domain.identity_field()) + .and_then(Json::as_str) + .ok_or_else(|| { + WipOperationError::OutcomeUnknown( + "inventory identity missing".into(), + ) + })?; + if self.provider.allowed( + self.domain.read_permission(), + &self.provider.read_input(self.domain, id), + ) { + items.push(self.provider.projected(self.domain, raw)?); + } + } + let next = if attachment { + match page.get("next_offset") { + Some(Json::Null) => None, + Some(v) if v.as_u64().is_some_and(|n| n <= u32::MAX as u64) => { + Some(format!("offset:{}", v.as_u64().unwrap())) + } + _ => { + return Err(WipOperationError::OutcomeUnknown( + "invalid attachment next page".into(), + )); + } + } + } else { + match page.get("next_cursor") { + Some(Json::Null) => None, + Some(Json::String(v)) if cursor.is_none_or(|old| v.as_str() > old) => { + Some(v.clone()) + } + _ => { + return Err(WipOperationError::OutcomeUnknown( + "invalid Workdir next page".into(), + )); + } + } + }; + // Empty describes this permission-filtered page, not the + // entire catalog. A sparse page still advances the Backend cursor. + return native( + json!({"empty":items.is_empty(), "items":items, "has_more":next.is_some(), "next_cursor":next, "limit":limit}), + ); + } + let mut items = Vec::new(); + for raw in self.provider.inventory(self.domain)? { + let id = raw + .get(self.domain.identity_field()) + .and_then(Json::as_str) + .ok_or_else(|| { + WipOperationError::OutcomeUnknown("inventory identity missing".into()) + })?; + if self.provider.allowed( + self.domain.read_permission(), + &self.provider.read_input(self.domain, id), + ) { + items.push(self.provider.projected(self.domain, &raw)?); + } + } + items.sort_by(|a, b| { + a[self.domain.identity_field()] + .as_str() + .cmp(&b[self.domain.identity_field()].as_str()) + }); + let total_visible = items.len(); + let remaining: Vec<_> = items + .into_iter() + .filter(|v| { + cursor.is_none_or(|c| { + v[self.domain.identity_field()] + .as_str() + .is_some_and(|id| id > c) + }) + }) + .collect(); + let has_more = remaining.len() > limit; + let items: Vec<_> = remaining.into_iter().take(limit).collect(); + let next_cursor = if has_more { + items + .last() + .map(|v| v[self.domain.identity_field()].clone()) + } else { + None + }; + json!({"items": items, "empty": total_visible == 0, "has_more": has_more, "next_cursor": next_cursor, "limit": limit}) + } + "create" => { + self.provider.authorize(CREATE_TOOL, &input)?; + let tool = WorkspaceHttpWorkdirTool { + backend: self.provider.backend.clone(), + operation: WorkdirOperation::Create, + }; + let output = tool + .execute(&input.to_string(), context.execution) + .await + .map_err(map_tool_error)?; + let raw = output_json(output)?; + json!({"item": self.provider.projected(Domain::Workdir, &raw["item"])?, "runtime_id":raw["runtime_id"]}) + } + _ => { + return Err(failure( + ProtocolErrorCode::OperationNotFound, + "operation not published", + )); + } + }; + native(value) + } +} + +struct ItemResolver { + provider: Provider, + domain: Domain, +} +impl WipDynamicItemResolver for ItemResolver { + fn resolve(&self, reference: &str) -> Option { + let id = decode_identity(reference)?; + if !self.provider.catalog + || !self.provider.allowed( + self.domain.read_permission(), + &self.provider.read_input(self.domain, &id), + ) + { + return None; + } + let raw = self.provider.raw_item(self.domain, &id).ok()?; + let projected = self.provider.projected(self.domain, &raw).ok()?; + let interface = format!("yoi.{}/item/v1", self.domain.owner()); + Some(WipDynamicItem { + object: object(&item_path(self.domain, &id), &id, &interface, &projected), + handler: Arc::new(ItemHandler { + provider: self.provider.clone(), + domain: self.domain, + id, + }), + }) + } +} +impl WipDynamicOperationResolver for ItemResolver { + fn handler(&self, reference: &str) -> Arc { + Arc::new(ItemHandler { + provider: self.provider.clone(), + domain: self.domain, + id: decode_identity(reference).unwrap_or_default(), + }) + } +} +struct ItemHandler { + provider: Provider, + domain: Domain, + id: String, +} +impl ItemHandler { + fn input(&self, arguments: &BTreeMap) -> Result { + let mut input = argument_json(arguments)?; + // Defense in depth in addition to descriptor validation, including direct handler calls. + if input.get(self.domain.identity_field()).is_some() + || input.get("alias").is_some() && matches!(self.domain, Domain::Attachment) + { + return Err(failure( + ProtocolErrorCode::InvalidArguments, + "route-bound identity cannot be overridden", + )); + } + input[self.domain.identity_field()] = json!(self.id); + Ok(input) + } + fn supports(&self, operation: &str) -> bool { + let Ok(raw) = self.provider.raw_item(self.domain, &self.id) else { + return false; + }; + match operation { + "read" => true, + "attach" => { + matches!(self.domain, Domain::Workdir) + && matches!( + raw.get("source") + .and_then(|v| v.get("kind")) + .and_then(Json::as_str), + Some("repository" | "external_grant") + ) + && raw.get("status").and_then(Json::as_str) == Some("active") + } + "delete" => { + matches!(self.domain, Domain::Workdir) + && raw.get("cleanup_target").is_some_and(|v| !v.is_null()) + } + "detach" => matches!(self.domain, Domain::Attachment), + _ => false, + } + } +} +#[async_trait] +impl WipOperationHandler for ItemHandler { + fn contextual_interface(&self) -> bool { + true + } + fn operation_available(&self, operation: &str) -> bool { + let (enabled, permission) = match operation { + "read" => (self.provider.catalog, self.domain.read_permission()), + "attach" => (self.provider.manage, ATTACH_TOOL), + "delete" => (self.provider.manage, DELETE_TOOL), + "detach" => (self.provider.manage, DETACH_TOOL), + _ => return false, + }; + enabled && self.provider.may_allow(permission) && self.supports(operation) + } + async fn call( + &self, + operation: &str, + arguments: &BTreeMap, + context: WipCallContext, + ) -> Result { + let mut input = self.input(arguments)?; + // Recheck reference permission as well as mutation permission on every call. + self.provider.authorize( + self.domain.read_permission(), + &self.provider.read_input(self.domain, &self.id), + )?; + if !self.operation_available(operation) { + return Err(failure( + ProtocolErrorCode::OperationNotFound, + "target or subject does not support this operation", + )); + } + let value = match operation { + "read" => self + .provider + .projected(self.domain, &self.provider.raw_item(self.domain, &self.id)?)?, + "attach" | "delete" => { + let (permission, op) = if operation == "attach" { + (ATTACH_TOOL, WorkdirOperation::Attach) + } else { + (DELETE_TOOL, WorkdirOperation::Delete) + }; + self.provider.authorize(permission, &input)?; + let output = WorkspaceHttpWorkdirTool { + backend: self.provider.backend.clone(), + operation: op, + } + .execute(&input.to_string(), context.execution) + .await + .map_err(map_tool_error)?; + let raw = output_json(output)?; + if operation == "attach" { + let mut result = raw; + result["workdir_path"] = json!(item_path(Domain::Workdir, &self.id)); + result["attachments_path"] = json!(ATTACHMENTS); + // Backend attachment list supplies the durable lifetime identity. + result + } else { + raw + } + } + "detach" => { + let raw = self.provider.raw_item(Domain::Attachment, &self.id)?; + let alias = raw.get("alias").and_then(Json::as_str).ok_or_else(|| { + WipOperationError::OutcomeUnknown("attachment alias missing".into()) + })?; + input + .as_object_mut() + .expect("route input is an object") + .remove("connection_id"); + input["alias"] = json!(alias); + self.provider.authorize(DETACH_TOOL, &input)?; + output_json( + self.provider + .backend + .detach_managed(alias, Some(&self.id)) + .await + .map_err(map_tool_error)?, + )? + } + _ => { + return Err(failure( + ProtocolErrorCode::OperationNotFound, + "operation not published", + )); + } + }; + native(value) + } +} + +fn object(path: &str, identity: &str, interface: &str, value: &Json) -> Object { + let mut digest = Sha256::new(); + digest.update(path); + digest.update([0]); + digest.update(value.to_string()); + Object { + name: path.rsplit('/').next().unwrap_or_default().into(), + description: Some(format!("Workspace authority Object: {identity}")), + interfaces: vec![interface.into()], + r#ref: Some(identity.into()), + validator: Some(digest.finalize().to_vec()), + } +} +fn native(value: Json) -> Result { + Ok(WipOperationOutput::native( + json_to_wip(&value).map_err(WipOperationError::OutcomeUnknown)?, + )) +} +fn failure(code: ProtocolErrorCode, message: impl Into) -> WipOperationError { + WipOperationError::Protocol(ProtocolError { + code, + message: message.into(), + }) +} +fn map_tool_error(error: ToolError) -> WipOperationError { + match error { + ToolError::InvalidArgument(message) => { + failure(ProtocolErrorCode::InvalidArguments, message) + } + ToolError::Cancelled(output) => WipOperationError::Cancelled(output), + ToolError::Interrupted(output) => WipOperationError::Interrupted(output), + ToolError::StructuredConflict { code, .. } => { + let (code, message) = match code.as_str() { + "workspace_http_401" | "workspace_http_403" => ( + ProtocolErrorCode::PermissionDenied, + "Workspace authority denied the lifecycle request", + ), + "workspace_http_404" => (ProtocolErrorCode::NotFound, "Object no longer exists"), + "workspace_http_409" => ( + ProtocolErrorCode::ValidatorMismatch, + "connection or lifecycle observation is stale; rediscover", + ), + _ => { + return WipOperationError::OutcomeUnknown( + "Workspace lifecycle conflict with unknown execution outcome".into(), + ); + } + }; + failure(code, message) + } + // Never expose raw backend errors (which may include host/provider details). + _ => WipOperationError::OutcomeUnknown( + "Workspace lifecycle operation failed; outcome unknown; do not automatically retry" + .into(), + ), + } +} +fn output_json(output: ToolOutput) -> Result { + serde_json::from_str(output.content.as_deref().unwrap_or_default()) + .map_err(|_| WipOperationError::OutcomeUnknown("invalid lifecycle result".into())) +} +fn argument_json(arguments: &BTreeMap) -> Result { + let mut values = serde_json::Map::new(); + for (name, value) in arguments { + if !matches!(value, Value::Unit) { + values.insert( + name.clone(), + wip_to_json(value).map_err(|e| failure(ProtocolErrorCode::InvalidArguments, e))?, + ); + } + } + Ok(Json::Object(values)) +} +fn page_limit(input: &Json) -> Result { + let limit = match input.get("limit") { + None | Some(Json::Null) => 50, + Some(value) => value.as_u64().ok_or_else(|| { + failure( + ProtocolErrorCode::InvalidArguments, + "limit must be an integer in 1..=100", + ) + })?, + }; + if !(1..=100).contains(&limit) { + return Err(failure( + ProtocolErrorCode::InvalidArguments, + "limit must be 1..=100", + )); + } + Ok(limit as usize) +} + +// Readable ASCII identities remain unchanged. Every other UTF-8 identity uses a +// canonical ~hex segment; the escape marker is itself always escaped. This is +// injective, does not exclude existing keys, and never decodes a second route. +fn encode_identity(id: &str) -> String { + if !id.is_empty() + && id + .bytes() + .all(|b| b.is_ascii_alphanumeric() || matches!(b, b'-' | b'_')) + { + id.into() + } else { + format!( + "~{}", + id.as_bytes() + .iter() + .map(|b| format!("{b:02x}")) + .collect::() + ) + } +} +fn decode_identity(segment: &str) -> Option { + let id = if let Some(hex) = segment.strip_prefix('~') { + if hex.len() % 2 != 0 || hex.is_empty() || !hex.bytes().all(|b| b.is_ascii_hexdigit()) { + return None; + } + let bytes = (0..hex.len()) + .step_by(2) + .map(|i| u8::from_str_radix(&hex[i..i + 2], 16)) + .collect::, _>>() + .ok()?; + String::from_utf8(bytes).ok()? + } else { + segment.into() + }; + (encode_identity(&id) == segment && !id.is_empty()).then_some(id) +} +fn item_path(domain: Domain, id: &str) -> String { + format!("{}/{}", domain.route(), encode_identity(id)) +} +fn parameter(name: &str, required: bool, r#type: TypeExpr) -> ParameterDeclaration { + ParameterDeclaration { + name: name.into(), + required, + documentation: None, + r#type, + } +} +fn operation( + name: &str, + summary: &str, + parameters: Vec, +) -> OperationDeclaration { + OperationDeclaration { + name: name.into(), + documentation: Some(Documentation { + summary: summary.into(), + details: None, + }), + parameters, + returns: ReturnDeclaration { + documentation: None, + r#type: TypeExpr::Json, + }, + } +} +fn descriptor( + domain: Domain, + item: bool, + operations: Vec, +) -> InterfaceDescriptor { + InterfaceDescriptor { format: INTERFACE_FORMAT_V1.into(), documentation: Some(Documentation { + summary: format!("Workspace {} {}", domain.owner(), if item { "item" } else { "collection" }), + details: Some("Object identity is route-bound. Feature implementation, target capability and subject permission are independent. Inventory does not grant content or command access.".into()), + }), types: Vec::new(), operations } +} +fn collection_descriptor(domain: Domain, management: bool) -> InterfaceDescriptor { + descriptor( + domain, + false, + if management { + vec![operation( + "create", + CREATE_DESCRIPTION, + vec![ + parameter("repository_key", true, TypeExpr::String), + parameter("selector", false, TypeExpr::String), + parameter("runtime_id", false, TypeExpr::String), + parameter("display_name", false, TypeExpr::String), + ], + )] + } else { + vec![operation( + "list", + "List a bounded page of visible Objects with item paths", + vec![ + parameter("limit", false, TypeExpr::Integer), + parameter("cursor", false, TypeExpr::String), + ], + )] + }, + ) +} +fn item_descriptor(domain: Domain, management: bool) -> InterfaceDescriptor { + descriptor( + domain, + true, + if management { + match domain { + Domain::Workdir => vec![ + operation( + "attach", + ATTACH_DESCRIPTION, + vec![parameter("alias", true, TypeExpr::String)], + ), + operation( + "delete", + DELETE_DESCRIPTION, + vec![parameter("reason", true, TypeExpr::String)], + ), + ], + Domain::Attachment => vec![operation("detach", DETACH_DESCRIPTION, vec![])], + Domain::Repository => vec![], + } + } else { + vec![operation( + "read", + "Read this Object through current Workspace authority", + vec![], + )] + }, + ) +} + +#[cfg(test)] +pub(crate) mod tests { + use super::*; + use manifest::ToolPermissionRule; + use std::sync::Mutex; + + #[derive(Debug)] + pub(crate) struct CatalogClient { + pub(crate) state: Mutex, + } + #[derive(Debug)] + pub(crate) struct CatalogState { + pub(crate) repositories: Vec, + pub(crate) workdirs: Vec, + pub(crate) attachments: Vec, + pub(crate) requests: Vec, + pub(crate) denied: bool, + generation: usize, + } + impl Default for CatalogClient { + fn default() -> Self { + Self { + state: Mutex::new(CatalogState { + repositories: vec![ + json!({"repository_key":"registered-key", "kind":"git", "provider":"git", "source":{"path":"/secret/host", "credential":"SECRET"}, "git":{"remotes":["secret-uri"]}}), + ], + workdirs: vec![], + attachments: vec![], + requests: vec![], + denied: false, + generation: 0, + }), + } + } + } + fn directory(id: &str, repository: &str) -> Json { + json!({"working_directory_id":id, "source":{"kind":"repository", "repository_key":repository}, "display_name":"Review checkout", "status":"active", "cleanliness":"clean", "materializer_kind":"runtime_git_clone", "cleanup_target":{"kind":"runtime_git_clone", "repository_key":repository, "working_directory_id":id}}) + } + impl WorkspaceClient for CatalogClient { + fn workspace_id(&self) -> Option<&str> { + Some("test-workspace") + } + fn kind(&self) -> &str { + "catalog-test" + } + fn is_available(&self) -> bool { + true + } + fn execute( + &self, + request: WorkspaceRequest, + ) -> Result { + let mut state = self.state.lock().unwrap(); + state.requests.push(request.clone()); + if state.denied { + return Ok(WorkspaceResponse { + status: 403, + body: "SECRET /host/path".into(), + }); + } + let path = request.path.strip_prefix("/api/w/test-workspace/").unwrap(); + let body: Json = request + .body + .as_deref() + .map(|b| serde_json::from_str(b).unwrap()) + .unwrap_or(Json::Null); + let value = match (request.method, path.split('?').next().unwrap()) { + (WorkspaceRequestMethod::Get, "repositories") => { + json!({"items":state.repositories}) + } + (WorkspaceRequestMethod::Get, "working-directories") => { + // The legacy browser/Tool endpoint is a latest-200 snapshot, + // not a complete WIP catalog; regressions must not use it. + json!({"items":state.workdirs.iter().rev().take(200).collect::>()}) + } + (WorkspaceRequestMethod::Get, "workers/self/workdir-catalog") => { + let query: BTreeMap<_, _> = path + .split('?') + .nth(1) + .unwrap_or_default() + .split('&') + .filter_map(|p| p.split_once('=')) + .collect(); + let limit = query + .get("limit") + .and_then(|s| s.parse::().ok()) + .unwrap_or(50); + let cursor = query.get("cursor").copied(); + let mut all: Vec<_> = state.workdirs.iter().collect(); + all.sort_by_key(|v| v["working_directory_id"].as_str().unwrap()); + let revision = Sha256::digest(serde_json::to_vec(&all).unwrap()) + .iter() + .map(|b| format!("{b:02x}")) + .collect::(); + let remaining: Vec<_> = all + .into_iter() + .filter(|v| { + cursor.is_none_or(|c| v["working_directory_id"].as_str().unwrap() > c) + }) + .collect(); + let has_more = remaining.len() > limit; + let items: Vec<_> = remaining.into_iter().take(limit).collect(); + let next = if has_more { + items.last().map(|v| v["working_directory_id"].clone()) + } else { + None + }; + json!({"items":items, "next_cursor":next, "revision":revision}) + } + (WorkspaceRequestMethod::Get, "workers/self/workdir-attachments") => { + let query: BTreeMap<_, _> = path + .split('?') + .nth(1) + .unwrap_or_default() + .split('&') + .filter_map(|p| p.split_once('=')) + .collect(); + let mut all: Vec<_> = state.attachments.iter().collect(); + all.sort_by_key(|v| v["alias"].as_str().unwrap()); + let revision = Sha256::digest(serde_json::to_vec(&all).unwrap()) + .iter() + .map(|b| format!("{b:02x}")) + .collect::(); + if let Some(expected) = query.get("connection_id") { + json!({"items":all.into_iter().filter(|a| a["connection_id"] == *expected).collect::>(), "next_offset":null, "revision":revision}) + } else { + let limit = query + .get("limit") + .and_then(|s| s.parse::().ok()) + .unwrap_or(50); + let offset = query + .get("offset") + .and_then(|s| s.parse::().ok()) + .unwrap_or(0); + let count = all.len(); + let items: Vec<_> = all.into_iter().skip(offset).take(limit).collect(); + let next = (offset + limit < count).then_some(offset + limit); + json!({"items":items, "next_offset":next, "revision":revision}) + } + } + (WorkspaceRequestMethod::Get, p) if p.starts_with("repositories/") => { + let id = p.trim_start_matches("repositories/"); + match state + .repositories + .iter() + .find(|r| encode_path_segment(r["repository_key"].as_str().unwrap()) == id) + { + Some(r) => json!({"item":r}), + None => { + return Ok(WorkspaceResponse { + status: 404, + body: String::new(), + }); + } + } + } + (WorkspaceRequestMethod::Get, p) if p.starts_with("working-directories/") => { + let id = p.trim_start_matches("working-directories/"); + match state + .workdirs + .iter() + .find(|r| r["working_directory_id"] == id) + { + Some(r) => json!({"item":r}), + None => { + return Ok(WorkspaceResponse { + status: 404, + body: String::new(), + }); + } + } + } + (WorkspaceRequestMethod::Post, "working-directories") => { + let raw = directory("wd-created", body["repository_key"].as_str().unwrap()); + state.workdirs.push(raw.clone()); + json!({"workspace_id":"test-workspace", "runtime_id":"runtime", "item":raw, "diagnostics":[]}) + } + (WorkspaceRequestMethod::Post, "workers/self/workdir-attachments") => { + state.generation += 1; + let raw = json!({"alias":body["alias"], "working_directory_id":body["working_directory_id"], "connection_id":format!("connection-{}", state.generation), "capabilities":{"bits":63}}); + state.attachments.push(raw); + json!({"workspace_id":"test-workspace", "alias":body["alias"], "working_directory_id":body["working_directory_id"], "capabilities":{"bits":63}, "attached":true}) + } + (WorkspaceRequestMethod::Delete, p) + if p.starts_with("workers/self/workdir-attachments/") => + { + let alias = p.trim_start_matches("workers/self/workdir-attachments/"); + let expected = path + .split("expected_connection_id=") + .nth(1) + .unwrap_or_default(); + let Some(index) = state + .attachments + .iter() + .position(|r| r["alias"] == alias && r["connection_id"] == expected) + else { + return Ok(WorkspaceResponse { + status: 409, + body: "stale".into(), + }); + }; + let raw = state.attachments.remove(index); + json!({"workspace_id":"test-workspace", "alias":raw["alias"], "working_directory_id":raw["working_directory_id"], "capabilities":raw["capabilities"], "attached":false}) + } + (WorkspaceRequestMethod::Delete, p) if p.starts_with("working-directories/") => { + json!({"working_directory_id":p.trim_start_matches("working-directories/"), "disposition":"retained", "retryable":false}) + } + other => panic!("unexpected catalog request: {other:?}"), + }; + Ok(WorkspaceResponse { + status: 200, + body: value.to_string(), + }) + } + } + fn provider( + client: Arc, + catalog: bool, + manage: bool, + permissions: Option, + ) -> Provider { + Provider { + backend: ManageWorkdirFeature::new(client).backend(), + catalog, + manage, + permissions, + } + } + fn arguments(input: Json) -> BTreeMap { + input + .as_object() + .unwrap() + .iter() + .map(|(k, v)| (k.clone(), json_to_wip(v).unwrap())) + .collect() + } + fn context() -> WipCallContext { + WipCallContext { + execution: ToolExecutionContext::default(), + security_context: "test".into(), + } + } + async fn call(handler: &dyn WipOperationHandler, operation: &str, input: Json) -> Json { + let output = handler + .call(operation, &arguments(input), context()) + .await + .unwrap_or_else(|_| panic!("{operation} failed")); + wip_to_json(&output.value).unwrap() + } + fn policy(allowed: &[&str]) -> ToolPermissionConfig { + ToolPermissionConfig { + default_action: ToolPermissionAction::Deny, + rules: allowed + .iter() + .map(|name| ToolPermissionRule { + tool: (*name).into(), + pattern: "*".into(), + action: ToolPermissionAction::Allow, + }) + .collect(), + } + } + + #[test] + fn catalog_route_encoding_is_injective_and_canonical_for_existing_keys() { + let keys = [ + "main", + "Main", + "repo/name", + "../main", + ".", + "日本語", + "~2e", + "a.b", + "%2f", + "with space", + ]; + let mut paths = std::collections::HashSet::new(); + for key in keys { + let encoded = encode_identity(key); + assert_eq!(decode_identity(&encoded).as_deref(), Some(key)); + assert!(paths.insert(encoded.clone())); + wip_protocol::validate_path(&format!("/repositories/{encoded}")).unwrap(); + } + for invalid in ["~", "~xx", "~é", "~6162", "a.b", "..", "~C3A9"] { + assert!(decode_identity(invalid).is_none(), "{invalid}"); + } + } + + #[tokio::test] + async fn catalog_fresh_workspace_find_key_create_attach_read_detach_preserves_workdir() { + let client = Arc::new(CatalogClient::default()); + let p = provider(client.clone(), true, true, None); + let repositories = CollectionHandler { + provider: p.clone(), + domain: Domain::Repository, + }; + let listed = call(&repositories, "list", json!({})).await; + assert_eq!(listed["items"][0]["repository_key"], "registered-key"); + assert_eq!(listed["items"][0]["path"], "/repositories/registered-key"); + assert!(listed["items"][0]["default_selector"].is_null()); + assert!(!listed.to_string().contains("SECRET")); + assert!(!listed.to_string().contains("/secret/host")); + let repository = ItemHandler { + provider: p.clone(), + domain: Domain::Repository, + id: "registered-key".into(), + }; + assert!(!repository.operation_available("create")); + let read = call(&repository, "read", json!({})).await; + let workdirs = CollectionHandler { + provider: p.clone(), + domain: Domain::Workdir, + }; + let created = call( + &workdirs, + "create", + json!({"repository_key":read["repository_key"]}), + ) + .await; + assert_eq!(created["item"]["path"], "/workdirs/wd-created"); + assert!( + client.state.lock().unwrap().attachments.is_empty(), + "create is not attach" + ); + let workdir = ItemHandler { + provider: p.clone(), + domain: Domain::Workdir, + id: "wd-created".into(), + }; + let attached = call(&workdir, "attach", json!({"alias":"checkout"})).await; + assert_eq!(attached["attachments_path"], ATTACHMENTS); + let attachments = CollectionHandler { + provider: p.clone(), + domain: Domain::Attachment, + }; + let listed = call(&attachments, "list", json!({})).await; + assert_eq!(listed["items"][0]["alias"], "checkout"); + assert_eq!(listed["items"][0]["workdir_path"], "/workdirs/wd-created"); + let connection = ItemHandler { + provider: p.clone(), + domain: Domain::Attachment, + id: listed["items"][0]["connection_id"].as_str().unwrap().into(), + }; + call(&connection, "detach", json!({})).await; + assert!(client.state.lock().unwrap().attachments.is_empty()); + assert_eq!( + client.state.lock().unwrap().workdirs.len(), + 1, + "detach is not delete" + ); + assert!(p.backend.session_router.aliases().is_empty()); + let retained = call(&workdir, "delete", json!({"reason":"done"})).await; + assert_eq!(retained["disposition"], "retained"); + } + + #[tokio::test] + async fn catalog_feature_permission_and_target_capability_are_independent() { + let client = Arc::new(CatalogClient::default()); + client + .state + .lock() + .unwrap() + .workdirs + .push(directory("wd", "registered-key")); + for manage in [false, true] { + let p = provider( + client.clone(), + true, + manage, + Some(policy(&[ + "RepositoryList", + "RepositoryRead", + LIST_TOOL, + "WorkdirRead", + ])), + ); + let handler = ItemHandler { + provider: p.clone(), + domain: Domain::Workdir, + id: "wd".into(), + }; + assert!(handler.operation_available("read")); + assert!(!handler.operation_available("attach")); + assert!(matches!( + handler + .call("attach", &arguments(json!({"alias":"x"})), context()) + .await, + Err(WipOperationError::Protocol(_)) + )); + let resolver = ItemResolver { + provider: p, + domain: Domain::Workdir, + }; + assert!(resolver.resolve("wd").is_some()); + } + let denied = provider(client.clone(), true, true, Some(policy(&[]))); + assert!( + ItemResolver { + provider: denied.clone(), + domain: Domain::Workdir + } + .resolve("wd") + .is_none() + ); + assert!( + !CollectionHandler { + provider: denied, + domain: Domain::Repository + } + .is_visible() + ); + let p = provider(client.clone(), true, true, None); + client.state.lock().unwrap().workdirs.push(json!({"working_directory_id":"external", "status":"active", "source":{"kind":"external_grant", "grant_id":"opaque", "grant_permissions":{"read":true}}})); + let external = ItemHandler { + provider: p.clone(), + domain: Domain::Workdir, + id: "external".into(), + }; + assert!(external.operation_available("attach")); + assert!(!external.operation_available("delete")); + let raw = call(&external, "read", json!({})).await; + assert!(raw["source"].get("grant_id").is_none()); + client.state.lock().unwrap().denied = true; + assert!(!external.operation_available("read")); + assert!( + ItemResolver { + provider: p, + domain: Domain::Workdir + } + .resolve("external") + .is_none() + ); + assert!( + client + .state + .lock() + .unwrap() + .requests + .iter() + .all(|r| r.method == WorkspaceRequestMethod::Get) + ); + } + + #[tokio::test] + async fn catalog_empty_paging_route_bound_identity_and_failure_classification() { + let client = Arc::new(CatalogClient::default()); + let p = provider(client.clone(), true, true, None); + let handler = CollectionHandler { + provider: p.clone(), + domain: Domain::Repository, + }; + client.state.lock().unwrap().repositories.clear(); + let empty = call(&handler, "list", json!({})).await; + assert_eq!(empty["empty"], true); + client.state.lock().unwrap().repositories = (0..3) + .map(|i| json!({"repository_key":format!("key-{i}")})) + .collect(); + let first = call(&handler, "list", json!({"limit":2})).await; + assert_eq!(first["items"].as_array().unwrap().len(), 2); + let second = call( + &handler, + "list", + json!({"limit":2, "cursor":first["next_cursor"]}), + ) + .await; + assert_eq!(second["items"][0]["repository_key"], "key-2"); + assert_eq!(second["has_more"], false); + assert!( + handler + .call("list", &arguments(json!({"limit":101})), context()) + .await + .is_err() + ); + let item = ItemHandler { + provider: p, + domain: Domain::Workdir, + id: "wd-bound".into(), + }; + let requests_before = client.state.lock().unwrap().requests.len(); + assert!( + item.call( + "delete", + &arguments(json!({"working_directory_id":"different", "reason":"done"})), + context() + ) + .await + .is_err() + ); + assert_eq!(client.state.lock().unwrap().requests.len(), requests_before); + assert!(matches!( + map_tool_error(ToolError::StructuredConflict { + code: "workspace_http_409".into(), + message: "SECRET".into() + }), + WipOperationError::Protocol(ProtocolError { + code: ProtocolErrorCode::ValidatorMismatch, + .. + }) + )); + assert!(matches!( + map_tool_error(ToolError::ExecutionFailed( + "transport uncertain SECRET".into() + )), + WipOperationError::OutcomeUnknown(_) + )); + } + + #[test] + fn catalog_registration_uses_host_namespace_and_rejects_conflicts() { + let feature = ManageWorkdirFeature::new(Arc::new(CatalogClient::default())); + let mut registry = WipMountRegistry::new(); + mount_workspace_workdir_wip(&mut registry, &feature, true, true, None).unwrap(); + assert_eq!( + registry.routes().collect::>(), + [REPOSITORIES, ATTACHMENTS, WORKDIRS] + ); + assert!(matches!( + mount_workspace_workdir_wip(&mut registry, &feature, true, true, None), + Err(WipMountError::RouteCollision { .. }) + )); + assert!(matches!( + registry.contribute_operations(WipOperationContribution { + route: WORKDIRS.into(), + contributor: "another-feature".into(), + interface: "yoi.workdir/collection/v1".into(), + descriptor: collection_descriptor(Domain::Workdir, true), + handler: Arc::new(CollectionHandler { + provider: provider(Arc::new(CatalogClient::default()), true, true, None), + domain: Domain::Workdir + }) + }), + Err(WipMountError::OperationCollision { .. }) + )); + } +} + +#[cfg(test)] +mod lifetime_tests { + use super::tests::CatalogClient; + use super::*; + use std::sync::atomic::{AtomicUsize, Ordering}; + + #[tokio::test] + async fn attachment_pages_and_direct_lookup_are_not_limited_to_first_page() { + let client = Arc::new(CatalogClient::default()); + client.state.lock().unwrap().attachments = (0..65).map(|n| json!({"connection_id":format!("lifetime-{n:03}"), "alias":format!("alias-{n:03}"), "working_directory_id":"wd", "capabilities":{"bits":25}})).collect(); + let p = Provider { + backend: ManageWorkdirFeature::new(client).backend(), + permissions: None, + catalog: true, + manage: true, + }; + let handler = CollectionHandler { + provider: p.clone(), + domain: Domain::Attachment, + }; + let args = BTreeMap::from([("limit".into(), Value::Integer(50))]); + let context = || WipCallContext { + execution: ToolExecutionContext::default(), + security_context: "test".into(), + }; + let first = handler + .call("list", &args, context()) + .await + .unwrap_or_else(|_| panic!("list failed")); + let first = wip_to_json(&first.value).unwrap(); + assert_eq!(first["items"].as_array().unwrap().len(), 50); + assert_eq!(first["next_cursor"], "offset:50"); + let args = BTreeMap::from([("cursor".into(), Value::String("offset:50".into()))]); + let second = handler + .call("list", &args, context()) + .await + .unwrap_or_else(|_| panic!("list failed")); + let second = wip_to_json(&second.value).unwrap(); + assert_eq!(second["items"].as_array().unwrap().len(), 15); + assert_eq!(second["has_more"], false); + let resolver = ItemResolver { + provider: p, + domain: Domain::Attachment, + }; + assert!(resolver.resolve("lifetime-064").is_some()); + assert!(resolver.resolve("lifetime-unknown").is_none()); + } + + #[tokio::test] + async fn stale_lifetime_preflight_never_closes_new_alias_or_stops_children() { + let client = Arc::new(CatalogClient::default()); + client.state.lock().unwrap().attachments.push(json!({"connection_id":"replacement", "alias":"checkout", "working_directory_id":"same-workdir", "capabilities":{"bits":63}})); + let hooks = Arc::new(AtomicUsize::new(0)); + let hook_calls = hooks.clone(); + let backend = WorkspaceHttpWorkdirBackend::new(client.clone()).with_child_lifecycle( + Some(Arc::new(move || { + let hook_calls = hook_calls.clone(); + Box::pin(async move { + hook_calls.fetch_add(1, Ordering::SeqCst); + Ok(()) + }) + })), + None, + ); + let alias = workdir::WorkdirAttachmentAlias::new("checkout").unwrap(); + backend + .session_router + .attach( + alias.clone(), + WorkspaceAttachedWorkdirSession::handle_for_workdir( + client.clone(), + "checkout", + "same-workdir", + ), + ) + .unwrap(); + let error = backend + .detach_managed("checkout", Some("old-lifetime")) + .await + .unwrap_err(); + assert!(matches!(error, ToolError::StructuredConflict { .. })); + assert_eq!(hooks.load(Ordering::SeqCst), 0); + assert!(backend.session_router.session(&alias).is_some()); + assert_eq!( + client.state.lock().unwrap().attachments[0]["connection_id"], + "replacement" + ); + assert!( + client + .state + .lock() + .unwrap() + .requests + .iter() + .all(|r| r.method == WorkspaceRequestMethod::Get) + ); + } +} + +#[cfg(test)] +mod logical_capability_tests { + use super::*; + #[test] + fn logical_reference_does_not_gain_physical_attach_delete_or_command() { + let client = Arc::new(super::tests::CatalogClient::default()); + client.state.lock().unwrap().workdirs.push(json!({"working_directory_id":"logical-settings", "source":{"kind":"workspace_config"}, "status":"active"})); + let provider = Provider { + backend: ManageWorkdirFeature::new(client).backend(), + catalog: true, + manage: true, + permissions: None, + }; + let item = ItemHandler { + provider, + domain: Domain::Workdir, + id: "logical-settings".into(), + }; + assert!(item.operation_available("read")); + for operation in ["attach", "delete", "command"] { + assert!(!item.operation_available(operation)); + } + } +} + +#[cfg(test)] +mod page_contract_tests { + use super::*; + #[test] + fn bounded_page_limit_rejects_negative_and_noninteger_inputs() { + assert_eq!(page_limit(&json!({})).unwrap_or_default(), 50); + assert_eq!(page_limit(&json!({"limit":100})).unwrap_or_default(), 100); + for input in [ + json!({"limit":-1}), + json!({"limit":0}), + json!({"limit":101}), + json!({"limit":"50"}), + json!({"limit":1.5}), + ] { + assert!(matches!( + page_limit(&input), + Err(WipOperationError::Protocol(ProtocolError { + code: ProtocolErrorCode::InvalidArguments, + .. + })) + )); + } + } +} diff --git a/crates/worker/src/feature/builtin/memory.rs b/crates/worker/src/feature/builtin/memory.rs index 62411f540..3565e8fcf 100644 --- a/crates/worker/src/feature/builtin/memory.rs +++ b/crates/worker/src/feature/builtin/memory.rs @@ -4,7 +4,9 @@ //! authority. In that case model-visible Memory tools must go through the //! workspace backend instead of resolving `.yoi/memory` from a Worker workdir. -use std::sync::Arc; +use std::collections::HashMap; +use std::sync::{Arc, Mutex}; +use std::time::Duration; use agen::tool::{Tool, ToolDefinition, ToolError, ToolExecutionContext, ToolMeta, ToolOutput}; use arc_swap::ArcSwap; @@ -23,11 +25,15 @@ use crate::feature::{ FeatureDescriptor, FeatureHookPoint, FeatureInstallContext, FeatureInstallError, FeatureModule, HookDeclaration, ToolContribution, ToolDeclaration, }; -use crate::hook::{Hook, HookError, HookErrorCategory, HookExecutionPolicy, WorkerRestored}; +use crate::hook::{ + BeforeSessionRewrite, BeforeSessionRewriteAction, BeforeSessionRewriteContext, Hook, HookError, + HookErrorCategory, HookExecutionPolicy, HookPreRequestAction, PreLlmRequest, PreRequestContext, + WorkerRestored, +}; use crate::prompt::catalog::{PromptCatalog, WorkerPrompt}; use crate::worker::{ - SystemPromptContribution, SystemPromptContributionSource, WorkspaceClient, - WorkspaceClientError, WorkspaceRequest, WorkspaceRequestMethod, + ResidentContextRefresh, SystemPromptContribution, SystemPromptContributionSource, + WorkspaceClient, WorkspaceClientError, WorkspaceRequest, WorkspaceRequestMethod, }; #[derive(Clone, Debug)] @@ -73,6 +79,8 @@ pub enum WorkspaceMemoryBackendError { Decode(#[from] serde_json::Error), #[error("workspace memory backend rejected operation: {0}")] Backend(String), + #[error("workspace memory backend returned an invalid response: {0}")] + InvalidResponse(String), } impl dyn WorkspaceClient + '_ { @@ -150,9 +158,24 @@ impl dyn WorkspaceClient + '_ { } } +const RESIDENT_SUMMARY_REQUEST_TIMEOUT: Duration = Duration::from_secs(5); + async fn execute_subjektiv_memory_operation( client: &dyn WorkspaceClient, operation: server_api::SubjektivMemoryBackendOperation, +) -> Result { + execute_subjektiv_memory_operation_with(client, operation, |client, request| { + client.execute(request) + }) +} + +fn execute_subjektiv_memory_operation_with( + client: &dyn WorkspaceClient, + operation: server_api::SubjektivMemoryBackendOperation, + execute: impl FnOnce( + &dyn WorkspaceClient, + WorkspaceRequest, + ) -> Result, ) -> Result { let workspace_id = client @@ -163,11 +186,14 @@ async fn execute_subjektiv_memory_operation( client.kind() ), })?; - let response = client.execute(WorkspaceRequest::json( - WorkspaceRequestMethod::Post, - format!("/api/w/{workspace_id}/subjektiv/memory"), - serde_json::to_string(&server_api::SubjektivMemoryBackendRequest { operation })?, - ))?; + let response = execute( + client, + WorkspaceRequest::json( + WorkspaceRequestMethod::Post, + format!("/api/w/{workspace_id}/subjektiv/memory"), + serde_json::to_string(&server_api::SubjektivMemoryBackendRequest { operation })?, + ), + )?; let status = reqwest::StatusCode::from_u16(response.status) .unwrap_or(reqwest::StatusCode::INTERNAL_SERVER_ERROR); if !response.is_success() { @@ -653,12 +679,17 @@ struct WorkspaceResidentSummarySource { client: Arc, } -struct WorkspaceSubjektivResidentSummarySource { - client: Arc, +#[derive(Default)] +struct SubjectBehaviorRefreshState { + applied: Option<(u64, String)>, + pending: HashMap, } -const READY_EMPTY_RESIDENT_SURFACE: &str = - "The current ready resident memory surface is intentionally empty."; +struct WorkspaceSubjektivResidentSummarySource { + client: Arc, + prompts: Arc>, + behavior_state: Mutex, +} fn resident_summary_contribution( output: memory::backend::MemoryResidentSummaryOutput, @@ -696,36 +727,133 @@ impl SystemPromptContributionSource for WorkspaceResidentSummarySource { } } +impl WorkspaceSubjektivResidentSummarySource { + async fn fetch( + &self, + ) -> Result { + match execute_subjektiv_memory_operation_with( + self.client.as_ref(), + server_api::SubjektivMemoryBackendOperation::ResidentContext(Default::default()), + |client, request| { + client.execute_with_timeout(request, RESIDENT_SUMMARY_REQUEST_TIMEOUT) + }, + )? { + server_api::SubjektivMemoryBackendResponse::ResidentContext(output) => Ok(output), + other => Err(WorkspaceMemoryBackendError::InvalidResponse(format!( + "unexpected subject resident context response: {other:?}" + ))), + } + } + + fn render( + &self, + output: &server_api::SubjektivResidentContextOutput, + ) -> Result { + use memory::backend::MemoryResidentSummaryAvailability as Availability; + let availability = match output.memory_surface.availability { + Availability::Ready => "ready", + Availability::Ungenerated => "ungenerated", + Availability::Stale => "stale", + Availability::Failed => "failed", + Availability::Unavailable => "unavailable", + }; + self.prompts + .load() + .subjektiv_resident_context( + &output.behavior_md, + availability, + output.memory_surface.content.as_deref(), + ) + .map_err(|error| error.to_string()) + } + + fn remember_behavior(&self, output: &server_api::SubjektivResidentContextOutput) { + let mut state = self + .behavior_state + .lock() + .expect("subject behavior state poisoned"); + state.applied = Some((output.behavior_revision, output.behavior_md.clone())); + state.pending.remove(&output.behavior_revision); + } +} + #[async_trait] impl SystemPromptContributionSource for WorkspaceSubjektivResidentSummarySource { async fn load(&self) -> SystemPromptContribution { - match execute_subjektiv_memory_operation( - self.client.as_ref(), - server_api::SubjektivMemoryBackendOperation::ResidentSummary(Default::default()), - ) - .await - { - Ok(server_api::SubjektivMemoryBackendResponse::ResidentSummary(output)) => { - match resident_summary_contribution(output) { - SystemPromptContribution::Ready(content) if content.trim().is_empty() => { - SystemPromptContribution::Ready(READY_EMPTY_RESIDENT_SURFACE.to_string()) - } - contribution => contribution, + match self.fetch().await { + Ok(output) => match self.render(&output) { + Ok(rendered) => { + self.remember_behavior(&output); + SystemPromptContribution::Ready(rendered) } - } - Ok(other) => { - tracing::debug!(?other, "unexpected subject resident Memory response"); - SystemPromptContribution::Unavailable - } + Err(error) => { + tracing::error!(%error, "subject resident context render failed"); + SystemPromptContribution::Ready( + "The Host could not render the Subject resident context. Do not treat this as unset behavior or infer replacement behavior from Memory." + .to_string(), + ) + } + }, Err(error) => { - tracing::debug!(%error, "subject resident Memory unavailable"); - SystemPromptContribution::Unavailable + tracing::error!(%error, "subject resident context fetch failed"); + SystemPromptContribution::Ready( + "The Host could not fetch the Subject resident context. Do not treat this as unset behavior or infer replacement behavior from Memory." + .to_string(), + ) } } } + + async fn load_changed_resident_context( + &self, + ) -> Result, String> { + let output = self.fetch().await.map_err(|error| error.to_string())?; + let changed = self + .behavior_state + .lock() + .expect("subject behavior state poisoned") + .applied + .as_ref() + .is_none_or(|(revision, body)| { + *revision != output.behavior_revision || body != &output.behavior_md + }); + if !changed { + return Ok(None); + } + let rendered = self.render(&output)?; + let revision = output.behavior_revision; + self.behavior_state + .lock() + .expect("subject behavior state poisoned") + .pending + .insert(revision, output.behavior_md); + Ok(Some(ResidentContextRefresh { + body: rendered, + revision, + })) + } + + fn confirm_resident_context_revision(&self, revision: u64) { + let mut state = self + .behavior_state + .lock() + .expect("subject behavior state poisoned"); + if let Some(body) = state.pending.remove(&revision) { + state.applied = Some((revision, body)); + } + } + + fn invalidate_resident_context_representation(&self) { + self.behavior_state + .lock() + .expect("subject behavior state poisoned") + .applied = None; + } } const SUBJEKTIV_RESTORE_REFRESH_HOOK: &str = "refresh-resident-surface-after-restore"; +const SUBJEKTIV_BEHAVIOR_REFRESH_HOOK: &str = "refresh-subject-behavior-before-request"; +const SUBJEKTIV_BEHAVIOR_REWRITE_HOOK: &str = "invalidate-subject-behavior-before-rewrite"; /// subjektiv-owned restore behavior. Initial prompt contribution remains a /// shared host facility, while loading and rendering a post-restore refresh is @@ -773,6 +901,14 @@ impl FeatureModule for SubjektivResidentRestoreRefreshFeature { SUBJEKTIV_RESTORE_REFRESH_HOOK, FeatureHookPoint::WorkerRestored, )) + .with_hook(HookDeclaration::new( + SUBJEKTIV_BEHAVIOR_REFRESH_HOOK, + FeatureHookPoint::PreLlmRequest, + )) + .with_hook(HookDeclaration::new( + SUBJEKTIV_BEHAVIOR_REWRITE_HOOK, + FeatureHookPoint::BeforeSessionRewrite, + )) } fn install(&self, context: &mut FeatureInstallContext<'_>) -> Result<(), FeatureInstallError> { @@ -784,10 +920,64 @@ impl FeatureModule for SubjektivResidentRestoreRefreshFeature { prompts: Arc::clone(&self.prompts), workspace_id: self.workspace_id.clone(), }, + )?; + context.hooks().add_pre_llm_request( + SUBJEKTIV_BEHAVIOR_REFRESH_HOOK, + HookExecutionPolicy::fail_closed(), + SubjektivBehaviorRefreshHook { + source: Arc::clone(&self.source), + }, + )?; + context.hooks().add_before_session_rewrite( + SUBJEKTIV_BEHAVIOR_REWRITE_HOOK, + HookExecutionPolicy::fail_closed(), + SubjektivBehaviorRewriteHook { + source: Arc::clone(&self.source), + }, ) } } +struct SubjektivBehaviorRewriteHook { + source: Arc, +} + +#[async_trait] +impl Hook for SubjektivBehaviorRewriteHook { + async fn call( + &self, + _context: &BeforeSessionRewriteContext, + ) -> Result { + self.source.invalidate_resident_context_representation(); + Ok(BeforeSessionRewriteAction::Continue) + } +} + +struct SubjektivBehaviorRefreshHook { + source: Arc, +} + +#[async_trait] +impl Hook for SubjektivBehaviorRefreshHook { + async fn call(&self, context: &PreRequestContext) -> Result { + let refresh = self + .source + .load_changed_resident_context() + .await + .map_err(|error| HookError::new(HookErrorCategory::Dependency, error))?; + if let Some(refresh) = refresh { + let system_items = context.system_items().ok_or_else(|| { + HookError::new( + HookErrorCategory::Dependency, + "Subject behavior refresh requires durable session append authority", + ) + })?; + system_items.append_subject_behavior_refresh(refresh.body, refresh.revision); + } + Ok(HookPreRequestAction::Continue) + } +} + struct SubjektivResidentRestoreRefreshHook { source: Arc, prompts: Arc>, @@ -931,6 +1121,7 @@ pub(crate) fn ordinary_subjektiv_features_enabled(manifest: &manifest::WorkerMan pub(crate) fn ordinary_subjektiv_resident_summary_source( manifest: &manifest::WorkerManifest, client: Arc, + prompts: Arc>, ) -> std::io::Result>> { if !ordinary_subjektiv_features_enabled(manifest) { return Ok(None); @@ -968,6 +1159,8 @@ pub(crate) fn ordinary_subjektiv_resident_summary_source( } Ok(Some(Arc::new(WorkspaceSubjektivResidentSummarySource { client, + prompts, + behavior_state: Mutex::new(SubjectBehaviorRefreshState::default()), }))) } @@ -1100,6 +1293,10 @@ mod tests { use agen::tool::ToolDefinition; use std::sync::Mutex; + fn test_prompts() -> Arc> { + Arc::new(ArcSwap::from(PromptCatalog::builtins_only().unwrap())) + } + fn test_client() -> Arc { Arc::new(crate::worker::TestWorkspaceHttpClient::new( "workspace", @@ -1112,7 +1309,9 @@ mod tests { availability: memory::backend::MemoryResidentSummaryAvailability, content: Option, scope_allowed: bool, + behavior: Mutex<(u64, String)>, paths: Mutex>, + timeouts: Mutex>, } impl SubjectResidentClient { @@ -1124,7 +1323,9 @@ mod tests { availability, content: content.map(str::to_string), scope_allowed: true, + behavior: Mutex::new((3, "Be deliberate.".to_string())), paths: Mutex::new(Vec::new()), + timeouts: Mutex::new(Vec::new()), } } @@ -1133,7 +1334,9 @@ mod tests { availability: memory::backend::MemoryResidentSummaryAvailability::Ready, content: Some("must not be injected".into()), scope_allowed: false, + behavior: Mutex::new((3, "must not be injected".to_string())), paths: Mutex::new(Vec::new()), + timeouts: Mutex::new(Vec::new()), } } } @@ -1161,7 +1364,7 @@ mod tests { serde_json::from_str(request.body.as_deref().unwrap_or_default()).unwrap(); assert!(matches!( request.operation, - server_api::SubjektivMemoryBackendOperation::ResidentSummary(_) + server_api::SubjektivMemoryBackendOperation::ResidentContext(_) )); if !self.scope_allowed { return Ok(WorkspaceResponse { @@ -1169,19 +1372,33 @@ mod tests { body: "subject scope unavailable".into(), }); } + let (behavior_revision, behavior_md) = self.behavior.lock().unwrap().clone(); Ok(WorkspaceResponse { status: 200, body: serde_json::to_string( - &server_api::SubjektivMemoryBackendResponse::ResidentSummary( - memory::backend::MemoryResidentSummaryOutput { - availability: self.availability, - content: self.content.clone(), + &server_api::SubjektivMemoryBackendResponse::ResidentContext( + server_api::SubjektivResidentContextOutput { + behavior_md, + behavior_revision, + memory_surface: memory::backend::MemoryResidentSummaryOutput { + availability: self.availability, + content: self.content.clone(), + }, }, ), ) .unwrap(), }) } + + fn execute_with_timeout( + &self, + request: WorkspaceRequest, + timeout: Duration, + ) -> Result { + self.timeouts.lock().unwrap().push(timeout); + self.execute(request) + } } fn resident_client(content: &str) -> Arc { @@ -1479,29 +1696,44 @@ permission = "write" memory::backend::MemoryResidentSummaryAvailability::Ready, Some("current subject surface"), )); - let ready = ordinary_subjektiv_resident_summary_source(&manifest, ready_client.clone()) - .unwrap() - .unwrap(); - assert_eq!( - ready.load().await, - SystemPromptContribution::Ready("current subject surface".into()) - ); + let ready = ordinary_subjektiv_resident_summary_source( + &manifest, + ready_client.clone(), + test_prompts(), + ) + .unwrap() + .unwrap(); + let loaded = ready.load().await; + let SystemPromptContribution::Ready(loaded) = loaded else { + panic!("subject resident context must be present"); + }; + assert!(loaded.contains("Be deliberate.")); + assert!(loaded.contains("current subject surface")); assert_eq!( ready_client.paths.lock().unwrap().clone(), vec!["/api/w/workspace/subjektiv/memory".to_string()] ); + assert_eq!( + ready_client.timeouts.lock().unwrap().as_slice(), + &[RESIDENT_SUMMARY_REQUEST_TIMEOUT] + ); let ready_empty_client = Arc::new(SubjectResidentClient::new( memory::backend::MemoryResidentSummaryAvailability::Ready, None, )); - let ready_empty = ordinary_subjektiv_resident_summary_source(&manifest, ready_empty_client) - .unwrap() - .unwrap(); - assert_eq!( - ready_empty.load().await, - SystemPromptContribution::Ready(READY_EMPTY_RESIDENT_SURFACE.into()) - ); + let ready_empty = ordinary_subjektiv_resident_summary_source( + &manifest, + ready_empty_client, + test_prompts(), + ) + .unwrap() + .unwrap(); + let SystemPromptContribution::Ready(ready_empty) = ready_empty.load().await else { + panic!("empty surface must retain Subject behavior"); + }; + assert!(ready_empty.contains("Be deliberate.")); + assert!(ready_empty.contains("intentionally empty")); for availability in [ memory::backend::MemoryResidentSummaryAvailability::Ungenerated, @@ -1514,22 +1746,107 @@ permission = "write" availability, Some("must not be injected"), )), + test_prompts(), ) .unwrap() .unwrap(); - assert_eq!(source.load().await, SystemPromptContribution::Unavailable); + let SystemPromptContribution::Ready(loaded) = source.load().await else { + panic!("Subject behavior must survive unavailable surfaces"); + }; + assert!(loaded.contains("Be deliberate.")); + assert!(!loaded.contains("must not be injected")); } } + #[tokio::test] + async fn subject_resident_source_emits_only_changed_behavior_revisions() { + let manifest = subject_manifest("workspace"); + let client = Arc::new(SubjectResidentClient::new( + memory::backend::MemoryResidentSummaryAvailability::Ungenerated, + None, + )); + let source = + ordinary_subjektiv_resident_summary_source(&manifest, client.clone(), test_prompts()) + .unwrap() + .unwrap(); + + let initial = source.load().await; + assert!(matches!(initial, SystemPromptContribution::Ready(_))); + assert!( + source + .load_changed_resident_context() + .await + .unwrap() + .is_none() + ); + + *client.behavior.lock().unwrap() = (4, "Ask when uncertain.".to_string()); + let refresh = source + .load_changed_resident_context() + .await + .unwrap() + .expect("changed behavior must produce one refresh"); + assert_eq!(refresh.revision, 4); + assert!(refresh.body.contains("Ask when uncertain.")); + source.confirm_resident_context_revision(refresh.revision); + assert!( + source + .load_changed_resident_context() + .await + .unwrap() + .is_none() + ); + + *client.behavior.lock().unwrap() = (5, String::new()); + let cleared = source + .load_changed_resident_context() + .await + .unwrap() + .expect("cleared behavior must produce one refresh"); + assert_eq!(cleared.revision, 5); + assert!(cleared.body.contains("No user-managed behavior is set")); + source.confirm_resident_context_revision(cleared.revision); + assert!( + source + .load_changed_resident_context() + .await + .unwrap() + .is_none() + ); + + source.invalidate_resident_context_representation(); + let replay_after_rewrite = source + .load_changed_resident_context() + .await + .unwrap() + .expect("history rewrite must require a durable current representation"); + assert_eq!(replay_after_rewrite.revision, 5); + assert!( + replay_after_rewrite + .body + .contains("No user-managed behavior is set") + ); + // Initial load and every live update/clear/replay probe retain the + // target-side bounded resident request contract. + assert_eq!( + client.timeouts.lock().unwrap().as_slice(), + &[RESIDENT_SUMMARY_REQUEST_TIMEOUT; 7] + ); + } + #[tokio::test] async fn subject_resident_source_fails_closed_without_trusted_subject_scope() { let manifest = subject_manifest("workspace"); let client = Arc::new(SubjectResidentClient::denied()); - let source = ordinary_subjektiv_resident_summary_source(&manifest, client.clone()) - .unwrap() - .unwrap(); + let source = + ordinary_subjektiv_resident_summary_source(&manifest, client.clone(), test_prompts()) + .unwrap() + .unwrap(); - assert_eq!(source.load().await, SystemPromptContribution::Unavailable); + let SystemPromptContribution::Ready(failure) = source.load().await else { + panic!("fetch failure must be explicit in resident context"); + }; + assert!(failure.contains("could not fetch")); assert_eq!( client.paths.lock().unwrap().clone(), vec!["/api/w/workspace/subjektiv/memory".to_string()] @@ -1557,7 +1874,7 @@ permission = "write" ) .unwrap(); assert!( - ordinary_subjektiv_resident_summary_source(&disabled, test_client()) + ordinary_subjektiv_resident_summary_source(&disabled, test_client(), test_prompts()) .unwrap() .is_none() ); @@ -1569,22 +1886,30 @@ permission = "write" Some("policy-only surface must not load"), )); assert!( - ordinary_subjektiv_resident_summary_source(&policy_only, unattached_client.clone()) - .unwrap() - .is_none() + ordinary_subjektiv_resident_summary_source( + &policy_only, + unattached_client.clone(), + test_prompts() + ) + .unwrap() + .is_none() ); assert!(unattached_client.paths.lock().unwrap().is_empty()); let foreign = subject_manifest("other-workspace"); - assert!(ordinary_subjektiv_resident_summary_source(&foreign, test_client()).is_err()); + assert!( + ordinary_subjektiv_resident_summary_source(&foreign, test_client(), test_prompts()) + .is_err() + ); } #[test] fn subject_restore_refresh_is_suppressed_for_internal_hosts() { let manifest = subject_manifest("workspace"); - let source = ordinary_subjektiv_resident_summary_source(&manifest, test_client()) - .unwrap() - .unwrap(); + let source = + ordinary_subjektiv_resident_summary_source(&manifest, test_client(), test_prompts()) + .unwrap() + .unwrap(); let prompts = Arc::new(ArcSwap::from( crate::prompt::catalog::PromptCatalog::builtins_only().unwrap(), )); @@ -1598,9 +1923,10 @@ permission = "write" #[test] fn subject_restore_refresh_feature_declares_only_restore_lifecycle() { let manifest = subject_manifest("workspace"); - let source = ordinary_subjektiv_resident_summary_source(&manifest, test_client()) - .unwrap() - .unwrap(); + let source = + ordinary_subjektiv_resident_summary_source(&manifest, test_client(), test_prompts()) + .unwrap() + .unwrap(); let feature = SubjektivResidentRestoreRefreshFeature::new( source, Arc::new(ArcSwap::from( @@ -1611,8 +1937,13 @@ permission = "write" let descriptor = feature.descriptor(); assert!(descriptor.tools.is_empty()); - assert_eq!(descriptor.hooks.len(), 1); + assert_eq!(descriptor.hooks.len(), 3); assert_eq!(descriptor.hooks[0].point, FeatureHookPoint::WorkerRestored); + assert_eq!(descriptor.hooks[1].point, FeatureHookPoint::PreLlmRequest); + assert_eq!( + descriptor.hooks[2].point, + FeatureHookPoint::BeforeSessionRewrite + ); } #[test] 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/hook.rs b/crates/worker/src/hook.rs index 6e0445d87..82f93d469 100644 --- a/crates/worker/src/hook.rs +++ b/crates/worker/src/hook.rs @@ -281,6 +281,20 @@ impl SystemItemAppendHandle { .expect("system-item append queue poisoned") .push(item); } + + /// Queue a revisioned Subject behavior refresh. The caller receives this + /// narrow operation only at a pre-request boundary, and the host commits it + /// before exposing it to the model. + pub fn append_subject_behavior_refresh(&self, body: impl Into, behavior_revision: u64) { + self.pending + .lock() + .expect("system-item append queue poisoned") + .push(SystemItem::SubjectBehaviorRefresh { + body: body.into(), + behavior_revision, + prompt_provenance: None, + }); + } } /// Host-created narrow append authority exposed only at a restore lifecycle diff --git a/crates/worker/src/image_recovery_tests.rs b/crates/worker/src/image_recovery_tests.rs new file mode 100644 index 000000000..2c75374b2 --- /dev/null +++ b/crates/worker/src/image_recovery_tests.rs @@ -0,0 +1,221 @@ +//! Recovery tests use the normal Worker execution and append-log commit gate. +use super::*; +use agen::llm_client::event::{Event as LlmEvent, ResponseStatus, StatusEvent}; +use agen::llm_client::{ClientError, Request, ResponseStream}; +use agen::tool::{Attachment, ImageAttachment}; +use async_trait::async_trait; + +#[derive(Clone, Default)] +struct RejectImages { + requests: Arc>>, + cancel_on_retry: Arc>>>, +} +#[async_trait] +impl LlmClient for RejectImages { + fn clone_boxed(&self) -> Box { + Box::new(self.clone()) + } + async fn stream(&self, request: Request) -> Result { + let has_image = request.items.iter().any( + |item| matches!(item, Item::ToolResult { attachments, .. } if !attachments.is_empty()), + ); + self.requests.lock().unwrap().push(request); + if has_image { + return Err(ClientError::Api { + status: Some(400), + code: Some("invalid_value".into()), + message: "The image requires 52150 patches, exceeding the limit of 30000.".into(), + retry_after: None, + }); + } + if let Some(cancel) = self.cancel_on_retry.lock().unwrap().take() { + cancel.try_send(()).unwrap(); + return Ok(Box::pin(futures::stream::pending())); + } + Ok(Box::pin(futures::stream::iter(vec![ + Ok(LlmEvent::text_block_start(0)), + Ok(LlmEvent::text_delta(0, "Resize the source image")), + Ok(LlmEvent::text_block_stop(0, None)), + Ok(LlmEvent::Status(StatusEvent { + status: ResponseStatus::Completed, + })), + ]))) + } +} + +async fn image_worker() -> ( + tempfile::TempDir, + Worker, + RejectImages, +) { + let dir = tempfile::tempdir().unwrap(); + let store = session_store::FsStore::new(dir.path().join("session")).unwrap(); + let manifest = WorkerManifest::from_toml( + r#" +[worker] +name = "image-recovery-test" +[model] +scheme = "anthropic" +model_id = "test" +[engine] +[scope] +allow = [] +"#, + ) + .unwrap(); + let client = RejectImages::default(); + let mut worker = Worker::new( + manifest, + Engine::<_, Mutable, SessionHistoryMetadata>::new_annotated(client.clone()), + store, + WorkerWorkspaceContext::local_filesystem(None), + WorkerFilesystemAuthority::None, + Scope::writable(dir.path()).unwrap(), + ) + .await + .unwrap(); + worker.disable_manifest_lifecycle_features(); + worker.set_history_for_test(vec![ + Item::user_message("inspect this image"), + Item::tool_call("image-call", "ViewImage", "{}"), + Item::tool_result_item_with_disposition_and_attachments( + "image-call", + "Attached image", + None, + agen::ToolResultDisposition::Success, + vec![Attachment::Image(ImageAttachment::new( + "image/png", + vec![1_u8, 2, 3], + ))], + ), + ]); + worker.wire_history_persistence(); + (dir, worker, client) +} +fn rejected(item: &Item) -> bool { + matches!(item, Item::ToolResult { call_id, is_error: true, attachments, .. } if call_id == "image-call" && attachments.is_empty()) +} + +#[tokio::test] +async fn image_correction_is_committed_before_retry_and_survives_restore_and_reseed() { + let (_dir, mut worker, client) = image_worker().await; + assert_eq!( + worker.run_text("continue").await.unwrap(), + WorkerRunResult::Finished + ); + assert_eq!(client.requests.lock().unwrap().len(), 2); + let raw = worker + .store + .read_all(worker.session_id(), worker.segment_id()) + .unwrap(); + let correction = raw + .iter() + .find(|entry| matches!(entry, LogEntry::ToolResultCorrected { .. })) + .unwrap() + .clone(); + let restored = restore_history_entries(worker.session_id(), worker.segment_id(), &raw).unwrap(); + assert_eq!( + restored + .iter() + .filter(|entry| rejected(&entry.item)) + .count(), + 1 + ); + assert_eq!( + session_store::collect_state(&raw) + .history + .iter() + .filter(|item| rejected(item)) + .count(), + 1 + ); + let LogEntry::ToolResultCorrected { + entry: corrected, .. + } = &correction + else { + unreachable!() + }; + let original = match &raw[0] { + LogEntry::AnnotatedSegmentStart { history, .. } => history + .iter() + .find(|entry| entry.metadata.entry_id == corrected.metadata.entry_id) + .unwrap(), + _ => panic!("missing seed"), + }; + assert!( + matches!(&original.item, session_store::LoggedItem::ToolResult { attachments, is_error: false, .. } if attachments.len() == 1) + ); + assert_eq!( + restored + .iter() + .find(|entry| rejected(&entry.item)) + .unwrap() + .annotation, + original.metadata + ); + let snapshot = session_store::public_snapshot::project_current_session_snapshot(&raw); + let projected = snapshot + .entries + .iter() + .find(|entry| entry.entry_id == corrected.metadata.entry_id.0) + .unwrap(); + assert!( + matches!(&projected.data, protocol::SessionSnapshotEntryData::ToolResult { is_error: true, attachments, .. } if attachments.is_empty()) + ); + assert!(matches!( + crate::ipc::protocol_session::live_log_entry_event(correction), + Some(Event::SessionEntryCommitted { .. }) + )); + + // Fork/compact seed writers serialize the effective annotated history. + let seed = LogEntry::AnnotatedSegmentStart { + ts: 1, + session_id: worker.session_id(), + system_prompt: None, + config: agen::llm_client::RequestConfig::default(), + history: restored.iter().map(to_logged_history_entry).collect(), + forked_from: None, + compacted_from: None, + }; + let restored_again = + restore_history_entries(worker.session_id(), worker.segment_id(), &[seed]).unwrap(); + assert_eq!( + restored_again + .iter() + .filter(|entry| rejected(&entry.item)) + .count(), + 1 + ); + let mut history = History::from_entries(restored_again); + let out = Engine::<_, Mutable, SessionHistoryMetadata>::new_annotated(client.clone()) + .run_with_annotation(&mut history, "continue after restart", &mut |_| { + Ok(SessionHistoryMetadata::legacy_unknown()) + }) + .await; + assert!(matches!(out.result, EngineRunExit::Finished)); + assert_eq!(client.requests.lock().unwrap().len(), 3); +} + +#[tokio::test] +async fn cancellation_after_image_correction_does_not_rollback_the_correction() { + let (_dir, mut worker, client) = image_worker().await; + *client.cancel_on_retry.lock().unwrap() = Some(worker.engine().cancel_sender()); + let result = worker.run_text("continue").await.unwrap(); + assert_eq!(result, WorkerRunResult::Cancelled); + let raw = worker + .store + .read_all(worker.session_id(), worker.segment_id()) + .unwrap(); + assert!( + raw.iter() + .any(|entry| matches!(entry, LogEntry::ToolResultCorrected { .. })) + ); + let restored = restore_history_entries(worker.session_id(), worker.segment_id(), &raw).unwrap(); + assert_eq!( + restored + .iter() + .filter(|entry| rejected(&entry.item)) + .count(), + 1 + ); +} diff --git a/crates/worker/src/ipc/interceptor.rs b/crates/worker/src/ipc/interceptor.rs index e37a30aa3..96dd186f8 100644 --- a/crates/worker/src/ipc/interceptor.rs +++ b/crates/worker/src/ipc/interceptor.rs @@ -38,7 +38,9 @@ use crate::hook::{ use crate::ipc::notify_buffer::{NotifyBuffer, build_system_item_with_provenance}; use crate::prompt::catalog::PromptCatalog; use crate::session_history::SessionHistoryMetadata; -use crate::worker::SystemItemCommitter; +use crate::worker::{ + DurableNotificationCommitter, SystemItemCommitter, SystemPromptContributionSource, +}; use agen::HistoryEntry; use agen::token_counter::total_tokens; @@ -109,6 +111,8 @@ pub(crate) struct WorkerInterceptor { /// worker. `None` in tests / `Worker::new` paths where no writer is /// attached. log_writer: Option>, + notification_committer: Option>, + resident_context_source: Option>, pending_committed_history: Arc>>>, /// Next turn index assigned by `on_prompt_submit`. next_turn_index: AtomicUsize, @@ -163,12 +167,22 @@ impl WorkerInterceptor { prompts, prompt_workspace_id: None, log_writer, + notification_committer: None, + resident_context_source: None, pending_committed_history, next_turn_index: AtomicUsize::new(0), tool_calls_this_turn: AtomicUsize::new(0), } } + pub(crate) fn with_notification_committer( + mut self, + committer: Option>, + ) -> Self { + self.notification_committer = committer; + self + } + pub(crate) fn with_usage_tracker(mut self, usage_tracker: Arc) -> Self { self.usage_tracker = Some(usage_tracker); self @@ -179,11 +193,31 @@ impl WorkerInterceptor { self } + pub(crate) fn with_resident_context_source( + mut self, + source: Option>, + ) -> Self { + self.resident_context_source = source; + self + } + pub(crate) fn with_prompt_workspace_id(mut self, workspace_id: Option) -> Self { self.prompt_workspace_id = workspace_id; self } + fn confirm_resident_context_item(&self, item: &SystemItem) { + let Some(source) = self.resident_context_source.as_ref() else { + return; + }; + if let SystemItem::SubjectBehaviorRefresh { + behavior_revision, .. + } = item + { + source.confirm_resident_context_revision(*behavior_revision); + } + } + /// Commit each `SystemItem` as its own `LogEntry::AnnotatedSystemItem` /// entry through the attached writer (no-op when no writer is /// wired). Sync — writes complete before the matching @@ -211,6 +245,7 @@ impl WorkerInterceptor { .lock() .expect("pending committed history poisoned") .push_back(entry); + self.confirm_resident_context_item(item); } Ok(()) } @@ -350,6 +385,11 @@ impl WorkerInterceptor { } if prompt_provenance.is_none() => { *prompt_provenance = Some(provenance("internal.task_reminder")); } + SystemItem::SubjectBehaviorRefresh { + prompt_provenance, .. + } if prompt_provenance.is_none() => { + *prompt_provenance = Some(provenance("internal.subjektiv_resident_context")); + } SystemItem::Interrupt { prompt_provenance, .. } if prompt_provenance.is_none() => { @@ -452,19 +492,10 @@ impl Interceptor for WorkerInterceptor { projection_digest: projection.catalog_digest.clone(), logical_name: "internal.notify_wrapper".to_string(), }; - let mut system_items: Vec<( - SystemItem, - Vec, - Option, - )> = Vec::with_capacity(drained.len()); - let mut items: Vec = Vec::with_capacity(drained.len()); + let mut system_items = Vec::with_capacity(drained.len()); for entry in &drained { - let system_item = match build_system_item_with_provenance( - entry, - &prompts, - Some(provenance.clone()), - ) { - Ok(system_item) => system_item, + match build_system_item_with_provenance(entry, &prompts, Some(provenance.clone())) { + Ok(item) => system_items.push(item), Err(error) => { self.pending_notifies.requeue_front(drained); return Err(InterceptorError::new( @@ -472,16 +503,50 @@ impl Interceptor for WorkerInterceptor { format!("failed to render notify_wrapper: {error}"), )); } - }; - items.push(system_item.to_history_item()); - system_items.push((system_item, entry.extensions(), entry.history_provenance())); + } } - if let Err(error) = self.commit_system_items_with_extensions(&system_items) { - self.pending_notifies.requeue_front(drained); - return Err(InterceptorError::new( - InterceptorErrorCategory::Dependency, - format!("session persistence failed: {error}"), - )); + let mut items = Vec::with_capacity(drained.len()); + let mut remaining = drained.into_iter(); + for item in system_items { + let entry = remaining.next().expect("rendered notification must exist"); + let committed = if let Some(identity) = entry.notification_commit() { + match self.notification_committer.as_ref() { + Some(committer) => { + committer.commit_notification(identity, item, entry.history_provenance()) + } + None => Err(session_store::StoreError::Io(std::io::Error::other( + "durable notification commit authority is missing", + ))), + } + } else { + self.commit_system_items_with_extensions(&[( + item.clone(), + Vec::new(), + entry.history_provenance(), + )]) + .map(|()| { + items.push(item.to_history_item()); + None + }) + }; + match committed { + Ok(Some(history)) => { + items.push(history.item.clone()); + self.pending_committed_history + .lock() + .expect("pending committed history poisoned") + .push_back(history); + } + Ok(None) => {} + Err(error) => { + self.pending_notifies + .requeue_front(std::iter::once(entry).chain(remaining).collect()); + return Err(InterceptorError::new( + InterceptorErrorCategory::Dependency, + format!("session persistence failed: {error}"), + )); + } + } } Ok(items) } @@ -781,6 +846,7 @@ mod tests { HookTurnEndAction, OnTurnEnd, PostToolCall, PreLlmRequest, PreToolCall, }; use crate::session_history::{WorkerHistoryProvenance, history_entry}; + use crate::worker::SystemPromptContribution; fn test_prompts() -> Arc> { Arc::new(ArcSwap::from(PromptCatalog::builtins_only().unwrap())) @@ -877,6 +943,53 @@ mod tests { } } + struct AppendingSubjectBehaviorHook; + + #[async_trait] + impl Hook for AppendingSubjectBehaviorHook { + async fn call( + &self, + input: &PreRequestContext, + ) -> Result { + input + .system_items() + .expect("test interceptor has durable append authority") + .append_subject_behavior_refresh("current Subject context", 7); + Ok(HookPreRequestAction::Continue) + } + } + + struct ConfirmRecordingResidentSource { + confirmed: Arc>>, + } + + #[async_trait] + impl SystemPromptContributionSource for ConfirmRecordingResidentSource { + async fn load(&self) -> SystemPromptContribution { + SystemPromptContribution::Unavailable + } + + fn confirm_resident_context_revision(&self, revision: u64) { + self.confirmed.lock().unwrap().push(revision); + } + } + + struct FailingSystemItemCommitter; + + impl SystemItemCommitter for FailingSystemItemCommitter { + fn commit_log_entry( + &self, + entry: session_store::LogEntry, + ) -> Result<(), session_store::StoreError> { + if matches!(entry, session_store::LogEntry::AnnotatedSystemItem { .. }) { + return Err(session_store::StoreError::Io(std::io::Error::other( + "synthetic system-item failure", + ))); + } + Ok(()) + } + } + fn task_tool_call_info( name: &str, input: serde_json::Value, @@ -1364,6 +1477,88 @@ mod tests { } } + #[tokio::test] + async fn subject_behavior_revision_is_confirmed_only_after_durable_commit() { + let mut builder = HookRegistryBuilder::new(); + builder.add_pre_llm_request(AppendingSubjectBehaviorHook); + let committed = Arc::new(Mutex::new(Vec::new())); + let confirmed = Arc::new(Mutex::new(Vec::new())); + let source: Arc = + Arc::new(ConfirmRecordingResidentSource { + confirmed: Arc::clone(&confirmed), + }); + let interceptor = WorkerInterceptor::new( + Arc::new(builder.build()), + None, + None, + NotifyBuffer::new(), + Arc::new(Mutex::new(Vec::new())), + test_prompts(), + Some(Arc::new(RecordingSystemItemCommitter { + committed: Arc::clone(&committed), + })), + ) + .with_resident_context_source(Some(source)); + + let mut ctx = Vec::new(); + let action = interceptor + .pre_llm_request(PreLlmRequestContext { + invocation: Default::default(), + items: &mut ctx, + history: &[], + }) + .await + .unwrap(); + + assert!(matches!(action, PreRequestAction::ContinueWith(_))); + assert_eq!(*confirmed.lock().unwrap(), [7]); + assert!(matches!( + committed.lock().unwrap().as_slice(), + [SystemItem::SubjectBehaviorRefresh { + behavior_revision: 7, + prompt_provenance: Some(_), + .. + }] + )); + } + + #[tokio::test] + async fn failed_subject_behavior_persistence_does_not_confirm_revision() { + let mut builder = HookRegistryBuilder::new(); + builder.add_pre_llm_request(AppendingSubjectBehaviorHook); + let confirmed = Arc::new(Mutex::new(Vec::new())); + let source: Arc = + Arc::new(ConfirmRecordingResidentSource { + confirmed: Arc::clone(&confirmed), + }); + let interceptor = WorkerInterceptor::new( + Arc::new(builder.build()), + None, + None, + NotifyBuffer::new(), + Arc::new(Mutex::new(Vec::new())), + test_prompts(), + Some(Arc::new(FailingSystemItemCommitter)), + ) + .with_resident_context_source(Some(source)); + + let mut ctx = Vec::new(); + let action = interceptor + .pre_llm_request(PreLlmRequestContext { + invocation: Default::default(), + items: &mut ctx, + history: &[], + }) + .await + .unwrap(); + + assert!(matches!( + action, + PreRequestAction::Cancel(reason) if reason.contains("session persistence failed") + )); + assert!(confirmed.lock().unwrap().is_empty()); + } + #[tokio::test] async fn pre_llm_request_without_log_writer_does_not_expose_system_item_handle() { let saw_handle = Arc::new(AtomicBool::new(false)); diff --git a/crates/worker/src/ipc/notify_buffer.rs b/crates/worker/src/ipc/notify_buffer.rs index 699a778cf..6af0e75cb 100644 --- a/crates/worker/src/ipc/notify_buffer.rs +++ b/crates/worker/src/ipc/notify_buffer.rs @@ -24,8 +24,9 @@ use std::collections::VecDeque; use std::sync::{Arc, Mutex}; +use crate::worker::NotificationCommitIdentity; use protocol::WorkerEvent; -use session_store::{LoggedSessionHistoryOrigin, SessionExtension, SystemItem}; +use session_store::{LoggedSessionHistoryOrigin, SystemItem}; use crate::prompt::catalog::{CatalogError, PromptCatalog}; @@ -39,7 +40,7 @@ use crate::prompt::catalog::{CatalogError, PromptCatalog}; pub enum PendingNotify { Notify { message: String, - extensions: Vec, + notification_commit: Option, history_provenance: Option, }, WorkerEvent { @@ -48,10 +49,13 @@ pub enum PendingNotify { } impl PendingNotify { - pub(crate) fn extensions(&self) -> Vec { + pub(crate) fn notification_commit(&self) -> Option<&NotificationCommitIdentity> { match self { - PendingNotify::Notify { extensions, .. } => extensions.clone(), - PendingNotify::WorkerEvent { .. } => Vec::new(), + PendingNotify::Notify { + notification_commit, + .. + } => notification_commit.as_ref(), + PendingNotify::WorkerEvent { .. } => None, } } @@ -85,7 +89,7 @@ impl NotifyBuffer { pub fn push_notify(&self, message: String) { self.push_entry(PendingNotify::Notify { message, - extensions: Vec::new(), + notification_commit: None, history_provenance: None, }); } @@ -94,11 +98,11 @@ impl NotifyBuffer { &self, message: String, history_provenance: LoggedSessionHistoryOrigin, - extension: SessionExtension, + identity: NotificationCommitIdentity, ) { self.push_entry(PendingNotify::Notify { message, - extensions: vec![extension], + notification_commit: Some(identity), history_provenance: Some(history_provenance), }); } @@ -233,7 +237,7 @@ mod tests { fn build_system_item_for_notify_carries_wrapper_body() { let entry = PendingNotify::Notify { message: "hello".into(), - extensions: Vec::new(), + notification_commit: None, history_provenance: None, }; let catalog = PromptCatalog::builtins_only().unwrap(); diff --git a/crates/worker/src/ipc/protocol_session.rs b/crates/worker/src/ipc/protocol_session.rs index 85769210f..718b03aea 100644 --- a/crates/worker/src/ipc/protocol_session.rs +++ b/crates/worker/src/ipc/protocol_session.rs @@ -76,6 +76,7 @@ pub fn live_log_entry_event(entry: LogEntry) -> Option { } entry @ (LogEntry::AnnotatedAssistantItem { .. } | LogEntry::AnnotatedToolResult { .. } + | LogEntry::ToolResultCorrected { .. } | LogEntry::RunYielded { .. } | LogEntry::RunResumed { .. } | LogEntry::RunCancelled { .. } diff --git a/crates/worker/src/prompt/catalog.rs b/crates/worker/src/prompt/catalog.rs index f8c52cc47..9b3e30459 100644 --- a/crates/worker/src/prompt/catalog.rs +++ b/crates/worker/src/prompt/catalog.rs @@ -105,6 +105,7 @@ pub enum WorkerPrompt { AgentsMdSection, ResidentMemorySummarySection, ResidentMemoryRestoreSection, + SubjektivResidentContext, WorkerOrchestrationGuidanceSection, SubWorkerSpawnToolDescription, } @@ -128,6 +129,7 @@ impl WorkerPrompt { Self::AgentsMdSection => "internal.agents_md_section", Self::ResidentMemorySummarySection => "internal.resident_memory_summary_section", Self::ResidentMemoryRestoreSection => "internal.resident_memory_restore_section", + Self::SubjektivResidentContext => "internal.subjektiv_resident_context", Self::WorkerOrchestrationGuidanceSection => { "internal.worker_orchestration_guidance_section" } @@ -150,6 +152,7 @@ impl WorkerPrompt { WorkerPrompt::AgentsMdSection, WorkerPrompt::ResidentMemorySummarySection, WorkerPrompt::ResidentMemoryRestoreSection, + WorkerPrompt::SubjektivResidentContext, WorkerPrompt::WorkerOrchestrationGuidanceSection, WorkerPrompt::SubWorkerSpawnToolDescription, ]; @@ -518,6 +521,21 @@ impl PromptCatalog { }, ) } + pub fn subjektiv_resident_context( + &self, + behavior_md: &str, + surface_availability: &str, + surface_body: Option<&str>, + ) -> Result { + self.render( + WorkerPrompt::SubjektivResidentContext, + minijinja::context! { + behavior_md => behavior_md, + surface_availability => surface_availability, + surface_body => surface_body.unwrap_or_default(), + }, + ) + } pub fn worker_orchestration_guidance_section(&self) -> Result { self.render( WorkerPrompt::WorkerOrchestrationGuidanceSection, diff --git a/crates/worker/src/segment_log_sink.rs b/crates/worker/src/segment_log_sink.rs index 55da19660..93d0b0c73 100644 --- a/crates/worker/src/segment_log_sink.rs +++ b/crates/worker/src/segment_log_sink.rs @@ -121,6 +121,7 @@ impl SegmentLogSink { | LogEntry::AnnotatedUserInput { .. } | LogEntry::AnnotatedAssistantItem { .. } | LogEntry::AnnotatedToolResult { .. } + | LogEntry::ToolResultCorrected { .. } | LogEntry::AnnotatedSystemItem { .. } | LogEntry::Invoke { .. } | LogEntry::RunYielded { .. } diff --git a/crates/worker/src/session_history.rs b/crates/worker/src/session_history.rs index 420429185..5a5f60d8d 100644 --- a/crates/worker/src/session_history.rs +++ b/crates/worker/src/session_history.rs @@ -116,6 +116,17 @@ pub(crate) fn restore_history_entries( } LogEntry::AnnotatedAssistantItem { entry, .. } | LogEntry::AnnotatedToolResult { entry, .. } => history.push(from_logged(entry)), + LogEntry::ToolResultCorrected { entry, .. } => { + let target = history + .iter_mut() + .find(|existing| existing.annotation.entry_id == entry.metadata.entry_id) + .ok_or_else(|| "tool result correction target is missing".to_string())?; + if !matches!((&target.item, &entry.item), (Item::ToolResult { call_id, .. }, session_store::LoggedItem::ToolResult { call_id: replacement, .. }) if call_id == replacement) + { + return Err("tool result correction changed call identity".to_string()); + } + *target = from_logged(entry); + } LogEntry::AnnotatedSystemItem { entry, .. } => history.push(HistoryEntry::new( entry.item.to_history_item(), entry.metadata.clone(), diff --git a/crates/worker/src/wip.rs b/crates/worker/src/wip.rs index a489cf36e..46c06c438 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 }, } @@ -113,6 +121,28 @@ pub enum WipOperationError { /// routing and validators are resolved before this trait is called. #[async_trait] pub trait WipOperationHandler: Send + Sync { + /// Whether the owning provider currently exposes this Object to its subject. + fn is_visible(&self) -> bool { + true + } + + /// Override the Object's registered validator with current provider state. + /// None preserves the registered validator and requires no inventory fetch. + fn object_validator(&self) -> Option> { + None + } + + /// Current target capability and subject permission intersection. Providers + /// must still enforce authority at execution; this hook controls publication. + fn operation_available(&self, _operation: &str) -> bool { + true + } + + /// Opt into path-qualified, freshly resolved interface descriptors. + fn contextual_interface(&self) -> bool { + false + } + async fn call( &self, operation: &str, @@ -123,6 +153,16 @@ pub trait WipOperationHandler: Send + Sync { async fn cancel(&self, _context: &ToolExecutionContext) -> Result<(), ToolError> { Ok(()) } + + /// Cancel the selected operation on this exact handler instance. Providers + /// that share a cancellation boundary may keep implementing `cancel`. + async fn cancel_operation( + &self, + _operation: &str, + context: &ToolExecutionContext, + ) -> Result<(), ToolError> { + self.cancel(context).await + } } /// One route contribution. Native projections replace compatibility projections @@ -139,9 +179,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, @@ -149,6 +201,12 @@ pub struct WipDynamicItem { pub trait WipDynamicItemResolver: Send + Sync { fn resolve(&self, item_reference: &str) -> Option; + + /// Family-level opt-in, needed to deny unqualified descriptor fetches before + /// resolving any item. A contextual collection owner also opts in its family. + fn contextual_interface(&self) -> bool { + false + } } pub struct WipDynamicMount { @@ -160,43 +218,117 @@ 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 { + owner: Arc::clone(&self.projection.handler), + handlers: self + .operation_handlers + .iter() + .map(|(operation, (_, handler))| (operation.clone(), Arc::clone(handler))) + .collect(), + }); + projection + } +} + +struct OperationDispatchHandler { + owner: Arc, + handlers: BTreeMap>, +} + +#[async_trait] +impl WipOperationHandler for OperationDispatchHandler { + fn is_visible(&self) -> bool { + self.owner.is_visible() + } + + fn object_validator(&self) -> Option> { + self.owner.object_validator() + } + + fn operation_available(&self, operation: &str) -> bool { + self.handlers + .get(operation) + .is_some_and(|handler| handler.operation_available(operation)) + } + + fn contextual_interface(&self) -> bool { + self.owner.contextual_interface() + || self + .handlers + .values() + .any(|handler| handler.contextual_interface()) + } + + 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_operation( + &self, + operation: &str, + context: &ToolExecutionContext, + ) -> Result<(), ToolError> { + let handler = self.handlers.get(operation).ok_or_else(|| { + ToolError::InvalidArgument("WIP cancellation operation is not published".into()) + })?; + handler.cancel_operation(operation, context).await + } } #[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 +337,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 +395,35 @@ impl WipMountRegistry { projection: WipProjection, ) -> Result { validate_projection(&projection)?; + if projection.kind == WipProjectionKind::Compatibility && !is_tool_route(&projection.route) + { + return Err(WipMountError::InvalidProjection { + route: projection.route, + message: "compatibility Objects must use /tools/".into(), + }); + } + if projection.kind == WipProjectionKind::Native && !is_tool_route(&projection.route) { + 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 +444,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 +468,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. @@ -299,6 +583,13 @@ impl WipMountRegistry { message: "dynamic item mount requires a native collection projection".into(), }); } + if mount.capability.split(':').next() != collection.projection.capability.split(':').next() + { + return Err(WipMountError::InvalidProjection { + route: mount.collection_route, + message: "dynamic Object capability must belong to the collection owner".into(), + }); + } mount .descriptor .validate() @@ -344,6 +635,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 +846,71 @@ 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 contextual_reference(base: &str, path: &str) -> String { + let encoded: String = path + .as_bytes() + .iter() + .map(|byte| format!("{byte:02x}")) + .collect(); + format!("{base}/@/{encoded}") +} + +fn decode_contextual_path(encoded: &str) -> Option { + if encoded.len() % 2 != 0 { + return None; + } + let bytes = encoded + .as_bytes() + .chunks_exact(2) + .map(|pair| { + let digit = |byte| match byte { + b'0'..=b'9' => Some(byte - b'0'), + b'a'..=b'f' => Some(byte - b'a' + 10), + _ => None, + }; + Some(digit(pair[0])? * 16 + digit(pair[1])?) + }) + .collect::>>()?; + let path = String::from_utf8(bytes).ok()?; + wip_protocol::validate_path(&path).ok()?; + Some(path) +} + +fn is_tool_route(route: &str) -> bool { + route + .strip_prefix("/tools/") + .is_some_and(|name| !name.is_empty() && !name.contains('/')) +} + fn validate_projection(projection: &WipProjection) -> Result<(), WipMountError> { wip_protocol::validate_path(&projection.route).map_err(|error| { WipMountError::InvalidRoute { @@ -486,36 +964,118 @@ impl WipHost { } } - fn descriptor(&self, reference: &str) -> Option<(&InterfaceDescriptor, Option<&[u8]>)> { - self.registry - .mounts - .values() - .find(|mounted| mounted.projection.interface == reference) - .map(|mounted| { - ( - &mounted.projection.descriptor, - 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 descriptor(&self, reference: &str) -> Option<(InterfaceDescriptor, Option>)> { + for (path, mounted) in &self.registry.mounts { + if mounted.projection.interface == reference { + let Some(projection) = self.projection(path) else { + continue; + }; + if projection.interface == reference { + return Some((projection.descriptor, projection.interface_validator)); + } + } + } + if let Some(descriptor) = self.registry.dynamic_mounts.iter().find_map(|mounted| { + if mounted.interface != reference || self.dynamic_contextual(mounted) { + return None; + } + // Legacy families retain their shared descriptor. Contextual + // families must always resolve a qualified item reference below. + Some(( + mounted.descriptor.clone(), + mounted.interface_validator.clone(), + )) + }) { + return Some(descriptor); + } + let (_, encoded_path) = reference.rsplit_once("/@/")?; + let path = decode_contextual_path(encoded_path)?; + let projection = self.projection(&path)?; + // Re-resolve the exact current Object, not a cached descriptor or an + // arbitrary base interface with an appended path. Exact legacy refs + // above remain opaque even if they contain our contextual delimiter. + if projection.interface != reference { + return None; + } + Some((projection.descriptor, projection.interface_validator)) + } + + fn dynamic_contextual(&self, mounted: &WipDynamicMount) -> bool { + mounted.resolver.contextual_interface() + || self.registry.mounts[&mounted.collection_route] + .resolved() + .handler + .contextual_interface() + } + + fn project_current( + &self, + mut projection: WipProjection, + contextual_family: bool, + ) -> Option { + if !projection.handler.is_visible() { + return None; + } + if let Some(validator) = projection.handler.object_validator() { + projection.object.validator = Some(validator); + } + let contextual = contextual_family || projection.handler.contextual_interface(); + let original_len = projection.descriptor.operations.len(); + projection + .descriptor + .operations + .retain(|operation| projection.handler.operation_available(&operation.name)); + if contextual || projection.descriptor.operations.len() != original_len { + let mut digest = Sha256::new(); + digest.update(descriptor_validator(&projection.descriptor)); + // Keep an explicit provider version authoritative even when the + // filtered shape is unchanged. + if let Some(validator) = &projection.interface_validator { + digest.update(validator); + } + projection.interface_validator = Some(digest.finalize().to_vec()); + } + if contextual { + projection.interface = contextual_reference(&projection.interface, &projection.route); + projection.object.interfaces = vec![projection.interface.clone()]; + } + Some(projection) } fn projection(&self, path: &str) -> Option { if let Some(mounted) = self.registry.mounts.get(path) { - return Some(mounted.projection.clone()); + return self.project_current(mounted.resolved(), false); } 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; } + // Collection listing authority is independent of item read authority. + // The resolved item's owner gates its own visibility below. 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,14 +1084,32 @@ impl WipHost { interface: mounted.interface.clone(), descriptor: mounted.descriptor.clone(), interface_validator: mounted.interface_validator.clone(), - handler: item.handler, + handler: Arc::new(OperationDispatchHandler { + owner: item.handler, + handlers, + }), }; validate_projection(&projection).ok()?; - Some(projection) + self.project_current(projection, self.dynamic_contextual(mounted)) }) } fn object_at(&self, path: &str) -> Option { + // An explicit Object can also have static descendants. Do not replace + // its identity, interfaces, or validator with a synthetic namespace. + if let Some(projection) = self.projection(path) { + return Some(projection.object); + } + if self.registry.mounts.contains_key(path) + || self + .registry + .dynamic_mounts + .iter() + .any(|mount| path.starts_with(&format!("{}/", mount.collection_route))) + { + // A denied native Object must not reappear as a synthetic namespace. + return None; + } if path == WIP_ROOT || self.is_namespace(path) { let name = if path == WIP_ROOT { String::new() @@ -546,8 +1124,7 @@ impl WipHost { validator: Some(self.generation.clone()), }); } - self.projection(path) - .map(|projection| projection.object.clone()) + None } fn is_namespace(&self, path: &str) -> bool { @@ -559,7 +1136,7 @@ impl WipHost { self.registry .mounts .keys() - .any(|route| route.starts_with(&prefix)) + .any(|route| route.starts_with(&prefix) && self.projection(route).is_some()) } fn children(&self, path: &str) -> Vec { @@ -584,15 +1161,17 @@ impl WipHost { } else { format!("{path}/{segment}") }; - children.insert(child); + if self.object_at(&child).is_some() { + children.insert(child); + } } children.into_iter().collect() } 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 { @@ -614,22 +1193,29 @@ impl WipHost { })?; Ok(FetchInterfaceResponse { interface: reference.to_string(), - descriptor: descriptor.clone(), - validator: validator.map(<[u8]>::to_vec), + descriptor, + validator, }) } + #[cfg(test)] async fn call( &self, request: CallOperationRequest, 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)) })?; + self.call_projection(projection, request, context).await + } + + async fn call_projection( + &self, + projection: WipProjection, + request: CallOperationRequest, + context: WipCallContext, + ) -> Result { let expected_object_validator = projection.object.validator.as_deref(); match ( expected_object_validator, @@ -698,13 +1284,15 @@ impl WipHost { .call(&request.operation, &request.arguments, context) .await } +} - async fn cancel(&self, path: &str, context: &ToolExecutionContext) -> Result<(), ToolError> { - let projection = self.projection(path).ok_or_else(|| { - ToolError::InvalidArgument("WIP cancellation target is no longer published".into()) - })?; - projection.handler.cancel(context).await - } +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 { @@ -953,6 +1541,12 @@ struct ClientState { session: SessionId, } +#[derive(Clone)] +struct ActiveOperation { + operation: String, + handler: Arc, +} + pub struct WipRuntime { endpoint: Endpoint, wire_limits: Limits, @@ -960,7 +1554,7 @@ pub struct WipRuntime { security_context: SecurityContext, state: Arc>, host: Arc, - active: Arc>>, + active: Arc>>, audit: Arc>>, metrics: Arc, } @@ -1139,9 +1733,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 = { @@ -1163,10 +1756,6 @@ impl WipRuntime { .operation_round_trips .fetch_add(1, Ordering::Relaxed); let execution_id = execution.execution_id(); - self.active - .lock() - .unwrap_or_else(|error| error.into_inner()) - .insert(execution_id.clone(), path.clone()); let mut guard = DispatchedCallGuard::new( Arc::clone(&self.state), Arc::clone(&self.active), @@ -1208,14 +1797,19 @@ impl WipRuntime { } async fn cancel(&self, execution: &ToolExecutionContext) -> Result<(), ToolError> { - let path = self + let active = self .active .lock() .unwrap_or_else(|error| error.into_inner()) .get(&execution.execution_id()) .cloned(); - match path { - Some(path) => self.host.cancel(&path, execution).await, + match active { + Some(active) => { + active + .handler + .cancel_operation(&active.operation, execution) + .await + } None => Ok(()), } } @@ -1311,7 +1905,29 @@ impl WipRuntime { execution, security_context: self.security_context.as_str().to_string(), }; - match self.host.call(request_value.clone(), context).await { + let result = match self.host.projection(&request_value.target.path) { + Some(projection) => { + // Pin the exact resolved instance used for dispatch. A dynamic + // resolver may return a fresh stateful handler on each lookup. + self.active + .lock() + .unwrap_or_else(|error| error.into_inner()) + .insert( + context.execution.execution_id(), + ActiveOperation { + operation: request_value.operation.clone(), + handler: Arc::clone(&projection.handler), + }, + ); + self.host + .call_projection(projection, request_value.clone(), context) + .await + } + None => Err(WipOperationError::Protocol(unpublished_path_error( + &request_value.target.path, + ))), + }; + match result { Ok(output) => { let response = encode_call_operation_response( &request_value, @@ -1394,7 +2010,7 @@ impl WipRuntime { struct DispatchedCallGuard { state: Arc>, - active: Arc>>, + active: Arc>>, request_id: RequestId, execution_id: String, armed: bool, @@ -1403,7 +2019,7 @@ struct DispatchedCallGuard { impl DispatchedCallGuard { fn new( state: Arc>, - active: Arc>>, + active: Arc>>, request_id: RequestId, execution_id: String, ) -> Self { @@ -1956,7 +2572,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 +2800,1771 @@ 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 { .. }) + )); + } + + fn asset_registry( + read: Arc, + manage: Option>, + calls: Arc, + ) -> WipMountRegistry { + let mut registry = WipMountRegistry::new(); + registry.allocate_namespace("asset", "assets").unwrap(); + let projection = contribution_projection(Arc::new(ContributionHandler { + calls: Arc::clone(&calls), + allowed: read, + })); + let mut descriptor = projection.descriptor.clone(); + descriptor.operations = vec![contributed_operation("manage")]; + registry.mount(projection).unwrap(); + if let Some(allowed) = manage { + registry + .contribute_operations(WipOperationContribution { + route: "/assets/A-1".into(), + contributor: "asset-management".into(), + interface: "test.asset/item/v1".into(), + descriptor, + handler: Arc::new(ContributionHandler { calls, allowed }), + }) + .unwrap(); + } + registry + } + + fn asset_request(projection: &WipProjection, operation: &str) -> CallOperationRequest { + CallOperationRequest { + target: wip_protocol::Target { + path: projection.route.clone(), + validator: projection.object.validator.clone(), + }, + interface: wip_protocol::InterfaceTarget { + reference: projection.interface.clone(), + validator: projection.interface_validator.clone(), + }, + operation: operation.into(), + arguments: BTreeMap::new(), + } + } + + fn direct_wip_context() -> WipCallContext { + WipCallContext { + execution: ToolExecutionContext::direct(), + security_context: "worker-a".into(), + } + } + + #[tokio::test] + async fn management_enablement_is_independent_of_read_and_operation_permissions() { + for enabled in [false, true] { + for can_read in [false, true] { + for can_manage in [false, true] { + let calls = Arc::new(AtomicUsize::new(0)); + let host = WipHost::new(asset_registry( + Arc::new(AtomicBool::new(can_read)), + enabled.then(|| Arc::new(AtomicBool::new(can_manage))), + Arc::clone(&calls), + )); + // Metadata resolution is not authorization to read domain data. + let projection = host.projection("/assets/A-1").unwrap(); + let read = host + .call(asset_request(&projection, "read"), direct_wip_context()) + .await; + if can_read { + assert!(read.is_ok()); + } else { + assert!(matches!( + read, + Err(WipOperationError::Protocol(ProtocolError { + code: ProtocolErrorCode::PermissionDenied, + .. + })) + )); + } + let manage = host + .call(asset_request(&projection, "manage"), direct_wip_context()) + .await; + if !enabled { + assert!(matches!( + manage, + Err(WipOperationError::Protocol(ProtocolError { + code: ProtocolErrorCode::OperationNotFound, + .. + })) + )); + } else if !can_manage { + assert!(matches!( + manage, + Err(WipOperationError::Protocol(ProtocolError { + code: ProtocolErrorCode::PermissionDenied, + .. + })) + )); + } else { + assert!(manage.is_ok()); + } + assert_eq!( + calls.load(Ordering::SeqCst), + usize::from(can_read) + usize::from(enabled && can_manage) + ); + } + } + } + } + + #[tokio::test] + async fn contributed_interface_invalidates_observations_and_restore_requires_discovery() { + let calls = Arc::new(AtomicUsize::new(0)); + let allowed = Arc::new(AtomicBool::new(true)); + let old_host = WipHost::new(asset_registry( + Arc::clone(&allowed), + None, + Arc::clone(&calls), + )); + let old_projection = old_host.projection("/assets/A-1").unwrap(); + let old_runtime = WipRuntime::new(old_host, SecurityContext::new("worker-a"), 0).unwrap(); + old_runtime + .discover("/assets/A-1".into(), 0, false) + .await + .unwrap(); + old_runtime + .inspect(old_projection.interface.clone(), false) + .await + .unwrap(); + let restored_host = WipHost::new(asset_registry( + Arc::clone(&allowed), + Some(Arc::clone(&allowed)), + Arc::clone(&calls), + )); + let current = restored_host.projection("/assets/A-1").unwrap(); + assert_eq!(old_projection.object, current.object); + assert_ne!( + old_projection.interface_validator, + current.interface_validator + ); + assert!(matches!( + restored_host + .call(asset_request(&old_projection, "read"), direct_wip_context()) + .await, + Err(WipOperationError::Protocol(ProtocolError { + code: ProtocolErrorCode::InterfaceValidatorMismatch, + .. + })) + )); + let restored = WipRuntime::new(restored_host, SecurityContext::new("worker-a"), 0).unwrap(); + // Same Worker security identity, fresh runtime: no inherited observations. + assert!( + restored + .call( + "/assets/A-1".into(), + current.interface.clone(), + "read".into(), + json!({}), + ToolExecutionContext::direct() + ) + .await + .is_err() + ); + assert_eq!(calls.load(Ordering::SeqCst), 0); + restored + .discover("/assets/A-1".into(), 0, false) + .await + .unwrap(); + restored + .inspect(current.interface.clone(), false) + .await + .unwrap(); + restored + .call( + "/assets/A-1".into(), + current.interface.clone(), + "manage".into(), + json!({}), + ToolExecutionContext::direct(), + ) + .await + .unwrap(); + allowed.store(false, Ordering::SeqCst); + assert!( + restored + .call( + "/assets/A-1".into(), + current.interface.clone(), + "manage".into(), + json!({}), + ToolExecutionContext::direct() + ) + .await + .unwrap_err() + .to_string() + .contains("PermissionDenied") + ); + restored.reset_observations().unwrap(); + assert!( + restored + .call( + "/assets/A-1".into(), + current.interface, + "manage".into(), + json!({}), + ToolExecutionContext::direct() + ) + .await + .is_err() + ); + assert_eq!(calls.load(Ordering::SeqCst), 1); + } + + #[test] + fn mounted_collection_keeps_identity_and_interface_with_static_children() { + let mut registry = asset_registry( + Arc::new(AtomicBool::new(true)), + None, + Arc::new(AtomicUsize::new(0)), + ); + let mut collection = contribution_projection(Arc::new(ContributionHandler { + calls: Arc::new(AtomicUsize::new(0)), + allowed: Arc::new(AtomicBool::new(true)), + })); + collection.route = "/assets".into(); + collection.object.name = "assets".into(); + collection.object.r#ref = Some("asset:collection".into()); + let expected = collection.object.clone(); + registry.mount(collection).unwrap(); + let observation = WipHost::new(registry).observe("/assets", 1).unwrap(); + assert_eq!(observation.object, expected); + assert_eq!(observation.children.unwrap()[0].object.name, "A-1"); + } + + #[test] + fn compatibility_mounts_cannot_claim_host_or_legacy_namespaces() { + for route in [ + "/features/ticket/tickets", + "/assets/A-1", + "/tools/Echo/child", + ] { + let mut projection = compatibility_projection( + meta("Echo"), + Arc::new(EchoTool { + calls: Arc::new(AtomicUsize::new(0)), + }), + None, + ) + .unwrap(); + projection.route = route.into(); + projection.object.name = route.rsplit('/').next().unwrap().into(); + let mut registry = WipMountRegistry::new(); + registry.allocate_namespace("asset", "assets").unwrap(); + assert!(matches!( + registry.mount(projection), + Err(WipMountError::InvalidProjection { .. }) + )); + assert!(registry.routes().next().is_none()); + } + } + + #[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 `/`")); + } + + #[derive(Clone)] + struct ContextualHandler { + visible: Arc, + permitted: Arc, + capable: Arc, + calls: Arc, + } + + impl ContextualHandler { + fn new(permitted: bool, capable: bool) -> Self { + Self { + visible: Arc::new(AtomicBool::new(true)), + permitted: Arc::new(AtomicBool::new(permitted)), + capable: Arc::new(AtomicBool::new(capable)), + calls: Arc::new(AtomicUsize::new(0)), + } + } + } + + #[async_trait] + impl WipOperationHandler for ContextualHandler { + fn is_visible(&self) -> bool { + self.visible.load(Ordering::SeqCst) + } + + fn operation_available(&self, _operation: &str) -> bool { + self.permitted.load(Ordering::SeqCst) && self.capable.load(Ordering::SeqCst) + } + + fn contextual_interface(&self) -> bool { + true + } + + async fn call( + &self, + operation: &str, + _arguments: &BTreeMap, + _context: WipCallContext, + ) -> Result { + if !self.is_visible() || !self.operation_available(operation) { + return Err(WipOperationError::Protocol(protocol_error( + ProtocolErrorCode::PermissionDenied, + "current native authority denied", + ))); + } + self.calls.fetch_add(1, Ordering::SeqCst); + Ok(WipOperationOutput::native(Value::Unit)) + } + } + + fn operation_names(descriptor: &InterfaceDescriptor) -> Vec<&str> { + descriptor + .operations + .iter() + .map(|operation| operation.name.as_str()) + .collect() + } + + fn contextual_static_registry( + owner: Arc, + contributor: Arc, + ) -> WipMountRegistry { + let mut registry = WipMountRegistry::new(); + registry.allocate_namespace("asset", "assets").unwrap(); + let projection = contribution_projection(owner); + let mut descriptor = projection.descriptor.clone(); + descriptor.operations = vec![contributed_operation("manage")]; + registry.mount(projection).unwrap(); + registry + .contribute_operations(WipOperationContribution { + route: "/assets/A-1".into(), + contributor: "management".into(), + interface: "test.asset/item/v1".into(), + descriptor, + handler: contributor, + }) + .unwrap(); + registry + } + + #[tokio::test] + async fn contextual_static_filters_contributors_and_rechecks_old_references() { + let owner = Arc::new(ContextualHandler::new(true, true)); + let contributor = Arc::new(ContextualHandler::new(false, true)); + let mut registry = contextual_static_registry(Arc::clone(&owner), Arc::clone(&contributor)); + // Same base contract, different Object/capability. It must not share the + // first Object's cached contextual descriptor. + let mut other = registry.mounts["/assets/A-1"].projection.clone(); + other.route = "/assets/A-2".into(); + other.object.name = "A-2".into(); + other.handler = Arc::new(ContextualHandler::new(true, false)); + registry.mount(other).unwrap(); + let host = WipHost::new(registry); + let initial = host.projection("/assets/A-1").unwrap(); + let other = host.projection("/assets/A-2").unwrap(); + assert_eq!( + initial.interface, + contextual_reference("test.asset/item/v1", "/assets/A-1") + ); + assert_ne!(initial.interface, other.interface); + assert_eq!(operation_names(&initial.descriptor), ["read"]); + assert!(other.descriptor.operations.is_empty()); + assert!(host.fetch_interface("test.asset/item/v1").is_err()); + assert_eq!( + host.fetch_interface(&initial.interface).unwrap().descriptor, + initial.descriptor + ); + host.call(asset_request(&initial, "read"), direct_wip_context()) + .await + .unwrap_or_else(|_| panic!("available owner operation must run")); + assert!(matches!( + host.call(asset_request(&initial, "manage"), direct_wip_context()) + .await, + Err(WipOperationError::Protocol(ProtocolError { + code: ProtocolErrorCode::OperationNotFound, + .. + })) + )); + + contributor.permitted.store(true, Ordering::SeqCst); + let enabled = host.projection("/assets/A-1").unwrap(); + assert_eq!(enabled.interface, initial.interface); + assert_eq!( + operation_names(&host.fetch_interface(&initial.interface).unwrap().descriptor), + ["manage", "read"] + ); + assert_ne!(enabled.interface_validator, initial.interface_validator); + assert!(matches!( + host.call(asset_request(&initial, "read"), direct_wip_context()) + .await, + Err(WipOperationError::Protocol(ProtocolError { + code: ProtocolErrorCode::InterfaceValidatorMismatch, + .. + })) + )); + host.call(asset_request(&enabled, "manage"), direct_wip_context()) + .await + .unwrap_or_else(|_| panic!("available contributor operation must run")); + contributor.capable.store(false, Ordering::SeqCst); + assert_eq!( + operation_names(&host.fetch_interface(&initial.interface).unwrap().descriptor), + ["read"] + ); + assert!(matches!( + host.call(asset_request(&enabled, "manage"), direct_wip_context()) + .await, + Err(WipOperationError::Protocol(ProtocolError { + code: ProtocolErrorCode::InterfaceValidatorMismatch, + .. + })) + )); + let current = host.projection("/assets/A-1").unwrap(); + assert!(matches!( + host.call(asset_request(¤t, "manage"), direct_wip_context()) + .await, + Err(WipOperationError::Protocol(ProtocolError { + code: ProtocolErrorCode::OperationNotFound, + .. + })) + )); + let mut unqualified = asset_request(¤t, "read"); + unqualified.interface.reference = "test.asset/item/v1".into(); + assert!(matches!( + host.call(unqualified, direct_wip_context()).await, + Err(WipOperationError::Protocol(ProtocolError { + code: ProtocolErrorCode::InterfaceMismatch, + .. + })) + )); + owner.visible.store(false, Ordering::SeqCst); + contributor.permitted.store(true, Ordering::SeqCst); + contributor.capable.store(true, Ordering::SeqCst); + assert!(host.projection("/assets/A-1").is_none()); + assert!(host.fetch_interface(&initial.interface).is_err()); + assert!(host.observe("/assets/A-1", 0).is_err()); + let observed = host.observe("/assets", 1).unwrap(); + assert_eq!( + observed + .children + .unwrap() + .iter() + .map(|item| item.object.name.as_str()) + .collect::>(), + ["A-2"] + ); + assert!(matches!( + host.call(asset_request(&enabled, "manage"), direct_wip_context()) + .await, + Err(WipOperationError::Protocol(ProtocolError { + code: ProtocolErrorCode::NotFound, + .. + })) + )); + assert_eq!(owner.calls.load(Ordering::SeqCst), 1); + assert_eq!(contributor.calls.load(Ordering::SeqCst), 1); + } + + #[test] + fn contextual_visibility_cannot_fall_back_to_namespace_and_provider_versions_stay_current() { + let owner = Arc::new(ContextualHandler::new(true, true)); + let mut registry = contextual_static_registry( + Arc::clone(&owner), + Arc::new(ContextualHandler::new(true, true)), + ); + let mut child = contribution_projection(Arc::new(DynamicHandler { + calls: Arc::new(AtomicUsize::new(0)), + })); + child.route = "/assets/A-1/child".into(); + child.object.name = "child".into(); + // Legacy interface references stay opaque, including this delimiter. + child.interface = "legacy/@/opaque".into(); + child.object.interfaces = vec![child.interface.clone()]; + registry.mount(child.clone()).unwrap(); + let mut host = WipHost::new(registry); + assert_eq!( + host.fetch_interface(&child.interface).unwrap().descriptor, + child.descriptor + ); + let initial = host.projection("/assets/A-1").unwrap(); + host.registry + .mounts + .get_mut("/assets/A-1") + .unwrap() + .projection + .interface_validator = Some(vec![99]); + let current = host.fetch_interface(&initial.interface).unwrap(); + assert_eq!(current.descriptor, initial.descriptor); + assert_ne!(current.validator, initial.interface_validator); + owner.visible.store(false, Ordering::SeqCst); + assert!(host.object_at("/assets/A-1").is_none()); + assert!(host.observe("/assets/A-1", 1).is_err()); + assert!( + host.observe("/assets", 1) + .unwrap() + .children + .unwrap() + .is_empty() + ); + } + + struct ContextualItemResolver { + owner: Arc, + revision: Arc, + contextual_family: bool, + } + + impl WipDynamicItemResolver for ContextualItemResolver { + fn contextual_interface(&self) -> bool { + self.contextual_family + } + + fn resolve(&self, item_reference: &str) -> Option { + if !matches!(item_reference, "O-1" | "O-2") { + return None; + } + let mut handler = self.owner.as_ref().clone(); + if item_reference == "O-2" { + handler.capable = Arc::new(AtomicBool::new(false)); + } + 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(handler), + }) + } + } + + struct ContextualContributionResolver(Arc); + + impl WipDynamicOperationResolver for ContextualContributionResolver { + fn handler(&self, _item_reference: &str) -> Arc { + self.0.clone() + } + } + + fn contextual_dynamic_registry( + owner: Arc, + contributor: Arc, + revision: Arc, + contextual_collection: bool, + ) -> WipMountRegistry { + let mut registry = dynamic_registry(Arc::clone(&revision), Arc::clone(&owner.calls)); + let mut mounted = registry.dynamic_mounts.pop().unwrap(); + mounted.descriptor.operations = vec![contributed_operation("read")]; + mounted.interface_validator = Some(descriptor_validator(&mounted.descriptor)); + mounted.resolver = Arc::new(ContextualItemResolver { + owner, + revision, + contextual_family: !contextual_collection, + }); + if contextual_collection { + let collection = registry.mounts.get_mut("/objectives").unwrap(); + collection.projection.handler = Arc::new(ContextualHandler::new(true, true)); + collection.operation_handlers = + operation_handlers(&collection.projection, "objective:collection"); + } + let mut descriptor = mounted.descriptor.clone(); + descriptor.operations = vec![contributed_operation("manage")]; + registry.mount_dynamic(mounted).unwrap(); + registry + .contribute_dynamic_operations(WipDynamicOperationContribution { + collection_route: "/objectives".into(), + contributor: "management".into(), + interface: "test.dynamic/v1".into(), + descriptor, + resolver: Arc::new(ContextualContributionResolver(contributor)), + }) + .unwrap(); + registry + } + + #[tokio::test] + async fn contextual_dynamic_filters_by_path_and_rechecks_permission_and_object_revision() { + // Both ways to opt in a family must block unqualified descriptor fetches. + for contextual_collection in [false, true] { + let owner = Arc::new(ContextualHandler::new(true, true)); + let contributor = Arc::new(ContextualHandler::new(true, true)); + let revision = Arc::new(AtomicUsize::new(1)); + let host = WipHost::new(contextual_dynamic_registry( + Arc::clone(&owner), + Arc::clone(&contributor), + Arc::clone(&revision), + contextual_collection, + )); + let initial = host.projection("/objectives/O-1").unwrap(); + let other = host.projection("/objectives/O-2").unwrap(); + assert_ne!(initial.interface, other.interface); + assert_eq!( + operation_names(&host.fetch_interface(&initial.interface).unwrap().descriptor), + ["manage", "read"] + ); + assert_eq!( + operation_names(&host.fetch_interface(&other.interface).unwrap().descriptor), + ["manage"] + ); + assert!(host.fetch_interface("test.dynamic/v1").is_err()); + assert!( + host.fetch_interface(&contextual_reference("wrong.base/v1", "/objectives/O-1")) + .is_err() + ); + assert!( + host.fetch_interface(&contextual_reference( + "test.dynamic/v1", + "/objectives/missing" + )) + .is_err() + ); + assert!(host.fetch_interface("test.dynamic/v1/@/ff").is_err()); + assert!(host.fetch_interface("test.dynamic/v1/@/2f0").is_err()); + let mut wrong_path = asset_request(&initial, "read"); + wrong_path.target.path = other.route.clone(); + assert!(matches!( + host.call(wrong_path, direct_wip_context()).await, + Err(WipOperationError::Protocol(ProtocolError { + code: ProtocolErrorCode::InterfaceMismatch, + .. + })) + )); + contributor.permitted.store(false, Ordering::SeqCst); + assert_eq!( + operation_names(&host.fetch_interface(&initial.interface).unwrap().descriptor), + ["read"] + ); + assert!(matches!( + host.call(asset_request(&initial, "manage"), direct_wip_context()) + .await, + Err(WipOperationError::Protocol(ProtocolError { + code: ProtocolErrorCode::InterfaceValidatorMismatch, + .. + })) + )); + let current = host.projection("/objectives/O-1").unwrap(); + assert!(matches!( + host.call(asset_request(¤t, "manage"), direct_wip_context()) + .await, + Err(WipOperationError::Protocol(ProtocolError { + code: ProtocolErrorCode::OperationNotFound, + .. + })) + )); + revision.store(2, Ordering::SeqCst); + assert!(matches!( + host.call(asset_request(¤t, "read"), direct_wip_context()) + .await, + Err(WipOperationError::Protocol(ProtocolError { + code: ProtocolErrorCode::ValidatorMismatch, + .. + })) + )); + let current = host.projection("/objectives/O-1").unwrap(); + host.call(asset_request(¤t, "read"), direct_wip_context()) + .await + .unwrap_or_else(|_| panic!("fresh available dynamic operation must run")); + owner.visible.store(false, Ordering::SeqCst); + assert!(host.observe("/objectives/O-1", 0).is_err()); + assert!(host.fetch_interface(&initial.interface).is_err()); + assert!(host.fetch_interface(&other.interface).is_err()); + assert!(matches!( + host.call(asset_request(¤t, "read"), direct_wip_context()) + .await, + Err(WipOperationError::Protocol(ProtocolError { + code: ProtocolErrorCode::NotFound, + .. + })) + )); + assert_eq!(owner.calls.load(Ordering::SeqCst), 1); + assert_eq!(contributor.calls.load(Ordering::SeqCst), 0); + } + } + + #[tokio::test] + async fn contextual_runtime_cached_descriptor_does_not_authorize_removed_operations() { + let owner = Arc::new(ContextualHandler::new(true, true)); + let contributor = Arc::new(ContextualHandler::new(true, true)); + let runtime = WipRuntime::new( + WipHost::new(contextual_static_registry( + Arc::clone(&owner), + Arc::clone(&contributor), + )), + SecurityContext::new("worker-a"), + 0, + ) + .unwrap(); + runtime + .discover("/assets/A-1".into(), 0, false) + .await + .unwrap(); + let interface = runtime.host.projection("/assets/A-1").unwrap().interface; + runtime.inspect(interface.clone(), false).await.unwrap(); + contributor.permitted.store(false, Ordering::SeqCst); + // The Client still knows the old descriptor; the Host must independently + // check its current filtered descriptor rather than trust Known Space. + let error = runtime + .call( + "/assets/A-1".into(), + interface.clone(), + "read".into(), + json!({}), + ToolExecutionContext::direct(), + ) + .await + .unwrap_err(); + assert!(error.to_string().contains("InterfaceValidatorMismatch")); + assert!( + runtime + .call( + "/assets/A-1".into(), + interface.clone(), + "manage".into(), + json!({}), + ToolExecutionContext::direct(), + ) + .await + .is_err() + ); + assert_eq!(owner.calls.load(Ordering::SeqCst), 0); + assert_eq!(contributor.calls.load(Ordering::SeqCst), 0); + runtime.inspect(interface.clone(), true).await.unwrap(); + runtime + .call( + "/assets/A-1".into(), + interface.clone(), + "read".into(), + json!({}), + ToolExecutionContext::direct(), + ) + .await + .unwrap(); + owner.visible.store(false, Ordering::SeqCst); + assert!(runtime.host.fetch_interface(&interface).is_err()); + let inspected = runtime.inspect(interface.clone(), true).await.unwrap(); + let inspected: Json = serde_json::from_str(inspected.content.as_deref().unwrap()).unwrap(); + assert_eq!(inspected["state"], "error"); + assert!( + runtime + .call( + "/assets/A-1".into(), + interface, + "read".into(), + json!({}), + ToolExecutionContext::direct(), + ) + .await + .is_err() + ); + assert_eq!(owner.calls.load(Ordering::SeqCst), 1); + } + + struct CurrentObjectValidatorHandler { + inner: Arc, + revision: Arc, + } + + #[async_trait] + impl WipOperationHandler for CurrentObjectValidatorHandler { + fn contextual_interface(&self) -> bool { + true + } + + fn object_validator(&self) -> Option> { + Some(self.revision.load(Ordering::SeqCst).to_be_bytes().to_vec()) + } + + async fn call( + &self, + operation: &str, + arguments: &BTreeMap, + context: WipCallContext, + ) -> Result { + self.inner.call(operation, arguments, context).await + } + } + + #[tokio::test] + async fn static_object_validator_hook_rejects_current_owner_state_changes() { + let owner = Arc::new(ContextualHandler::new(true, true)); + let revision = Arc::new(AtomicUsize::new(1)); + let mut registry = WipMountRegistry::new(); + registry.allocate_namespace("asset", "assets").unwrap(); + registry + .mount(contribution_projection(Arc::new( + CurrentObjectValidatorHandler { + inner: Arc::clone(&owner), + revision: Arc::clone(&revision), + }, + ))) + .unwrap(); + // Contributors cannot override Object ownership or its current validator. + let mut descriptor = registry.mounts["/assets/A-1"].projection.descriptor.clone(); + descriptor.operations = vec![contributed_operation("manage")]; + registry + .contribute_operations(WipOperationContribution { + route: "/assets/A-1".into(), + contributor: "management".into(), + interface: "test.asset/item/v1".into(), + descriptor, + handler: Arc::new(CurrentObjectValidatorHandler { + inner: Arc::new(ContextualHandler::new(true, true)), + revision: Arc::new(AtomicUsize::new(99)), + }), + }) + .unwrap(); + let runtime = + WipRuntime::new(WipHost::new(registry), SecurityContext::new("worker-a"), 0).unwrap(); + let interface = observed_contextual_interface(&runtime, "/assets/A-1").await; + let initial = runtime.host.projection("/assets/A-1").unwrap(); + assert_eq!( + initial.object.validator, + Some(1usize.to_be_bytes().to_vec()) + ); + revision.store(2, Ordering::SeqCst); + assert_eq!( + runtime.host.object_at("/assets/A-1").unwrap().validator, + Some(2usize.to_be_bytes().to_vec()) + ); + assert!(matches!( + runtime + .host + .call(asset_request(&initial, "read"), direct_wip_context()) + .await, + Err(WipOperationError::Protocol(ProtocolError { + code: ProtocolErrorCode::ValidatorMismatch, + .. + })) + )); + let error = runtime + .call( + "/assets/A-1".into(), + interface.clone(), + "read".into(), + json!({}), + ToolExecutionContext::direct(), + ) + .await + .unwrap_err(); + assert!(error.to_string().contains("ValidatorMismatch")); + assert_eq!(owner.calls.load(Ordering::SeqCst), 0); + assert_eq!( + observed_contextual_interface(&runtime, "/assets/A-1").await, + interface + ); + transport_call_json(&runtime, "/assets/A-1", &interface, "read", json!({})).await; + assert_eq!(owner.calls.load(Ordering::SeqCst), 1); + } + + #[tokio::test] + async fn dynamic_item_read_is_independent_of_collection_visibility_and_list_permission() { + let owner = Arc::new(ContextualHandler::new(true, true)); + let mut registry = contextual_dynamic_registry( + Arc::clone(&owner), + Arc::new(ContextualHandler::new(false, true)), + Arc::new(AtomicUsize::new(1)), + true, + ); + let denied_collection = Arc::new(ContextualHandler::new(false, true)); + denied_collection.visible.store(false, Ordering::SeqCst); + let collection = registry.mounts.get_mut("/objectives").unwrap(); + collection.projection.handler = denied_collection; + collection.operation_handlers = + operation_handlers(&collection.projection, "objective:collection"); + let runtime = + WipRuntime::new(WipHost::new(registry), SecurityContext::new("worker-a"), 0).unwrap(); + assert!(runtime.host.projection("/objectives").is_none()); + let interface = observed_contextual_interface(&runtime, "/objectives/O-1").await; + transport_call_json(&runtime, "/objectives/O-1", &interface, "read", json!({})).await; + owner.visible.store(false, Ordering::SeqCst); + assert!(runtime.host.fetch_interface(&interface).is_err()); + assert!( + runtime + .call( + "/objectives/O-1".into(), + interface, + "read".into(), + json!({}), + ToolExecutionContext::direct() + ) + .await + .is_err() + ); + assert_eq!(owner.calls.load(Ordering::SeqCst), 1); + } + + fn transport_output_json(output: ToolOutput) -> Json { + serde_json::from_str(output.content.as_deref().expect("transport JSON output")).unwrap() + } + + async fn observed_contextual_interface(runtime: &WipRuntime, path: &str) -> String { + let observed = transport_output_json(runtime.discover(path.into(), 0, true).await.unwrap()); + let object = observed["known_space"] + .as_array() + .unwrap() + .iter() + .find(|item| item["path"] == path) + .unwrap(); + assert_eq!(object["state"], "fresh"); + let interface = object["object"]["interfaces"][0] + .as_str() + .unwrap() + .to_string(); + assert!(interface.contains("/@/")); + let inspected = + transport_output_json(runtime.inspect(interface.clone(), true).await.unwrap()); + assert_eq!(inspected["state"], "fresh"); + interface + } + + async fn transport_call_json( + runtime: &WipRuntime, + path: &str, + interface: &str, + operation: &str, + arguments: Json, + ) -> Json { + transport_output_json( + runtime + .call( + path.into(), + interface.into(), + operation.into(), + arguments, + ToolExecutionContext::direct(), + ) + .await + .unwrap(), + ) + } + + fn catalog_transport_runtime( + client: Arc, + manage: bool, + permissions: Option, + ) -> Arc { + use crate::feature::FeatureRegistryBuilder; + use crate::feature::builtin::manage_workdir::{ + ManageWorkdirFeature, wip::mount_workspace_workdir_wip, + }; + let feature = ManageWorkdirFeature::new(client); + let mut engine = Engine::<_, Mutable, ()>::new_annotated(DummyClient); + let report = FeatureRegistryBuilder::new() + .with_module(feature.clone()) + .install_into_engine(&mut engine, &mut crate::hook::HookRegistryBuilder::new()); + assert!(!report.has_errors()); + assert_eq!(report.installed_tool_names().len(), 5); + let mut registry = WipMountRegistry::new(); + mount_workspace_workdir_wip(&mut registry, &feature, true, manage, permissions.clone()) + .unwrap(); + let runtime = + install_wip_mode_with_mounts(&mut engine, permissions, "worker-a".into(), registry) + .unwrap(); + for name in [ + "WorkdirList", + "WorkdirCreate", + "WorkdirAttach", + "WorkdirDetach", + "WorkdirDelete", + ] { + assert!(runtime.host.projection(&format!("/tools/{name}")).is_none()); + } + assert!(runtime.host.object_at("/features/manage-workdir").is_none()); + runtime + } + + #[tokio::test] + async fn catalog_native_transport_pages_all_workdirs_beyond_legacy_200_cap() { + use crate::feature::builtin::manage_workdir::wip::tests::CatalogClient; + let client = Arc::new(CatalogClient::default()); + client.state.lock().unwrap().workdirs = (0..205).map(|n| json!({ + "working_directory_id":format!("wd-{n:03}"), "source":{"kind":"repository", "repository_key":"registered-key"}, "status":"active" + })).collect(); + let runtime = catalog_transport_runtime(client.clone(), false, None); + let interface = observed_contextual_interface(&runtime, "/workdirs").await; + let mut cursor = Json::Null; + let mut ids = BTreeSet::new(); + let mut pages = 0; + loop { + let page = transport_call_json( + &runtime, + "/workdirs", + &interface, + "list", + if cursor.is_null() { + json!({"limit":100}) + } else { + json!({"limit":100, "cursor":cursor}) + }, + ) + .await; + pages += 1; + let items = page["items"].as_array().unwrap(); + assert!(items.len() <= 100); + for item in items { + assert!(ids.insert(item["working_directory_id"].as_str().unwrap().to_owned())); + } + if !page["has_more"].as_bool().unwrap() { + assert!(page["next_cursor"].is_null()); + break; + } + cursor = page["next_cursor"].clone(); + assert!(pages < 4, "cursor must progress"); + } + assert_eq!(pages, 3); + assert_eq!(ids.len(), 205); + assert!(ids.contains("wd-000")); + let item_interface = observed_contextual_interface(&runtime, "/workdirs/wd-000").await; + let read = transport_call_json( + &runtime, + "/workdirs/wd-000", + &item_interface, + "read", + json!({}), + ) + .await; + assert_eq!(read["working_directory_id"], "wd-000"); + assert!( + client + .state + .lock() + .unwrap() + .requests + .iter() + .all(|r| !r.path.ends_with("/working-directories")), + "WIP must not use a capped legacy inventory" + ); + + let permissions = Some(ToolPermissionConfig { + default_action: ToolPermissionAction::Deny, + rules: vec![ + manifest::ToolPermissionRule { + tool: "WorkdirList".into(), + pattern: "*".into(), + action: ToolPermissionAction::Allow, + }, + manifest::ToolPermissionRule { + tool: "WorkdirRead".into(), + pattern: "*wd-204*".into(), + action: ToolPermissionAction::Allow, + }, + ], + }); + let runtime = catalog_transport_runtime(client, false, permissions); + let interface = observed_contextual_interface(&runtime, "/workdirs").await; + let first = transport_call_json( + &runtime, + "/workdirs", + &interface, + "list", + json!({"limit":100}), + ) + .await; + assert_eq!(first["empty"], true); + assert_eq!( + first["has_more"], true, + "empty filtered page is not terminal" + ); + let second = transport_call_json( + &runtime, + "/workdirs", + &interface, + "list", + json!({"limit":100, "cursor":first["next_cursor"]}), + ) + .await; + assert_eq!(second["has_more"], true); + let last = transport_call_json( + &runtime, + "/workdirs", + &interface, + "list", + json!({"limit":100, "cursor":second["next_cursor"]}), + ) + .await; + assert_eq!(last["items"][0]["working_directory_id"], "wd-204"); + assert_eq!(last["has_more"], false); + } + + #[tokio::test] + async fn catalog_native_transport_invalidates_attachment_change_beyond_first_page() { + use crate::feature::builtin::manage_workdir::wip::tests::CatalogClient; + for capability_change in [false, true] { + let client = Arc::new(CatalogClient::default()); + client.state.lock().unwrap().attachments = (0..65).map(|n| json!({ + "connection_id":format!("lifetime-{n:03}"), "alias":format!("alias-{n:03}"), "working_directory_id":"wd", "capabilities":{"bits":25} + })).collect(); + let first_fifty = client.state.lock().unwrap().attachments[..50].to_vec(); + let runtime = catalog_transport_runtime(client.clone(), true, None); + let interface = observed_contextual_interface(&runtime, "/workdir-attachments").await; + let old_validator = runtime + .host + .projection("/workdir-attachments") + .unwrap() + .object + .validator; + if capability_change { + client.state.lock().unwrap().attachments[64]["capabilities"] = json!({"bits":63}); + } else { + client.state.lock().unwrap().attachments[64]["connection_id"] = + json!("new-lifetime"); + } + assert_eq!(client.state.lock().unwrap().attachments[..50], first_fifty); + assert_ne!( + runtime + .host + .projection("/workdir-attachments") + .unwrap() + .object + .validator, + old_validator + ); + let error = runtime + .call( + "/workdir-attachments".into(), + interface.clone(), + "list".into(), + json!({"cursor":"offset:50", "limit":100}), + ToolExecutionContext::direct(), + ) + .await + .unwrap_err(); + assert!(error.to_string().contains("ValidatorMismatch"), "{error}"); + let interface = observed_contextual_interface(&runtime, "/workdir-attachments").await; + let page = transport_call_json( + &runtime, + "/workdir-attachments", + &interface, + "list", + json!({"cursor":"offset:50"}), + ) + .await; + assert_eq!(page["items"].as_array().unwrap().len(), 15); + if capability_change { + assert_eq!(page["items"][14]["capabilities"]["bits"], 63); + } else { + assert_eq!(page["items"][14]["connection_id"], "new-lifetime"); + } + } + } + + #[tokio::test] + async fn catalog_native_transport_discovers_creates_attaches_reads_and_detaches() { + use crate::feature::builtin::manage_workdir::wip::tests::CatalogClient; + use crate::worker::WorkspaceRequestMethod; + let client = Arc::new(CatalogClient::default()); + let runtime = catalog_transport_runtime(client.clone(), true, None); + let root = transport_output_json(runtime.discover("/".into(), 1, false).await.unwrap()); + let paths = root["known_space"] + .as_array() + .unwrap() + .iter() + .map(|item| item["path"].as_str().unwrap()) + .collect::>(); + for path in ["/repositories", "/workdirs", "/workdir-attachments"] { + assert!(paths.contains(&path)); + } + assert!(!paths.iter().any(|path| path.starts_with("/tools/Workdir") + || path.starts_with("/features/manage-workdir"))); + let repositories_interface = observed_contextual_interface(&runtime, "/repositories").await; + let listed = transport_call_json( + &runtime, + "/repositories", + &repositories_interface, + "list", + json!({}), + ) + .await; + let repository = &listed["items"][0]; + assert_eq!(repository["repository_key"], "registered-key"); + assert!(repository["default_selector"].is_null()); + assert!(!listed.to_string().contains("SECRET")); + assert!(!listed.to_string().contains("/secret/host")); + let repository_path = repository["path"].as_str().unwrap(); + let repository_interface = observed_contextual_interface(&runtime, repository_path).await; + let read = transport_call_json( + &runtime, + repository_path, + &repository_interface, + "read", + json!({}), + ) + .await; + assert_eq!(read["repository_key"], repository["repository_key"]); + let workdirs_interface = observed_contextual_interface(&runtime, "/workdirs").await; + let created = transport_call_json( + &runtime, + "/workdirs", + &workdirs_interface, + "create", + json!({"repository_key":read["repository_key"]}), + ) + .await; + assert_eq!(created["item"]["path"], "/workdirs/wd-created"); + { + let state = client.state.lock().unwrap(); + assert_eq!(state.workdirs.len(), 1); + assert!(state.attachments.is_empty(), "creation must not attach"); + assert!( + !state + .requests + .iter() + .any(|request| request.method == WorkspaceRequestMethod::Post + && request.path.ends_with("workdir-attachments")) + ); + } + let workdir_path = created["item"]["path"].as_str().unwrap(); + let workdir_interface = observed_contextual_interface(&runtime, workdir_path).await; + let attached = transport_call_json( + &runtime, + workdir_path, + &workdir_interface, + "attach", + json!({"alias":"checkout"}), + ) + .await; + assert_eq!(attached["attachments_path"], "/workdir-attachments"); + let attachments_interface = + observed_contextual_interface(&runtime, "/workdir-attachments").await; + let listed = transport_call_json( + &runtime, + "/workdir-attachments", + &attachments_interface, + "list", + json!({}), + ) + .await; + let attachment = &listed["items"][0]; + assert_eq!(attachment["alias"], "checkout"); + assert_eq!(attachment["workdir_path"], workdir_path); + let attachment_path = attachment["path"].as_str().unwrap(); + let attachment_interface = observed_contextual_interface(&runtime, attachment_path).await; + let read = transport_call_json( + &runtime, + attachment_path, + &attachment_interface, + "read", + json!({}), + ) + .await; + assert_eq!(read["connection_id"], attachment["connection_id"]); + transport_call_json( + &runtime, + attachment_path, + &attachment_interface, + "detach", + json!({}), + ) + .await; + let attachments_interface = + observed_contextual_interface(&runtime, "/workdir-attachments").await; + let listed = transport_call_json( + &runtime, + "/workdir-attachments", + &attachments_interface, + "list", + json!({}), + ) + .await; + assert_eq!(listed["empty"], true); + assert!(client.state.lock().unwrap().attachments.is_empty()); + assert_eq!( + client.state.lock().unwrap().workdirs.len(), + 1, + "detach must preserve persistent Workdir" + ); + assert!(runtime.host.projection(attachment_path).is_none()); + assert!(runtime.host.fetch_interface(&attachment_interface).is_err()); + let state = client.state.lock().unwrap(); + let writes = state + .requests + .iter() + .filter(|request| request.method != WorkspaceRequestMethod::Get) + .collect::>(); + assert_eq!(writes.len(), 3); + assert!( + writes[2] + .path + .contains("expected_connection_id=connection-1") + ); + drop(state); + // A dirty provider result is a retained lifecycle outcome, not transport + // uncertainty or implicit destructive success. + client.state.lock().unwrap().workdirs[0]["cleanliness"] = json!("dirty"); + let workdir_interface = observed_contextual_interface(&runtime, workdir_path).await; + let read = transport_call_json( + &runtime, + workdir_path, + &workdir_interface, + "read", + json!({}), + ) + .await; + assert_eq!(read["cleanliness"], "dirty"); + let retained = transport_call_json( + &runtime, + workdir_path, + &workdir_interface, + "delete", + json!({"reason":"dirty checkout retained"}), + ) + .await; + assert_eq!(retained["disposition"], "retained"); + assert_eq!(client.state.lock().unwrap().workdirs.len(), 1); + assert_eq!( + runtime.audit().last().unwrap().outcome, + WipAuditOutcome::Success + ); + } + + #[tokio::test] + async fn catalog_native_transport_read_only_and_read_without_list_permissions() { + use crate::feature::builtin::manage_workdir::wip::tests::CatalogClient; + use crate::worker::WorkspaceRequestMethod; + let client = Arc::new(CatalogClient::default()); + client.state.lock().unwrap().workdirs.push(json!({ + "working_directory_id":"wd-read", "status":"active", "cleanliness":"clean", + "source":{"kind":"repository", "repository_key":"registered-key"}, + })); + let runtime = catalog_transport_runtime(client.clone(), false, None); + runtime.discover("/".into(), 1, false).await.unwrap(); + let collection_interface = observed_contextual_interface(&runtime, "/workdirs").await; + assert_eq!( + operation_names( + &runtime + .host + .fetch_interface(&collection_interface) + .unwrap() + .descriptor + ), + ["list"] + ); + let listed = transport_call_json( + &runtime, + "/workdirs", + &collection_interface, + "list", + json!({}), + ) + .await; + let path = listed["items"][0]["path"].as_str().unwrap(); + let interface = observed_contextual_interface(&runtime, path).await; + assert_eq!( + operation_names(&runtime.host.fetch_interface(&interface).unwrap().descriptor), + ["read"] + ); + transport_call_json(&runtime, path, &interface, "read", json!({})).await; + for (target, reference, operation, input) in [ + ( + "/workdirs", + &collection_interface, + "create", + json!({"repository_key":"registered-key"}), + ), + (path, &interface, "attach", json!({"alias":"checkout"})), + (path, &interface, "delete", json!({"reason":"done"})), + ] { + assert!( + runtime + .call( + target.into(), + reference.clone(), + operation.into(), + input, + ToolExecutionContext::direct() + ) + .await + .is_err() + ); + } + assert!( + client + .state + .lock() + .unwrap() + .requests + .iter() + .all(|request| request.method == WorkspaceRequestMethod::Get) + ); + let read_only = ToolPermissionConfig { + default_action: ToolPermissionAction::Deny, + rules: ["RepositoryRead", "WorkdirRead"] + .iter() + .map(|name| manifest::ToolPermissionRule { + tool: (*name).into(), + pattern: "*".into(), + action: ToolPermissionAction::Allow, + }) + .collect(), + }; + let runtime = catalog_transport_runtime(client.clone(), false, Some(read_only)); + for path in ["/repositories/registered-key", "/workdirs/wd-read"] { + let interface = observed_contextual_interface(&runtime, path).await; + assert_eq!( + operation_names(&runtime.host.fetch_interface(&interface).unwrap().descriptor), + ["read"] + ); + transport_call_json(&runtime, path, &interface, "read", json!({})).await; + } + client.state.lock().unwrap().denied = true; + let interface = contextual_reference("yoi.workdir/item/v1", "/workdirs/wd-read"); + assert!(runtime.host.fetch_interface(&interface).is_err()); + assert!( + runtime + .call( + "/workdirs/wd-read".into(), + interface, + "read".into(), + json!({}), + ToolExecutionContext::direct() + ) + .await + .is_err() + ); + assert!( + client + .state + .lock() + .unwrap() + .requests + .iter() + .all(|request| request.method == WorkspaceRequestMethod::Get) + ); + } + + #[derive(Debug)] + struct UncertainCatalogClient { + catalog: Arc, + } + + impl crate::worker::WorkspaceClient for UncertainCatalogClient { + fn workspace_id(&self) -> Option<&str> { + Some("test-workspace") + } + fn kind(&self) -> &str { + "uncertain-catalog-test" + } + fn is_available(&self) -> bool { + true + } + fn execute( + &self, + request: crate::worker::WorkspaceRequest, + ) -> Result { + use crate::worker::{WorkspaceClientError, WorkspaceRequestMethod}; + if request.method == WorkspaceRequestMethod::Post + && request.path.ends_with("/working-directories") + { + self.catalog.state.lock().unwrap().requests.push(request); + return Err(WorkspaceClientError::Unavailable( + "SECRET /host/path uncertain write".into(), + )); + } + self.catalog.execute(request) + } + } + + #[tokio::test] + async fn catalog_native_transport_unknown_mutation_is_terminal_and_not_retried() { + use crate::feature::builtin::manage_workdir::wip::tests::CatalogClient; + use crate::worker::WorkspaceRequestMethod; + let catalog = Arc::new(CatalogClient::default()); + let runtime = catalog_transport_runtime( + Arc::new(UncertainCatalogClient { + catalog: catalog.clone(), + }), + true, + None, + ); + let interface = observed_contextual_interface(&runtime, "/workdirs").await; + let error = runtime + .call( + "/workdirs".into(), + interface, + "create".into(), + json!({"repository_key":"registered-key"}), + ToolExecutionContext::direct(), + ) + .await + .unwrap_err(); + let error = error.to_string(); + assert!(error.contains("outcome unknown")); + assert!(!error.contains("SECRET")); + assert!(!error.contains("/host/path")); + assert_eq!( + runtime.audit().last().unwrap().outcome, + WipAuditOutcome::OutcomeUnknown + ); + let state = runtime.state.lock().unwrap(); + assert!(matches!( + state + .client + .call_history(&state.session) + .unwrap() + .back() + .unwrap() + .outcome, + CallOutcome::Unknown { .. } + )); + let state = catalog.state.lock().unwrap(); + assert_eq!( + state + .requests + .iter() + .filter(|request| request.method == WorkspaceRequestMethod::Post) + .count(), + 1 + ); + assert!(state.workdirs.is_empty()); + assert!(state.attachments.is_empty()); + } + struct NativeTyped; #[async_trait] @@ -2338,6 +4719,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 +4982,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 +5014,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 +5040,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 +5050,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 +5079,127 @@ mod tests { assert_eq!(calls.load(Ordering::SeqCst), 0); } + #[test] + fn dynamic_registration_rejects_foreign_owners_and_operation_collisions_atomically() { + let revision = Arc::new(AtomicUsize::new(1)); + let calls = Arc::new(AtomicUsize::new(0)); + let mut registry = dynamic_registry(Arc::clone(&revision), Arc::clone(&calls)); + let mounted = registry.dynamic_mounts.pop().unwrap(); + let foreign = WipDynamicMount { + collection_route: mounted.collection_route.clone(), + capability: "unrelated:item".into(), + interface: mounted.interface.clone(), + descriptor: mounted.descriptor.clone(), + interface_validator: mounted.interface_validator.clone(), + resolver: Arc::new(DynamicResolver { + revision, + calls: Arc::clone(&calls), + }), + }; + assert!(matches!( + registry.mount_dynamic(foreign), + Err(WipMountError::InvalidProjection { .. }) + )); + assert!(registry.dynamic_mounts.is_empty()); + registry.mount_dynamic(mounted).unwrap(); + let original = registry.dynamic_mounts[0].descriptor.clone(); + let contribution = |descriptor| WipDynamicOperationContribution { + collection_route: "/objectives".into(), + contributor: "management".into(), + interface: "test.dynamic/v1".into(), + descriptor, + resolver: Arc::new(DynamicContributionResolver { + calls: Arc::clone(&calls), + allowed: Arc::new(AtomicBool::new(true)), + }), + }; + assert!(matches!( + registry.contribute_dynamic_operations(contribution(original.clone())), + Err(WipMountError::OperationCollision { .. }) + )); + let mut inconsistent = original.clone(); + inconsistent.operations = vec![contributed_operation("manage")]; + inconsistent.documentation = documentation("different shape"); + assert!(matches!( + registry.contribute_dynamic_operations(contribution(inconsistent)), + Err(WipMountError::InterfaceCollision { .. }) + )); + assert_eq!(registry.dynamic_mounts[0].descriptor, original); + assert!(registry.dynamic_operation_contributions.is_empty()); + let mut valid = original; + valid.operations = vec![contributed_operation("manage")]; + registry + .contribute_dynamic_operations(contribution(valid.clone())) + .unwrap(); + assert!(matches!( + registry.contribute_dynamic_operations(contribution(valid)), + Err(WipMountError::OperationCollision { .. }) + )); + assert_eq!( + registry.dynamic_operation_contributions["/objectives"].len(), + 1 + ); + } + + #[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 +5210,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 +5258,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 +5279,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 +5308,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 +5366,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 +5469,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 +5488,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 +5591,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 +5680,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 +5854,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 +5870,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 +5909,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) @@ -3479,6 +5982,212 @@ mod tests { runtime } + struct UnrelatedCancellation { + cancellations: Arc, + } + + #[async_trait] + impl WipOperationHandler for UnrelatedCancellation { + async fn call( + &self, + _: &str, + _: &BTreeMap, + _: WipCallContext, + ) -> Result { + panic!("unrelated provider must not execute"); + } + + async fn cancel(&self, _: &ToolExecutionContext) -> Result<(), ToolError> { + self.cancellations.fetch_add(1, Ordering::SeqCst); + Err(ToolError::InvalidArgument( + "unknown execution in unrelated provider".into(), + )) + } + } + + struct PendingCancellation { + started: Arc, + cancelled: tokio::sync::Notify, + was_called: AtomicBool, + cancellations: Arc, + } + + #[async_trait] + impl WipOperationHandler for PendingCancellation { + async fn call( + &self, + _: &str, + _: &BTreeMap, + _: WipCallContext, + ) -> Result { + self.was_called.store(true, Ordering::SeqCst); + self.started.notify_one(); + self.cancelled.notified().await; + Err(WipOperationError::Cancelled( + "provider confirmed cancellation".to_string().into(), + )) + } + + async fn cancel(&self, _: &ToolExecutionContext) -> Result<(), ToolError> { + // A second resolution of the same path would produce another instance. + assert!( + self.was_called.load(Ordering::SeqCst), + "cancel must use the dispatched instance" + ); + self.cancellations.fetch_add(1, Ordering::SeqCst); + self.cancelled.notify_one(); + Ok(()) + } + } + + struct PendingCancellationResolver { + started: Arc, + cancellations: Arc, + } + + impl WipDynamicOperationResolver for PendingCancellationResolver { + fn handler(&self, _: &str) -> Arc { + Arc::new(PendingCancellation { + started: Arc::clone(&self.started), + cancelled: tokio::sync::Notify::new(), + was_called: AtomicBool::new(false), + cancellations: Arc::clone(&self.cancellations), + }) + } + } + + struct CancellationItemResolver { + unrelated: Arc, + } + + impl WipDynamicItemResolver for CancellationItemResolver { + fn resolve(&self, item: &str) -> Option { + if item != "A-1" { + return None; + } + let projection = contribution_projection(Arc::new(UnrelatedCancellation { + cancellations: Arc::clone(&self.unrelated), + })); + Some(WipDynamicItem { + object: projection.object, + handler: projection.handler, + }) + } + } + + async fn assert_contributed_cancellation_is_selected_once(dynamic: bool) { + let started = Arc::new(tokio::sync::Notify::new()); + let cancellations = Arc::new(AtomicUsize::new(0)); + let unrelated = Arc::new(AtomicUsize::new(0)); + let mut registry = WipMountRegistry::new(); + registry.allocate_namespace("asset", "assets").unwrap(); + let projection = contribution_projection(Arc::new(UnrelatedCancellation { + cancellations: Arc::clone(&unrelated), + })); + let mut descriptor = projection.descriptor.clone(); + // Two names share one provider; read sorts first and fails cancellation. + descriptor.operations = vec![ + contributed_operation("write"), + contributed_operation("write_again"), + ]; + if dynamic { + let mut collection = projection.clone(); + collection.route = "/assets".into(); + collection.object.name = "assets".into(); + collection.interface = "test.asset/collection/v1".into(); + collection.object.interfaces = vec![collection.interface.clone()]; + registry.mount(collection).unwrap(); + registry + .mount_dynamic(WipDynamicMount { + collection_route: "/assets".into(), + capability: "asset:item".into(), + interface: projection.interface.clone(), + descriptor: projection.descriptor, + interface_validator: projection.interface_validator, + resolver: Arc::new(CancellationItemResolver { + unrelated: Arc::clone(&unrelated), + }), + }) + .unwrap(); + registry + .contribute_dynamic_operations(WipDynamicOperationContribution { + collection_route: "/assets".into(), + contributor: "management".into(), + interface: projection.interface, + descriptor, + resolver: Arc::new(PendingCancellationResolver { + started: Arc::clone(&started), + cancellations: Arc::clone(&cancellations), + }), + }) + .unwrap(); + } else { + registry.mount(projection).unwrap(); + registry + .contribute_operations(WipOperationContribution { + route: "/assets/A-1".into(), + contributor: "management".into(), + interface: "test.asset/item/v1".into(), + descriptor, + handler: PendingCancellationResolver { + started: Arc::clone(&started), + cancellations: Arc::clone(&cancellations), + } + .handler("A-1"), + }) + .unwrap(); + } + let runtime = + WipRuntime::new(WipHost::new(registry), SecurityContext::new("worker-a"), 0).unwrap(); + runtime + .discover("/assets/A-1".into(), 0, false) + .await + .unwrap(); + runtime + .inspect("test.asset/item/v1".into(), false) + .await + .unwrap(); + let execution = ToolExecutionContext::direct(); + let call = runtime.call( + "/assets/A-1".into(), + "test.asset/item/v1".into(), + "write".into(), + json!({}), + execution.clone(), + ); + let cancel = async { + started.notified().await; + // Unknown executions must not invoke any provider. + runtime + .cancel(&ToolExecutionContext::new("unknown", "other-batch", 0)) + .await + .unwrap(); + runtime.cancel(&execution).await.unwrap(); + }; + let (result, ()) = tokio::time::timeout(std::time::Duration::from_secs(2), async { + tokio::join!(call, cancel) + }) + .await + .expect("selected provider must receive cancellation"); + assert!(matches!(result, Err(ToolError::Cancelled(_)))); + assert_eq!(unrelated.load(Ordering::SeqCst), 0); + assert_eq!(cancellations.load(Ordering::SeqCst), 1); + assert!(runtime.active.lock().unwrap().is_empty()); + assert_eq!(runtime.audit()[0].outcome, WipAuditOutcome::Cancelled); + runtime.cancel(&execution).await.unwrap(); + assert_eq!(cancellations.load(Ordering::SeqCst), 1); + } + + #[tokio::test] + async fn static_contributed_cancellation_ignores_unrelated_and_shared_handlers() { + assert_contributed_cancellation_is_selected_once(false).await; + } + + #[tokio::test] + async fn dynamic_contributed_cancellation_pins_the_dispatched_instance() { + assert_contributed_cancellation_is_selected_once(true).await; + } + #[tokio::test] async fn cancelled_unknown_and_disconnected_calls_have_distinct_terminal_states() { let cancelled = diff --git a/crates/worker/src/worker.rs b/crates/worker/src/worker.rs index 4392df901..70b633367 100644 --- a/crates/worker/src/worker.rs +++ b/crates/worker/src/worker.rs @@ -1,3 +1,7 @@ +#[cfg(test)] +#[path = "image_recovery_tests.rs"] +mod image_recovery_tests; + use std::collections::{HashMap, VecDeque}; #[cfg(test)] use std::path::Path; @@ -836,6 +840,17 @@ pub trait WorkspaceClient: std::fmt::Debug + Send + Sync { fn execute(&self, request: WorkspaceRequest) -> Result; + /// Executes one bounded Workspace request when the caller is on a + /// latency-sensitive lifecycle boundary. Implementations without transport + /// timeout support retain their existing execution semantics. + fn execute_with_timeout( + &self, + request: WorkspaceRequest, + _timeout: Duration, + ) -> Result { + self.execute(request) + } + fn execute_server_operation( &self, operation: WorkspaceServerOperation, @@ -1986,7 +2001,7 @@ where pub(crate) fn prepare_notification_batch( &self, - ) -> Vec<(PendingNotification, SessionExtension)> { + ) -> Vec<(PendingNotification, NotificationCommitIdentity)> { let mut state = self .state .lock() @@ -2003,18 +2018,11 @@ where notifications .into_iter() .map(|notification| { - let index = state - .activating_notifications - .iter() - .position(|active| { - active.notification_request_id == notification.notification_request_id - && active.source_namespace == notification.source_namespace - }) - .expect("newly staged notification must be active"); - let mut committed = state.clone(); - committed.activating_notifications.drain(..=index); - committed.revision = committed.revision.saturating_add(1); - (notification, pending_activation_extension(&committed)) + let identity = NotificationCommitIdentity { + request_id: notification.notification_request_id.clone(), + source_namespace: notification.source_namespace.clone(), + }; + (notification, identity) }) .collect() } @@ -2118,17 +2126,6 @@ where } } - pub(crate) fn finish_notification_batch(&self) { - let mut state = self - .state - .lock() - .expect("pending activation state poisoned"); - if !state.activating_notifications.is_empty() { - state.activating_notifications.clear(); - state.revision = state.revision.saturating_add(1); - } - } - pub(crate) fn direct_client_namespace(&self) -> String { format!("direct:{}", self.writer.state.location().session_id) } @@ -2249,6 +2246,93 @@ impl PendingSubmissionHandle { } } +/// Identity of one staged notification, never a frozen full-state checkpoint. +#[derive(Debug, Clone)] +pub struct NotificationCommitIdentity { + pub(crate) request_id: String, + pub(crate) source_namespace: String, +} + +pub(crate) trait DurableNotificationCommitter: Send + Sync { + fn commit_notification( + &self, + identity: &NotificationCommitIdentity, + item: SystemItem, + provenance: Option, + ) -> Result>, StoreError>; +} + +impl DurableNotificationCommitter for PendingSubmissionHandle { + fn commit_notification( + &self, + identity: &NotificationCommitIdentity, + item: SystemItem, + provenance: Option, + ) -> Result>, StoreError> { + let _append = self + .writer + .state + .append_lock + .lock() + .expect("segment append lock poisoned"); + let mut state = self + .state + .lock() + .expect("pending activation state poisoned"); + let matches = |notification: &PendingNotification| { + notification.notification_request_id == identity.request_id + && notification.source_namespace == identity.source_namespace + }; + let Some(index) = state.activating_notifications.iter().position(matches) else { + // A partial drain may already have committed this notification. Its + // original history entry remains authoritative; never append it twice. + if !state.pending_notifications.iter().any(matches) + && state.notification_receipts.iter().any(|receipt| { + receipt.notification_request_id == identity.request_id + && receipt.source_namespace == identity.source_namespace + }) + { + return Ok(None); + } + return Err(StoreError::Io(std::io::Error::other( + "notification is not staged at the commit boundary", + ))); + }; + if index != 0 { + return Err(StoreError::Io(std::io::Error::other( + "notification commit would violate FIFO order", + ))); + } + let notification = &state.activating_notifications[index]; + if !matches!(&item, SystemItem::Notification { message, .. } if message == ¬ification.message) + || provenance.as_ref() != Some(¬ification.provenance) + { + return Err(StoreError::Io(std::io::Error::other( + "notification does not match accepted input authority", + ))); + } + let mut committed = state.clone(); + committed.activating_notifications.pop_front(); + committed.revision = committed.revision.saturating_add(1); + let metadata = new_history_metadata( + provenance.unwrap_or(WorkerHistoryProvenance::LegacyUnknown), + None, + ); + let history_item = item.to_history_item(); + self.writer + .append_entry_locked(LogEntry::AnnotatedSystemItem { + ts: segment_log::now_millis(), + entry: session_store::LoggedSystemHistoryEntry { + item, + metadata: metadata.clone(), + }, + extensions: vec![pending_activation_extension(&committed)], + })?; + *state = committed; + Ok(Some(HistoryEntry::new(history_item, metadata))) + } +} + /// Type-erased commit handle for the interceptor. Lets the interceptor commit `SystemItem`s without being generic over the /// concrete `Store` type. pub trait SystemItemCommitter: Send + Sync { @@ -2472,9 +2556,30 @@ pub(crate) enum SystemPromptContribution { Unavailable, } +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) struct ResidentContextRefresh { + pub body: String, + pub revision: u64, +} + #[async_trait::async_trait] pub(crate) trait SystemPromptContributionSource: Send + Sync { async fn load(&self) -> SystemPromptContribution; + + /// Returns a durable model-visible refresh only when user-managed resident + /// context changed since the last successful load. + async fn load_changed_resident_context( + &self, + ) -> Result, String> { + Ok(None) + } + + /// Acknowledges that the revision's typed refresh was durably committed. + fn confirm_resident_context_revision(&self, _revision: u64) {} + + /// Invalidates the in-memory representation fence before history may be + /// compacted or rewound. + fn invalidate_resident_context_representation(&self) {} } #[derive(Debug, Clone)] @@ -2674,6 +2779,7 @@ pub struct Worker { /// paths skip SystemItem disk commits but still see the rendered /// `Item::system_message` in worker history. log_writer: Option>, + notification_committer: Option>, } impl Drop for Worker { @@ -2753,10 +2859,14 @@ impl Worker { /// Idempotent: subsequent calls overwrite the previous handle. pub fn attach_log_writer(&mut self, writer: Arc) { self.log_writer = Some(writer); + self.notification_committer = Some(Arc::new(self.pending_submission_handle())); } pub fn attach_in_flight_events(&mut self, in_flight: InFlightEvents) { self.in_flight = Some(in_flight); + if self.notification_committer.is_some() { + self.notification_committer = Some(Arc::new(self.pending_submission_handle())); + } } pub fn clear_in_flight_events(&self) { @@ -2902,6 +3012,7 @@ impl Worker { sink: SegmentLogSink::new(), history_persistence_wired: false, log_writer: None, + notification_committer: None, }; worker.apply_permissions_from_manifest(); worker.apply_prune_from_manifest(); @@ -3971,8 +4082,10 @@ impl Worker { self.log_writer.clone(), self.pending_committed_history.clone(), ) + .with_notification_committer(self.notification_committer.clone()) .with_usage_tracker(self.usage_tracker.clone()) .with_metrics_tracker(self.metrics_tracker.clone()) + .with_resident_context_source(self.feature_resident_summary_source.clone()) .with_prompt_workspace_id( self.workspace_context .workspace_id() @@ -4187,6 +4300,24 @@ impl Worker { St: Clone + 'static, { self.materialize_durable_session_head().await?; + let writer = self.log_writer_handle(); + let activity = self.ai_activity_counter.clone(); + self.engine_mut() + .set_image_rejection_handler(move |original, replacement| { + writer + .append_entry(LogEntry::ToolResultCorrected { + ts: segment_log::now_millis(), + entry: session_store::LoggedHistoryEntry { + item: replacement.clone().into(), + metadata: original.annotation.clone(), + }, + }) + .map_err(|error| error.to_string())?; + // A later Cancel must not roll back this committed correction while + // leaving the in-memory result changed (the original may predate Run). + activity.fetch_add(1, Ordering::SeqCst); + Ok(()) + }); self.ensure_interceptor_installed(); if self.should_pre_run_compact() { self.try_pre_run_compact().await?; @@ -4524,13 +4655,13 @@ impl Worker { match self.execute_feature_invocations(&input).await { Ok(false) => {} Ok(true) => { - self.materialize_committed_activation_history(&projected_input); + self.materialize_committed_history(&projected_input); return Err(WorkerError::FeatureInvocation( "activation preparation failed or has an unknown outcome; dependent LLM request was not sent".into(), )); } Err(error) => { - self.materialize_committed_activation_history(&projected_input); + self.materialize_committed_history(&projected_input); return Err(error); } } @@ -4586,7 +4717,7 @@ impl Worker { if let Some(input) = &committed_activation_input { // A prompt hook may cancel before Engine materializes input/results. // Business preparation is already durable and cannot be rolled back. - self.materialize_committed_activation_history(input); + self.materialize_committed_history(input); } if !has_invocations && self.should_rollback_empty_turn(&result, &rollback_snapshot) { self.rollback_empty_turn(rollback_snapshot)?; @@ -4783,10 +4914,7 @@ impl Worker { Ok(prior_failure) } - fn materialize_committed_activation_history( - &mut self, - input: &[HistoryEntry], - ) { + fn materialize_committed_history(&mut self, input: &[HistoryEntry]) { let pending = self .pending_committed_history .lock() @@ -4807,6 +4935,12 @@ impl Worker { self.session.note_mutation(); } + pub(crate) fn can_schedule_notification_run(&self) -> bool { + !self.has_failed_segment_activation() + && !self.has_pending_compaction_cleanup() + && self.ensure_invocation_preparation_complete().is_ok() + } + fn ensure_invocation_preparation_complete(&self) -> Result<(), WorkerError> { let state = self .session @@ -5407,6 +5541,10 @@ impl Worker { where St: Clone + 'static, { + // A producer may durably append a prefix before a later append fails. + // Preserve that exact annotated prefix even when Engine never got to + // materialize it; retries must neither lose nor reappend it. + self.materialize_committed_history(&[]); let run_id = uuid::Uuid::now_v7().to_string(); let hook_exit = hook_run_exit(&result); if let Some(hooks) = self.hook_registry.clone() { @@ -7158,6 +7296,7 @@ where sink: SegmentLogSink::new(), history_persistence_wired: false, log_writer: None, + notification_committer: None, }; worker.apply_permissions_from_manifest(); worker.apply_prune_from_manifest(); @@ -7436,6 +7575,7 @@ where sink: SegmentLogSink::new(), history_persistence_wired: false, log_writer: None, + notification_committer: None, }; worker.apply_permissions_from_manifest(); worker.apply_prune_from_manifest(); @@ -7562,6 +7702,7 @@ where sink: SegmentLogSink::new(), history_persistence_wired: false, log_writer: None, + notification_committer: None, }; worker.apply_permissions_from_manifest(); worker.apply_prune_from_manifest(); @@ -7949,6 +8090,7 @@ where sink: SegmentLogSink::with_initial(mirror_entries), history_persistence_wired: false, log_writer: None, + notification_committer: None, }; worker .session @@ -9598,6 +9740,7 @@ mod build_summary_prompt_tests { availability: memory::backend::MemoryResidentSummaryAvailability, content: Option, load_count: Arc, + behavior_revision: Option>, } impl WorkspaceClient for RestoreSubjectResidentClient { @@ -9621,16 +9764,23 @@ mod build_summary_prompt_tests { serde_json::from_str(request.body.as_deref().unwrap_or_default()).unwrap(); assert!(matches!( request.operation, - server_api::SubjektivMemoryBackendOperation::ResidentSummary(_) + server_api::SubjektivMemoryBackendOperation::ResidentContext(_) )); self.load_count.fetch_add(1, Ordering::SeqCst); Ok(WorkspaceResponse { status: 200, body: serde_json::to_string( - &server_api::SubjektivMemoryBackendResponse::ResidentSummary( - memory::backend::MemoryResidentSummaryOutput { - availability: self.availability, - content: self.content.clone(), + &server_api::SubjektivMemoryBackendResponse::ResidentContext( + server_api::SubjektivResidentContextOutput { + behavior_md: "restore behavior".to_string(), + behavior_revision: self + .behavior_revision + .as_ref() + .map_or(1, |revision| revision.load(Ordering::SeqCst) as u64), + memory_surface: memory::backend::MemoryResidentSummaryOutput { + availability: self.availability, + content: self.content.clone(), + }, }, ), ) @@ -9649,9 +9799,12 @@ mod build_summary_prompt_tests { availability, content: content.map(str::to_string), load_count: Arc::clone(&load_count), + behavior_revision: None, }); let source = crate::feature::builtin::memory::ordinary_subjektiv_resident_summary_source( - manifest, client, + manifest, + client, + Arc::new(ArcSwap::from(PromptCatalog::builtins_only().unwrap())), ) .unwrap() .unwrap(); @@ -10099,6 +10252,9 @@ mod build_summary_prompt_tests { { 2 } + LogEntry::AnnotatedSystemItem { entry, .. } if matches!(&entry.item, SystemItem::Notification { message, .. } if message == "second") => { + 5 + } _ => 0, }; if invocation_kind != 0 @@ -12675,9 +12831,10 @@ permission = "write" .unwrap() .to_string(); assert!( - empty_boundary.contains("current ready resident memory surface is intentionally empty") + empty_boundary + .contains("current generated Memory surface is ready and intentionally empty") ); - assert!(!empty_boundary.contains("No current ready resident memory surface")); + assert!(!empty_boundary.contains("No generated Memory surface exists yet")); drop(empty_restore); for availability in [ @@ -12729,7 +12886,22 @@ permission = "write" .as_text() .unwrap() .to_string(); - assert!(tombstone.contains("No current ready resident memory surface")); + let unavailable_message = match availability { + memory::backend::MemoryResidentSummaryAvailability::Ungenerated => { + "No generated Memory surface exists yet" + } + memory::backend::MemoryResidentSummaryAvailability::Stale => { + "generated Memory surface is stale" + } + memory::backend::MemoryResidentSummaryAvailability::Failed => { + "Generation of the Memory surface failed" + } + memory::backend::MemoryResidentSummaryAvailability::Unavailable => { + "Host could not fetch the Subject resident context" + } + memory::backend::MemoryResidentSummaryAvailability::Ready => unreachable!(), + }; + assert!(tombstone.contains(unavailable_message)); assert!(!tombstone.contains("latest subject surface")); assert!(!tombstone.contains("LEGACY_MEMORY_SENTINEL_MUST_NOT_REFRESH")); let entries = store.read_all(session_id, segment_id).unwrap(); @@ -13746,6 +13918,84 @@ permission = "write" assert_eq!(state.pending_notifications[1].provenance, account_b); } + #[test] + fn notification_lost_acceptance_retry_after_restore_commits_once_not_by_body() { + let temp = tempfile::tempdir().unwrap(); + let handle = PendingSubmissionHandle::for_test(temp.path()); + let source = "backend:orchestrator-attention"; + let accept = |handle: &PendingSubmissionHandle, id: &str| { + handle + .accept_notification_from_source( + id.into(), + "Queued Tickets require attention".into(), + source.into(), + WorkerHistoryProvenance::BackendInstruction { operation_id: None }, + ) + .unwrap() + }; + // The sender lost this acceptance response; the receipt is already durable. + assert!(accept(&handle, "attention-1")); + let restored_state: PendingActivationState = handle + .persisted_entries_for_test() + .iter() + .rev() + .find_map(|entry| match entry { + LogEntry::Extension { + domain, payload, .. + } if domain == SESSION_PENDING_ACTIVATIONS_EXTENSION_DOMAIN => { + Some(serde_json::from_value(payload.clone()).unwrap()) + } + _ => None, + }) + .unwrap(); + assert_eq!(restored_state.notification_receipts.len(), 1); + let handle = PendingSubmissionHandle { + state: Arc::new(Mutex::new(restored_state)), + writer: handle.writer.clone(), + }; + assert!(!accept(&handle, "attention-1")); + let commit_batch = |handle: &PendingSubmissionHandle| { + let batch = handle.prepare_notification_batch(); + let count = batch.len(); + for (notification, identity) in batch { + handle + .commit_notification( + &identity, + SystemItem::Notification { + body: notification.message.clone(), + message: notification.message, + prompt_provenance: None, + }, + Some(notification.provenance), + ) + .unwrap(); + } + count + }; + assert_eq!(commit_batch(&handle), 1); + assert!(!accept(&handle, "attention-1")); + assert_eq!(commit_batch(&handle), 0); + let notification_count = |handle: &PendingSubmissionHandle| { + handle + .persisted_entries_for_test() + .iter() + .filter(|entry| { + matches!(entry, + LogEntry::AnnotatedSystemItem { entry, .. } + if matches!(entry.item, SystemItem::Notification { .. }) + ) + }) + .count() + }; + assert_eq!(notification_count(&handle), 1); + assert_eq!(handle.state.lock().unwrap().notification_receipts.len(), 1); + // Same body with a new logical ID is legitimate, not content-deduped. + assert!(accept(&handle, "attention-2")); + assert_eq!(commit_batch(&handle), 1); + assert_eq!(notification_count(&handle), 2); + assert_eq!(handle.state.lock().unwrap().notification_receipts.len(), 2); + } + #[test] fn notification_batch_preserves_fifo_order_and_notification_dedupes() { let temp = tempfile::tempdir().unwrap(); @@ -13777,15 +14027,395 @@ permission = "write" assert_eq!(notifications.len(), 2); assert_eq!(notifications[0].0.message, "notice"); assert_eq!(notifications[1].0.message, "second notice"); - let committed_state: PendingActivationState = - serde_json::from_value(notifications[1].1.payload.clone()).unwrap(); + for (notification, identity) in notifications { + handle + .commit_notification( + &identity, + SystemItem::Notification { + body: notification.message.clone(), + message: notification.message, + prompt_provenance: None, + }, + Some(notification.provenance), + ) + .unwrap(); + } + let committed_state = handle.state.lock().unwrap().clone(); assert!(committed_state.pending_notifications.is_empty()); assert!(committed_state.activating_notifications.is_empty()); - handle.finish_notification_batch(); let submission = handle.prepare_next_activation(None).unwrap().unwrap(); assert!(matches!(submission, PendingActivation::Submission(_))); } + #[test] + fn notification_commit_preserves_later_admission_and_completed_activation() { + let temp = tempfile::tempdir().unwrap(); + let handle = PendingSubmissionHandle::for_test(temp.path()); + handle + .accept_notification("first".into(), "first".into()) + .unwrap(); + let batch = handle.prepare_notification_batch(); + // These transitions occur after staging but before the model boundary. + handle + .accept("submit-1".into(), vec![Segment::text("input")], false) + .unwrap(); + let Some(PendingActivation::Submission(submission)) = + handle.prepare_next_activation(None).unwrap() + else { + panic!("expected submission") + }; + handle.finish_activation(&submission.submission_id); + handle + .accept_notification("later".into(), "later".into()) + .unwrap(); + handle + .accept("submit-2".into(), vec![Segment::text("queued")], false) + .unwrap(); + let (notification, identity) = &batch[0]; + handle + .commit_notification( + identity, + SystemItem::Notification { + body: notification.message.clone(), + message: notification.message.clone(), + prompt_provenance: None, + }, + Some(notification.provenance.clone()), + ) + .unwrap() + .unwrap(); + let entries = handle.persisted_entries_for_test(); + let LogEntry::AnnotatedSystemItem { extensions, .. } = entries.last().unwrap() else { + panic!("expected notification") + }; + let checkpoint: PendingActivationState = + serde_json::from_value(extensions[0].payload.clone()).unwrap(); + assert!( + checkpoint.activating.is_none(), + "must not revive completed submission" + ); + assert!(checkpoint.activating_notifications.is_empty()); + assert_eq!(checkpoint.pending_notifications.len(), 1); + assert_eq!(checkpoint.notification_receipts.len(), 2); + assert_eq!(checkpoint.pending.len(), 1); + assert_eq!(checkpoint.receipts.len(), 2); + assert_eq!(checkpoint.revision, handle.state.lock().unwrap().revision); + let restored = PendingSubmissionHandle { + state: Arc::new(Mutex::new(checkpoint)), + writer: handle.writer.clone(), + }; + assert!( + !restored + .accept_notification("later".into(), "later".into()) + .unwrap() + ); + assert!( + restored + .accept("submit-2".into(), vec![Segment::text("queued")], false) + .unwrap() + .activation + .is_none() + ); + // Stage a newer batch before a delayed completion/retry of the old one. + let later = restored.prepare_notification_batch(); + assert_eq!(later.len(), 1); + assert!( + restored + .commit_notification( + identity, + SystemItem::Notification { + body: notification.message.clone(), + message: notification.message.clone(), + prompt_provenance: None, + }, + Some(notification.provenance.clone()) + ) + .unwrap() + .is_none(), + "retry must not duplicate durable notification" + ); + assert_eq!( + restored + .state + .lock() + .unwrap() + .activating_notifications + .len(), + 1, + "an old batch completion must not clear a concurrently staged notification" + ); + assert_eq!(restored.persisted_entries_for_test().len(), entries.len()); + } + + #[tokio::test] + async fn notification_partial_drain_retries_only_uncommitted_entries() { + use agen::interceptor::{Interceptor, PendingHistoryAppendsContext}; + let temp = tempfile::tempdir().unwrap(); + let original = PendingSubmissionHandle::for_test(temp.path()); + let store = FailRunTerminalStore { + inner: original.writer.store.clone(), + fail_run_terminal: Arc::new(AtomicBool::new(false)), + invocation_failure: Arc::new(AtomicUsize::new(5)), + }; + let handle = PendingSubmissionHandle { + state: original.state.clone(), + writer: LogWriterHandle { + store: store.clone(), + state: original.writer.state.clone(), + sink: original.writer.sink.clone(), + in_flight: None, + }, + }; + let buffer = NotifyBuffer::new(); + for message in ["first", "second"] { + handle + .accept_notification(message.into(), message.into()) + .unwrap(); + } + for (notification, identity) in handle.prepare_notification_batch() { + buffer.push_durable_notify(notification.message, notification.provenance, identity); + } + let history = Arc::new(Mutex::new(VecDeque::new())); + let interceptor = WorkerInterceptor::new_with_history_queue( + Arc::new(HookRegistryBuilder::new().build()), + None, + None, + buffer.clone(), + Arc::new(Mutex::new(Vec::new())), + Arc::new(ArcSwap::from(PromptCatalog::builtins_only().unwrap())), + Some(Arc::new(handle.writer.clone())), + history.clone(), + ) + .with_notification_committer(Some(Arc::new(handle.clone()))); + assert!( + interceptor + .pending_history_appends(PendingHistoryAppendsContext { + invocation: Default::default(), + history: &[] + }) + .await + .is_err() + ); + assert_eq!( + handle.state.lock().unwrap().activating_notifications.len(), + 1 + ); + assert_eq!(history.lock().unwrap().len(), 1); + // New acceptance between failure and retry must survive both commits. + handle + .accept_notification("later".into(), "later".into()) + .unwrap(); + let appends = interceptor + .pending_history_appends(PendingHistoryAppendsContext { + invocation: Default::default(), + history: &[], + }) + .await + .unwrap(); + assert_eq!(appends.len(), 1); + assert_eq!(history.lock().unwrap().len(), 2); + let entries = original.persisted_entries_for_test(); + assert_eq!(entries.iter().filter(|entry| matches!(entry, LogEntry::AnnotatedSystemItem { entry, .. } if matches!(entry.item, SystemItem::Notification { .. }))).count(), 2); + let LogEntry::AnnotatedSystemItem { extensions, .. } = entries.last().unwrap() else { + panic!("expected notification") + }; + let checkpoint: PendingActivationState = + serde_json::from_value(extensions[0].payload.clone()).unwrap(); + assert_eq!(checkpoint.notification_receipts.len(), 3); + assert_eq!(checkpoint.pending_notifications.len(), 1); + assert!(checkpoint.activating_notifications.is_empty()); + assert!(buffer.is_empty()); + } + + #[tokio::test] + async fn notification_partial_drain_preserves_live_and_restored_history() { + let temp = tempfile::tempdir().unwrap(); + let store = FailRunTerminalStore { + inner: session_store::FsStore::new(temp.path()).unwrap(), + fail_run_terminal: Arc::new(AtomicBool::new(false)), + invocation_failure: Arc::new(AtomicUsize::new(5)), + }; + let client = PauseResumeClient { + calls: Arc::new(AtomicUsize::new(1)), + }; + let mut worker = Worker::new( + minimal_manifest(), + Engine::<_, Mutable, SessionHistoryMetadata>::new_annotated(client.clone()), + store.clone(), + WorkerWorkspaceContext::no_workspace(), + WorkerFilesystemAuthority::None, + Scope::empty(), + ) + .await + .unwrap(); + worker.attach_log_writer(Arc::new(worker.log_writer_handle())); + let pending = worker.pending_submission_handle(); + for message in ["first", "second"] { + pending + .accept_notification(message.into(), message.into()) + .unwrap(); + } + for (notification, identity) in pending.prepare_notification_batch() { + worker.pending_notifies.push_durable_notify( + notification.message, + notification.provenance, + identity, + ); + } + let result = worker + .run_for_notification(protocol::InvokeKind::Notify) + .await; + assert!( + matches!(result, Ok(WorkerRunResult::Interrupted { .. }) | Err(_)), + "{result:?}" + ); + assert_eq!( + client.calls.load(Ordering::SeqCst), + 1, + "failed drain must not call the provider" + ); + let history = worker + .session + .history() + .entries() + .iter() + .find(|entry| { + entry + .item + .as_text() + .is_some_and(|text| text.contains("first")) + }) + .unwrap() + .clone(); + worker + .run_for_notification(protocol::InvokeKind::Notify) + .await + .unwrap(); + assert_eq!(client.calls.load(Ordering::SeqCst), 2); + assert_eq!( + worker + .session + .history() + .entries() + .iter() + .filter(|entry| entry.annotation.entry_id == history.annotation.entry_id) + .count(), + 1 + ); + let entries = store + .read_all(worker.session_id(), worker.segment_id()) + .unwrap(); + let restored = + restore_history_entries(worker.session_id(), worker.segment_id(), &entries).unwrap(); + assert_eq!( + restored + .iter() + .filter(|entry| entry.annotation.entry_id == history.annotation.entry_id) + .count(), + 1 + ); + assert_eq!(entries.iter().filter(|entry| matches!(entry, LogEntry::AnnotatedSystemItem { entry, .. } if matches!(entry.item, SystemItem::Notification { .. }))).count(), 2); + assert!( + pending + .state + .lock() + .unwrap() + .activating_notifications + .is_empty() + ); + } + + #[tokio::test] + async fn invocation_fence_suspends_automatic_notification_until_fresh_input() { + for scenario in ["failed", "unknown", "incomplete"] { + let temp = tempfile::tempdir().unwrap(); + let store = FailRunTerminalStore { + inner: session_store::FsStore::new(temp.path()).unwrap(), + fail_run_terminal: Arc::new(AtomicBool::new(false)), + invocation_failure: Arc::new(AtomicUsize::new(if scenario == "unknown" { + 2 + } else { + 0 + })), + }; + let client = PauseResumeClient { + calls: Arc::new(AtomicUsize::new(1)), + }; + let mut worker = Worker::new( + minimal_manifest(), + Engine::<_, Mutable, SessionHistoryMetadata>::new_annotated(client.clone()), + store, + WorkerWorkspaceContext::no_workspace(), + WorkerFilesystemAuthority::None, + Scope::empty(), + ) + .await + .unwrap(); + worker.attach_log_writer(Arc::new(worker.log_writer_handle())); + let effects = Arc::new(AtomicUsize::new(0)); + install_recovery_invocation(&mut worker, effects.clone()); + let pending = worker.pending_submission_handle(); + pending + .accept_notification("held-notify".into(), "held notification".into()) + .unwrap(); + for (notification, identity) in pending.prepare_notification_batch() { + worker.pending_notifies.push_durable_notify( + notification.message, + notification.provenance, + identity, + ); + } + let mut input = recovery_invocation(scenario); + if scenario == "failed" { + let Segment::FeatureInvoke { invocation } = &mut input else { + unreachable!() + }; + invocation.name = "invalid-name".into(); + } + if scenario == "incomplete" { + assert!( + tokio::time::timeout(Duration::from_millis(20), worker.run(vec![input])) + .await + .is_err() + ); + } else { + assert!(worker.run(vec![input]).await.is_err()); + } + let before = worker.sink().subscribe_with_snapshot().0; + for _ in 0..4 { + assert!(!worker.can_schedule_notification_run(), "{scenario}"); + assert!( + worker + .run_for_notification(protocol::InvokeKind::Notify) + .await + .is_err() + ); + } + assert_eq!( + worker.sink().subscribe_with_snapshot().0.len(), + before.len() + ); + assert_eq!(client.calls.load(Ordering::SeqCst), 1); + assert!(!worker.pending_notifies.is_empty()); + assert_eq!( + effects.load(Ordering::SeqCst), + usize::from(scenario != "failed") + ); + worker + .run(vec![Segment::text("explicit fresh input")]) + .await + .unwrap(); + assert!(worker.can_schedule_notification_run()); + assert!(worker.pending_notifies.is_empty()); + assert_eq!(client.calls.load(Ordering::SeqCst), 2); + assert_eq!( + effects.load(Ordering::SeqCst), + usize::from(scenario != "failed"), + "no unknown effect retry" + ); + } + } + #[derive(Clone)] struct RecoveryInvocationFeature { calls: Arc, @@ -13888,6 +14518,300 @@ permission = "write" ); } + #[derive(Clone)] + struct ResidentInvocationClient { + sink: Arc>>, + requests: Arc, + } + + #[async_trait] + impl LlmClient for ResidentInvocationClient { + async fn stream( + &self, + request: agen::llm_client::Request, + ) -> Result< + std::pin::Pin< + Box< + dyn futures::Stream< + Item = Result< + agen::llm_client::event::Event, + agen::llm_client::ClientError, + >, + > + Send, + >, + >, + agen::llm_client::ClientError, + > { + let entries = self + .sink + .lock() + .unwrap() + .as_ref() + .unwrap() + .subscribe_with_snapshot() + .0; + let preparation = entries + .iter() + .position(|entry| { + matches!(entry, + LogEntry::AnnotatedSystemItem { entry, .. } + if matches!(entry.item, SystemItem::FeatureInvocationResult { .. }) + ) + }) + .expect("invocation result must be durable before the model request"); + let behavior = entries + .iter() + .position(|entry| { + matches!(entry, + LogEntry::AnnotatedSystemItem { entry, .. } + if matches!(entry.item, SystemItem::SubjectBehaviorRefresh { .. }) + ) + }) + .expect("Subject behavior refresh must be durable before the model request"); + assert!(preparation < behavior); + let request = format!("{request:?}"); + assert!(request.contains("prepared recovery context")); + assert!(request.contains("restore behavior")); + assert!(request.contains("literal /prepare()")); + self.requests.fetch_add(1, Ordering::SeqCst); + Ok(Box::pin(futures::stream::iter([Ok( + agen::llm_client::event::Event::Status(agen::llm_client::event::StatusEvent { + status: agen::llm_client::event::ResponseStatus::Completed, + }), + )]))) + } + + fn clone_boxed(&self) -> Box { + Box::new(self.clone()) + } + } + + #[tokio::test] + async fn feature_invocation_and_subject_refresh_remain_append_only_across_restore_replay() { + let runtime = tempfile::tempdir().unwrap(); + let _sandbox = worker_allocation::test_util::RuntimeDirSandbox::new(runtime.path()); + let dir = tempfile::tempdir().unwrap(); + let cwd = dir.path().join("workspace"); + std::fs::create_dir_all(&cwd).unwrap(); + let mut manifest = minimal_manifest(); + manifest.scope.allow[0].target = cwd.clone(); + manifest.feature.subjektiv.profile.enabled = true; + manifest + .feature + .subjektiv + .bind_workspace_settings(manifest::WorkspaceMemorySettingsSnapshot { + workspace_id: "workspace-test".into(), + settings_revision: 1, + language: "English".into(), + }) + .unwrap(); + let client = ResidentInvocationClient { + sink: Arc::default(), + requests: Arc::default(), + }; + let mut engine = + Engine::<_, Mutable, SessionHistoryMetadata>::new_annotated(client.clone()); + engine.set_system_prompt("fixed initial system prompt"); + let store = session_store::CombinedStore::new( + session_store::FsStore::new(dir.path().join("sessions")).unwrap(), + session_store::FsWorkerStore::new(dir.path().join("workers")).unwrap(), + ); + let authority = WorkerFilesystemAuthority::local(cwd.clone(), cwd.clone()); + let mut original = Worker::new( + manifest.clone(), + engine, + store.clone(), + WorkerWorkspaceContext::no_workspace(), + authority.clone(), + Scope::writable(&cwd).unwrap(), + ) + .await + .unwrap(); + let calls = Arc::new(AtomicUsize::new(0)); + let behavior_revision = Arc::new(AtomicUsize::new(1)); + let resident_source = || { + crate::feature::builtin::memory::ordinary_subjektiv_resident_summary_source( + &manifest, + Arc::new(RestoreSubjectResidentClient { + availability: memory::backend::MemoryResidentSummaryAvailability::Ready, + content: Some("literal /prepare()".into()), + load_count: Arc::default(), + behavior_revision: Some(behavior_revision.clone()), + }), + Arc::new(ArcSwap::from(PromptCatalog::builtins_only().unwrap())), + ) + .unwrap() + .unwrap() + }; + original.attach_log_writer(Arc::new(original.log_writer_handle())); + let source = resident_source(); + let report = original.install_features( + FeatureRegistryBuilder::new() + .with_module(RecoveryInvocationFeature { + calls: calls.clone(), + sink: original.sink(), + }) + .with_module( + crate::feature::builtin::memory::SubjektivResidentRestoreRefreshFeature::new( + source.clone(), + original.prompts(), + "workspace-test", + ), + ), + ); + assert!(!report.has_errors(), "{report:?}"); + original.install_system_prompt_contribution(Some(source), None); + original.set_system_prompt_template( + SystemPromptTemplate::parse( + "default", + crate::prompt::source::PromptCatalogSource::builtins_only(), + ) + .unwrap(), + ); + original.materialize_durable_session_head().await.unwrap(); + behavior_revision.store(2, Ordering::SeqCst); + *client.sink.lock().unwrap() = Some(original.sink()); + let pending = original.pending_submission_handle(); + pending + .accept_notification( + "composed-notify".into(), + "resident context notification".into(), + ) + .unwrap(); + for (notification, extension) in pending.prepare_notification_batch() { + original.pending_notifies.push_durable_notify( + notification.message, + notification.provenance, + extension, + ); + } + let input = vec![recovery_invocation("composed")]; + pending + .accept("composed-submit".into(), input.clone(), false) + .unwrap(); + let Some(PendingActivation::Submission(submission)) = + pending.prepare_next_activation(None).unwrap() + else { + panic!("expected submission") + }; + let (sender, mut receiver) = tokio::sync::oneshot::channel(); + let result = original + .run_with_input_extensions_and_commit_hook( + submission.input, + vec![pending.activation_extension()], + submission.provenance, + crate::controller::input_committed_hook( + pending.clone(), + submission.submission_id, + sender, + ), + ) + .await + .unwrap(); + // No Controller receiver was polled while the invocation/refresh ran. + assert!(receiver.try_recv().is_ok()); + assert!(pending.state.lock().unwrap().activating.is_none()); + assert_eq!( + client.requests.load(Ordering::SeqCst), + 1, + "{result:?}; history={:?}", + original.history() + ); + assert_eq!( + calls.load(Ordering::SeqCst), + 1, + "resident text must not invoke a handler" + ); + let history_before = original.history().to_vec(); + let session_id = original.session_id(); + let segment_id = original.segment_id(); + drop(original); + + let mut restored = + Worker::, _>::restore_from_manifest_with_context_and_model_client( + session_id, + segment_id, + manifest.clone(), + store, + crate::prompt::source::PromptCatalogSource::builtins_only(), + WorkerWorkspaceContext::no_workspace(), + authority, + Some(Box::new(client.clone())), + ) + .await + .unwrap(); + restored.attach_log_writer(Arc::new(restored.log_writer_handle())); + assert!( + restored + .pending_submission_handle() + .prepare_next_activation(None) + .unwrap() + .is_none(), + "notification/invocation checkpoints must not resurrect committed input after restore" + ); + assert_eq!( + restored + .session + .pending_activations + .lock() + .unwrap() + .invocation_receipts + .len(), + 1 + ); + assert_eq!( + restored + .session + .pending_activations + .lock() + .unwrap() + .notification_receipts + .len(), + 1 + ); + let source = resident_source(); + let report = restored.install_features( + FeatureRegistryBuilder::new() + .with_module(RecoveryInvocationFeature { + calls: calls.clone(), + sink: restored.sink(), + }) + .with_module( + crate::feature::builtin::memory::SubjektivResidentRestoreRefreshFeature::new( + source.clone(), + restored.prompts(), + "workspace-test", + ), + ), + ); + assert!(!report.has_errors(), "{report:?}"); + restored.install_system_prompt_contribution(Some(source), None); + *client.sink.lock().unwrap() = Some(restored.sink()); + restored.run(input).await.unwrap(); + assert_eq!(client.requests.load(Ordering::SeqCst), 2); + assert_eq!( + calls.load(Ordering::SeqCst), + 1, + "restore/replay must not repeat the business effect" + ); + assert_eq!(&restored.history()[..history_before.len()], history_before); + let entries = restored.sink().subscribe_with_snapshot().0; + assert_eq!( + entries + .iter() + .filter(|entry| matches!(entry, + LogEntry::AnnotatedSystemItem { entry, .. } + if matches!(entry.item, SystemItem::FeatureInvocationResult { .. }) + )) + .count(), + 2 // Explicit replay displays the cached result; it does not repeat the effect. + ); + assert!(entries.iter().any(|entry| matches!(entry, + LogEntry::AnnotatedSystemItem { entry, .. } + if matches!(entry.item, SystemItem::ResidentSummaryRefresh { .. }) + ))); + } + #[tokio::test] async fn feature_invocation_restart_reuses_completed_results_and_fences_incomplete_starts() { let runtime = tempfile::tempdir().unwrap(); diff --git a/crates/workspace-server/src/server.rs b/crates/workspace-server/src/server.rs index eaf204c55..a70eb7f8d 100644 --- a/crates/workspace-server/src/server.rs +++ b/crates/workspace-server/src/server.rs @@ -162,11 +162,11 @@ use crate::hosts::{ RuntimeRegistryError, RuntimeRegistryUnregisterResult, TicketWorkerRole, WorkerCompletionsRequest, WorkerCompletionsResult, WorkerControlOperation, WorkerCreateBinding, WorkerInputDisposition, WorkerInputKind, WorkerInputRequest, WorkerInputResult, - WorkerLifecycleRequest, WorkerLifecycleResult, WorkerSpawnAcceptanceRequirement, - WorkerSpawnIntent, WorkerSpawnRequest, WorkerSpawnResult, WorkerSpawnWorkingDirectoryRequest, - WorkerTicketAssignmentRequest, WorkspaceRuntimeAuthorization, - is_disallowed_remote_runtime_address, is_loopback_runtime_origin, - worker_spawn_create_fingerprint, workspace_worker_summary, + WorkerLifecycleRequest, WorkerLifecycleResult, WorkerSpawnAcceptanceEvidence, + WorkerSpawnAcceptanceRequirement, WorkerSpawnIntent, WorkerSpawnRequest, WorkerSpawnResult, + WorkerSpawnWorkingDirectoryRequest, WorkerTicketAssignmentRequest, + WorkspaceRuntimeAuthorization, is_disallowed_remote_runtime_address, + is_loopback_runtime_origin, worker_spawn_create_fingerprint, workspace_worker_summary, }; use crate::memory_backend::execute_memory_backend_operation_with_authority; use crate::memory_staging::{ @@ -1379,6 +1379,56 @@ impl workdir::WorkdirSession for ExternalProviderWorkdirSession { } } +// Per-recipient reservation. No lock is held across Runtime calls: subscription +// callbacks may reenter the attention path while send_input runs. +#[derive(Default)] +struct OrchestratorAttentionState { + sending: bool, + restore_recheck: bool, + accepted: Option, + pending: Option, +} + +#[derive(Clone)] +struct OrchestratorAttentionDelivery { + fingerprint: String, + request_id: String, + content: String, +} + +struct OrchestratorAttentionReservation(Option>>); + +impl OrchestratorAttentionReservation { + fn complete(mut self) -> bool { + let state = self.0.take().expect("active attention reservation"); + let mut state = state.lock().unwrap_or_else(|p| p.into_inner()); + // Release and take deferred work atomically. A Restore arriving after + // release can reserve its own check; one arriving before is ours to drain. + state.sending = false; + std::mem::take(&mut state.restore_recheck) + } +} + +impl Drop for OrchestratorAttentionReservation { + fn drop(&mut self) { + if let Some(state) = &self.0 { + state.lock().unwrap_or_else(|p| p.into_inner()).sending = false; + } + } +} + +fn orchestrator_attention_state( + api: &WorkspaceApi, + worker: &RuntimeWorkerRef, +) -> Arc> { + api.orchestrator_attention + .lock() + .unwrap_or_else(|p| p.into_inner()) + .entry(worker.clone()) + .or_default() + .clone() +} + #[derive(Clone)] pub struct WorkspaceApi { pub(crate) config: ServerConfig, @@ -1396,7 +1446,8 @@ pub struct WorkspaceApi { runtime_binding_expectations: Arc>>, companion: Arc, orchestrator_spawn_lock: Arc>, - orchestrator_attention_fingerprint: Arc>>, + orchestrator_attention: + Arc>>>>, backend_job_drain_requests: Arc, observation_proxy: BackendObservationProxy, runtime_subscription_broker: RuntimeSubscriptionBroker, @@ -3247,7 +3298,7 @@ impl WorkspaceApi { runtime_binding_expectations: Arc::new(RwLock::new(HashMap::new())), companion, orchestrator_spawn_lock: Arc::new(std::sync::Mutex::new(())), - orchestrator_attention_fingerprint: Arc::new(Mutex::new(None)), + orchestrator_attention: Arc::new(Mutex::new(HashMap::new())), backend_job_drain_requests: Arc::new(AtomicU64::new(0)), observation_proxy, runtime_subscription_broker, @@ -4739,6 +4790,7 @@ impl WorkspaceApi { for (alias, workdir_id, reservation_id, capabilities) in &reserved_attachments { let attachment = WorkerWorkdirLinkRecord { + connection_id: String::new(), workspace_id: self.config.workspace_id.clone(), worker: worker_ref.clone(), workdir_id: workdir_id.clone(), @@ -5555,6 +5607,49 @@ fn contract_request_headers( Ok(headers) } +/// Self APIs derive identity from authenticated middleware, never caller-supplied hints. +fn current_worker_contract_headers( + context: &server_api::ServerRequestContext, +) -> std::result::Result { + let source = context.runtime_source.as_ref().ok_or_else(|| { + ApiError::from(Error::WorkspacePermissionDenied( + "self Workdir requests require a verified Runtime Worker source".to_string(), + )) + .into_repository_api_error() + })?; + let worker_id = source.worker_id.as_deref().ok_or_else(|| { + ApiError::from(Error::WorkspacePermissionDenied( + "self Workdir requests require a Runtime-bound Worker identity".to_string(), + )) + .into_repository_api_error() + })?; + let mut headers = contract_request_headers(context)?; + for (name, expected) in [ + ("x-yoi-runtime-id", source.runtime_id.as_str()), + ("x-yoi-worker-id", worker_id), + ] { + if headers + .get(name) + .is_some_and(|value| value.as_bytes() != expected.as_bytes()) + { + return Err(ApiError::from(Error::WorkspacePermissionDenied( + "self Workdir request identity does not match its verified source".to_string(), + )) + .into_repository_api_error()); + } + headers.insert( + name, + HeaderValue::from_str(expected).map_err(|_| { + ApiError::from(Error::WorkerSourceIdentity( + "invalid verified Worker identity".to_string(), + )) + .into_repository_api_error() + })?, + ); + } + Ok(headers) +} + async fn attach_server_origin_context(mut request: Request, next: Next) -> Response { let actor = request.extensions().get::().cloned(); let origin = request_origin(request.headers()); @@ -5760,6 +5855,7 @@ fn generated_workspace_contract_router(service: ServerApiContractService) -> Rou .merge(server_api::server_api_axum::subjektiv_subject_get( service.clone(), )) + .merge(server_api::server_api_axum::subjektiv_subject_behavior_update(service.clone())) .merge(server_api::server_api_axum::subjektiv_resident_surface( service.clone(), )) @@ -5841,6 +5937,10 @@ fn generated_workspace_contract_router(service: ServerApiContractService) -> Rou .merge(server_api::server_api_axum::runtime_workdir_cleanup( service.clone(), )) + .merge(server_api::server_api_axum::current_worker_workdir_catalog( + service.clone(), + )) + .merge(server_api::server_api_axum::current_worker_workdir_attachment_list(service.clone())) .merge(server_api::server_api_axum::current_worker_workdir_attach( service.clone(), )) @@ -7418,6 +7518,26 @@ impl server_api::ServerApi for ServerApiContractService { .map_err(ApiError::into_repository_api_error) } + async fn subjektiv_subject_behavior_update( + &self, + workspace_id: String, + subject_id: String, + request: server_api::SubjektivSubjectBehaviorUpdateRequest, + ) -> std::result::Result + { + scoped_update_subjektiv_subject_behavior( + State(self.workspace_api()?.clone()), + AxumPath(ScopedSubjektivSubjectPath { + workspace_id, + subject_id, + }), + Json(request), + ) + .await + .map(|Json(response)| response) + .map_err(ApiError::into_repository_api_error) + } + async fn subjektiv_resident_surface( &self, workspace_id: String, @@ -10355,6 +10475,53 @@ impl server_api::ServerApi for ServerApiContractService { .map_err(ApiError::into_repository_api_error) } + async fn current_worker_workdir_catalog( + &self, + context: server_api::ServerRequestContext, + workspace_id: String, + query: server_api::CurrentWorkerWorkdirCatalogQuery, + ) -> std::result::Result< + server_api::CurrentWorkerWorkdirCatalogResponse, + server_api::RepositoryApiError, + > { + let api = self.workspace_api()?; + validate_workspace_scope(api, &workspace_id) + .map_err(ApiError::into_repository_api_error)?; + current_worker_identity( + api, + &workspace_id, + ¤t_worker_contract_headers(&context)?, + ) + .map_err(ApiError::from) + .map_err(ApiError::into_repository_api_error)?; + list_current_worker_workdir_catalog(api, query).map_err(ApiError::into_repository_api_error) + } + + async fn current_worker_workdir_attachment_list( + &self, + context: server_api::ServerRequestContext, + workspace_id: String, + query: server_api::CurrentWorkerWorkdirAttachmentListQuery, + ) -> std::result::Result< + server_api::CurrentWorkerWorkdirAttachmentListResponse, + server_api::RepositoryApiError, + > { + let api = self.workspace_api()?; + validate_workspace_scope(api, &workspace_id) + .map_err(ApiError::into_repository_api_error)?; + let worker = current_worker_identity( + api, + &workspace_id, + ¤t_worker_contract_headers(&context)?, + ) + .map_err(ApiError::from) + .map_err(ApiError::into_repository_api_error)?; + let session_lock = current_worker_session_lock(api, &worker); + let _session_guard = session_lock.lock().await; + list_current_worker_workdir_attachments(api, &worker, query) + .map_err(ApiError::into_repository_api_error) + } + async fn current_worker_workdir_attach( &self, context: server_api::ServerRequestContext, @@ -10367,7 +10534,7 @@ impl server_api::ServerApi for ServerApiContractService { let Json(response) = scoped_attach_current_worker_workdir( State(self.workspace_api()?.clone()), AxumPath(ScopedWorkspacePath { workspace_id }), - contract_request_headers(&context)?, + current_worker_contract_headers(&context)?, Json(AttachCurrentWorkerWorkdirRequest { alias: request.alias, working_directory_id: request.working_directory_id, @@ -10383,6 +10550,7 @@ impl server_api::ServerApi for ServerApiContractService { context: server_api::ServerRequestContext, workspace_id: String, alias: String, + query: server_api::CurrentWorkerWorkdirDetachQuery, ) -> std::result::Result< server_api::CurrentWorkerWorkdirAttachmentResponse, server_api::RepositoryApiError, @@ -10390,7 +10558,8 @@ impl server_api::ServerApi for ServerApiContractService { let Json(response) = scoped_detach_current_worker_workdir( State(self.workspace_api()?.clone()), AxumPath((workspace_id, alias)), - contract_request_headers(&context)?, + current_worker_contract_headers(&context)?, + query, ) .await .map_err(ApiError::into_repository_api_error)?; @@ -10413,7 +10582,7 @@ impl server_api::ServerApi for ServerApiContractService { .clone(), ), AxumPath(ScopedWorkspacePath { workspace_id }), - contract_request_headers(&context) + current_worker_contract_headers(&context) .map_err(server_api::WorkdirOperationApiError::api)?, Json(WorkspaceWorkdirSessionOperationRequest { target_workdir: request.target_workdir, @@ -16337,6 +16506,7 @@ async fn scoped_attach_current_worker_workdir( } close_current_worker_attachment_session_locked(&api, &worker).await?; let link = api.store.attach_worker_workdir(&WorkerWorkdirLinkRecord { + connection_id: String::new(), workspace_id: api.config.workspace_id.clone(), worker: worker.clone(), workdir_id: workdir_id.to_string(), @@ -16396,56 +16566,161 @@ async fn scoped_attach_current_worker_workdir( })) } +fn list_current_worker_workdir_catalog( + api: &WorkspaceApi, + query: server_api::CurrentWorkerWorkdirCatalogQuery, +) -> ApiResult { + // No Runtime inventory refresh: both projection inputs and complete digest are authoritative + // Backend records captured by the store in one read transaction. + let page = api.store.workdir_catalog_page( + &api.config.workspace_id, + query.limit.unwrap_or(50), + query.cursor.as_deref(), + )?; + let items = page + .entries + .into_iter() + .filter_map(|entry| { + if matches!( + entry.record.source, + WorkdirRegistrySource::ExternalGrant { .. } + ) && entry.external_grant_permissions.is_none() + { + return None; + } + let mut summary = workdir_summary_from_record( + &entry.record, + entry.repository_key.as_deref(), + entry.external_grant_permissions, + ); + summary.occupied_by = entry.occupied_by; + Some(server_api::WorkingDirectorySummary::from(summary)) + }) + .collect(); + Ok(server_api::CurrentWorkerWorkdirCatalogResponse { + workspace_id: api.config.workspace_id.clone(), + items, + next_cursor: page.next_cursor, + revision: page.revision, + }) +} + +fn list_current_worker_workdir_attachments( + api: &WorkspaceApi, + worker: &RuntimeWorkerRef, + query: server_api::CurrentWorkerWorkdirAttachmentListQuery, +) -> ApiResult { + let limit = query.limit.unwrap_or(50); + let offset = query.offset.unwrap_or(0); + if limit == 0 || limit > 100 { + return Err( + Error::InvalidInput("attachment page limit must be 1..=100".to_string()).into(), + ); + } + let (mut links, revision) = api.store.list_worker_workdir_links_page_with_revision( + &api.config.workspace_id, + worker, + limit + 1, + offset, + query.connection_id.as_deref(), + )?; + let next_offset = + if links.len() > limit as usize { + Some(offset.checked_add(limit).ok_or_else(|| { + Error::InvalidInput("attachment page offset overflow".to_string()) + })?) + } else { + None + }; + links.truncate(limit as usize); + Ok(server_api::CurrentWorkerWorkdirAttachmentListResponse { + workspace_id: api.config.workspace_id.clone(), + items: links + .into_iter() + .map(|link| server_api::CurrentWorkerWorkdirAttachmentItem { + alias: link.alias, + working_directory_id: link.workdir_id, + capabilities: link.capabilities, + connection_id: link.connection_id, + }) + .collect(), + next_offset, + revision, + }) +} + async fn scoped_detach_current_worker_workdir( State(api): State, AxumPath((workspace_id, alias)): AxumPath<(String, String)>, headers: HeaderMap, + query: server_api::CurrentWorkerWorkdirDetachQuery, ) -> ApiResult> { validate_workspace_scope(&api, &workspace_id)?; let alias = workdir::WorkdirAttachmentAlias::new(alias) .map_err(|error| Error::InvalidInput(error.to_string()))?; let worker = current_worker_identity(&api, &workspace_id, &headers)?; - let session_lock = current_worker_session_lock(&api, &worker); + detach_current_worker_workdir( + &api, + &worker, + alias.as_str(), + query.expected_connection_id.as_deref(), + ) + .await + .map(Json) +} + +async fn detach_current_worker_workdir( + api: &WorkspaceApi, + worker: &RuntimeWorkerRef, + alias: &str, + expected_connection_id: Option<&str>, +) -> ApiResult { + let session_lock = current_worker_session_lock(api, worker); let _session_guard = session_lock.lock().await; let link = api .store - .list_worker_workdir_links(&api.config.workspace_id, &worker)? + .list_worker_workdir_links(&api.config.workspace_id, worker)? .into_iter() - .find(|link| link.unlinked_at.is_none() && link.alias == alias.as_str()) + .find(|link| link.unlinked_at.is_none() && link.alias == alias) .ok_or_else(|| { Error::WorkdirAttachmentConflict(format!( "Worker {}:{} has no Workdir attachment `{alias}`", worker.runtime_id, worker.worker_id )) })?; - close_current_worker_attachment_session_locked(&api, &worker).await?; - close_current_worker_command_sessions_for_alias_locked(&api, &worker, alias.as_str()).await?; - api.store.detach_worker_workdir( + if expected_connection_id.is_some_and(|expected| expected != link.connection_id) { + return Err(Error::WorkdirAttachmentConflict(format!( + "Workdir attachment `{alias}` connection changed during detach" + )) + .into()); + } + close_current_worker_attachment_session_locked(api, worker).await?; + close_current_worker_command_sessions_for_alias_locked(api, worker, alias).await?; + // Even unconditional HTTP requests condition the mutation on the lifetime just read + // under the session lock, so a concurrent ledger replacement cannot be removed. + api.store.detach_worker_workdir_connection( &api.config.workspace_id, - &worker, - Some(&link.workdir_id), + worker, + alias, + &link.connection_id, &Utc::now().to_rfc3339_opts(SecondsFormat::Millis, true), )?; - if let Err(error) = sync_runtime_worker_workdir_attachments(&api, &worker) { - let mut restored = link.clone(); - restored.unlinked_at = None; - api.store.attach_worker_workdir(&restored)?; + if let Err(error) = sync_runtime_worker_workdir_attachments(api, worker) { + api.store.restore_worker_workdir_connection(&link)?; return Err(error); } let remaining = api .store - .list_worker_workdir_links(&api.config.workspace_id, &worker)? + .list_worker_workdir_links(&api.config.workspace_id, worker)? .into_iter() .filter(|link| link.unlinked_at.is_none()) .collect::>(); if remaining.len() == 1 && let Err(error) = - open_current_worker_workdir_session_locked(&api, &worker, &remaining[0]).await + open_current_worker_workdir_session_locked(api, worker, &remaining[0]).await { - let mut restored = link.clone(); - restored.unlinked_at = None; - api.store.attach_worker_workdir(&restored)?; - let _ = sync_runtime_worker_workdir_attachments(&api, &worker); + api.store.restore_worker_workdir_connection(&link)?; + let _ = sync_runtime_worker_workdir_attachments(api, worker); return Err(error.into()); } if let Err(error) = api @@ -16453,20 +16728,18 @@ async fn scoped_detach_current_worker_workdir( .refresh(&worker) .map_err(ApiError::from) { - let mut restored = link.clone(); - restored.unlinked_at = None; - api.store.attach_worker_workdir(&restored)?; - sync_runtime_worker_workdir_attachments(&api, &worker)?; - refresh_current_worker_session_locked(&api, &worker).await?; + api.store.restore_worker_workdir_connection(&link)?; + sync_runtime_worker_workdir_attachments(api, worker)?; + refresh_current_worker_session_locked(api, worker).await?; return Err(error); } - Ok(Json(server_api::CurrentWorkerWorkdirAttachmentResponse { + Ok(server_api::CurrentWorkerWorkdirAttachmentResponse { workspace_id: api.config.workspace_id.clone(), alias: alias.to_string(), working_directory_id: link.workdir_id, capabilities: workdir::WorkdirSessionCapabilities::EMPTY, attached: false, - })) + }) } fn validated_current_worker_attachment( @@ -16748,48 +17021,54 @@ async fn run_orchestrator_turn_end_hook(api: WorkspaceApi) { }; let mut worker_states = HashMap::new(); while let Some(update) = subscription.recv().await { - match update { - crate::runtime_subscription::BrokerSubscriptionEvent::Snapshot { snapshot, .. } => { - if let protocol::subscription::SubscriptionSnapshot::Workers { workers } = snapshot - { - worker_states.clear(); - for worker in workers { - let worker_id = worker.worker_id.to_string(); - maybe_dispatch_orchestrator_turn_end(&api, &worker_id, None, worker.state); - worker_states.insert(worker_id, worker.state); - } - } - } - crate::runtime_subscription::BrokerSubscriptionEvent::Event { payload, .. } => { - match payload { - protocol::subscription::SubscriptionEventPayload::WorkerUpserted { worker } => { - let worker_id = worker.worker_id.to_string(); - let previous = worker_states.insert(worker_id.clone(), worker.state); - maybe_dispatch_orchestrator_turn_end( - &api, - &worker_id, - previous, - worker.state, - ); - } - protocol::subscription::SubscriptionEventPayload::WorkerRemoved { - worker_id, - .. - } => { - worker_states.remove(worker_id.as_str()); - } - _ => {} - } - } - crate::runtime_subscription::BrokerSubscriptionEvent::Disconnected { .. } => { - worker_states.clear(); - } - crate::runtime_subscription::BrokerSubscriptionEvent::Rejected { .. } - | crate::runtime_subscription::BrokerSubscriptionEvent::Closed { .. } => return, + if !observe_orchestrator_turn_end(&api, &mut worker_states, update) { + return; } } } +// A subscription snapshot establishes an observation baseline, not a lifecycle +// event. Reconnection deliberately discards the old baseline. +fn observe_orchestrator_turn_end( + api: &WorkspaceApi, + worker_states: &mut HashMap, + update: crate::runtime_subscription::BrokerSubscriptionEvent, +) -> bool { + match update { + crate::runtime_subscription::BrokerSubscriptionEvent::Snapshot { snapshot, .. } => { + if let protocol::subscription::SubscriptionSnapshot::Workers { workers } = snapshot { + worker_states.clear(); + for worker in workers { + let worker_id = worker.worker_id.to_string(); + worker_states.insert(worker_id, worker.state); + } + } + } + crate::runtime_subscription::BrokerSubscriptionEvent::Event { payload, .. } => { + match payload { + protocol::subscription::SubscriptionEventPayload::WorkerUpserted { worker } => { + let worker_id = worker.worker_id.to_string(); + let previous = worker_states.insert(worker_id.clone(), worker.state); + maybe_dispatch_orchestrator_turn_end(api, &worker_id, previous, worker.state); + } + protocol::subscription::SubscriptionEventPayload::WorkerRemoved { + worker_id, + .. + } => { + worker_states.remove(worker_id.as_str()); + } + _ => {} + } + } + crate::runtime_subscription::BrokerSubscriptionEvent::Disconnected { .. } => { + worker_states.clear(); + } + crate::runtime_subscription::BrokerSubscriptionEvent::Rejected { .. } + | crate::runtime_subscription::BrokerSubscriptionEvent::Closed { .. } => return false, + } + true +} + fn maybe_dispatch_orchestrator_turn_end( api: &WorkspaceApi, worker_id: &str, @@ -16799,12 +17078,7 @@ fn maybe_dispatch_orchestrator_turn_end( use protocol::subscription::SubscriptionWorkerState; if current != SubscriptionWorkerState::Idle - || !matches!( - previous, - None | Some(SubscriptionWorkerState::Running) - | Some(SubscriptionWorkerState::Stopped) - | Some(SubscriptionWorkerState::Paused) - ) + || previous != Some(SubscriptionWorkerState::Running) { return; } @@ -16814,14 +17088,62 @@ fn maybe_dispatch_orchestrator_turn_end( if orchestrator.worker.runtime_id == EMBEDDED_WORKER_RUNTIME_ID && orchestrator.worker.worker_id == worker_id { - dispatch_orchestrator_queue_attention(api); + dispatch_orchestrator_queue_attention(api, &orchestrator.worker, false); } } -fn dispatch_orchestrator_queue_attention(api: &WorkspaceApi) { - let Some(orchestrator) = find_workspace_orchestrator(api) else { +fn dispatch_orchestrator_queue_attention( + api: &WorkspaceApi, + worker: &RuntimeWorkerRef, + retry_only: bool, +) { + with_orchestrator_attention_reservation(api, worker, retry_only, |state, pending| { + check_orchestrator_queue_attention(api, worker, state, pending); + }); +} + +fn with_orchestrator_attention_reservation( + api: &WorkspaceApi, + worker: &RuntimeWorkerRef, + mut retry_only: bool, + mut check: impl FnMut( + &Arc>, + Option, + ), +) { + let state = orchestrator_attention_state(api, worker); + loop { + let pending = { + let mut state = state.lock().unwrap_or_else(|p| p.into_inner()); + if state.sending || (retry_only && state.pending.is_none()) { + return; + } + state.sending = true; + state.pending.clone() + }; + let reservation = OrchestratorAttentionReservation(Some(state.clone())); + check(&state, pending); + if !reservation.complete() { + return; + } + // Accepted Restore is an explicit recheck even when the current check + // was a no-op or an already-online retry. Never infer it from Idle. + retry_only = false; + } +} + +fn check_orchestrator_queue_attention( + api: &WorkspaceApi, + worker: &RuntimeWorkerRef, + state: &Arc>, + pending: Option, +) { + // Unknown outcomes keep the original immutable payload and ID, even if the + // backlog or prompt projection changed before this retry. + if let Some(delivery) = pending { + send_orchestrator_attention(api, worker, &state, delivery); return; - }; + } let Ok(backend) = browser_ticket_backend(api) else { return; }; @@ -16850,9 +17172,7 @@ fn dispatch_orchestrator_queue_attention(api: &WorkspaceApi) { queued.extend(inprogress); queued.sort_by(|left, right| left.id.cmp(&right.id)); if queued.is_empty() { - *api.orchestrator_attention_fingerprint - .lock() - .unwrap_or_else(|poisoned| poisoned.into_inner()) = None; + state.lock().unwrap_or_else(|p| p.into_inner()).accepted = None; return; } let fingerprint = queued @@ -16860,11 +17180,12 @@ fn dispatch_orchestrator_queue_attention(api: &WorkspaceApi) { .map(|ticket| ticket.id.as_str()) .collect::>() .join("|"); - if api - .orchestrator_attention_fingerprint + if state .lock() - .unwrap_or_else(|poisoned| poisoned.into_inner()) - .as_deref() + .unwrap_or_else(|p| p.into_inner()) + .accepted + .as_ref() + .map(|delivery| delivery.fingerprint.as_str()) == Some(fingerprint.as_str()) { return; @@ -16915,22 +17236,42 @@ fn dispatch_orchestrator_queue_attention(api: &WorkspaceApi) { return; } }; - let accepted = api - .runtime - .send_input( - &orchestrator.worker, - WorkerInputRequest { - kind: WorkerInputKind::Notify, - content, - submission_request_id: None, - segments: None, - }, - ) - .is_ok_and(|result| result.state == InternalWorkerOperationState::Accepted); - if accepted { - *api.orchestrator_attention_fingerprint - .lock() - .unwrap_or_else(|poisoned| poisoned.into_inner()) = Some(fingerprint); + let delivery = OrchestratorAttentionDelivery { + fingerprint, + request_id: uuid::Uuid::now_v7().to_string(), + content, + }; + state.lock().unwrap_or_else(|p| p.into_inner()).pending = Some(delivery.clone()); + send_orchestrator_attention(api, worker, &state, delivery); +} + +fn send_orchestrator_attention( + api: &WorkspaceApi, + worker: &RuntimeWorkerRef, + state: &Mutex, + delivery: OrchestratorAttentionDelivery, +) { + // Bounded delivery-side retry, not an Idle-snapshot retry. Rejection and + // transport ambiguity leave pending available for the next attempt. + for _ in 0..2 { + let accepted = api + .runtime + .send_input( + worker, + WorkerInputRequest { + kind: WorkerInputKind::Notify, + content: delivery.content.clone(), + submission_request_id: Some(delivery.request_id.clone()), + segments: None, + }, + ) + .is_ok_and(|result| result.state == InternalWorkerOperationState::Accepted); + if accepted { + let mut state = state.lock().unwrap_or_else(|p| p.into_inner()); + state.accepted = Some(delivery); + state.pending = None; + return; + } } } @@ -17311,6 +17652,8 @@ fn subjektiv_subject_response( server_api::SubjektivSubjectResponse { id: subject.id, role: subject.role.as_str().to_string(), + behavior_md: subject.behavior_md, + behavior_revision: subject.behavior_revision, state: match subject.state { crate::subjektiv::SubjectState::Active => server_api::SubjektivSubjectState::Active, crate::subjektiv::SubjectState::Retired => server_api::SubjektivSubjectState::Retired, @@ -17330,9 +17673,11 @@ async fn scoped_create_subjektiv_subject( validate_workspace_scope(&api, &path.workspace_id)?; let role = crate::subjektiv::SubjectRole::new(request.role) .map_err(|error| Error::InvalidInput(error.to_string()))?; + crate::subjektiv::validate_subject_behavior(&request.behavior_md) + .map_err(|error| Error::InvalidInput(error.to_string()))?; let subject = open_subjektiv_store(&api)? - .create_subject(role) - .map_err(|error| Error::Store(error.to_string()))?; + .create_subject_with_behavior(role, request.behavior_md) + .map_err(subjektiv_store_error)?; Ok(( StatusCode::CREATED, Json(subjektiv_subject_response(&api, subject)), @@ -17424,6 +17769,24 @@ async fn scoped_get_subjektiv_subject( Ok(Json(subjektiv_subject_response(&api, subject))) } +async fn scoped_update_subjektiv_subject_behavior( + State(api): State, + AxumPath(path): AxumPath, + Json(request): Json, +) -> ApiResult> { + validate_workspace_scope(&api, &path.workspace_id)?; + crate::subjektiv::validate_subject_behavior(&request.behavior_md) + .map_err(|error| Error::InvalidInput(error.to_string()))?; + let subject = open_subjektiv_store(&api)? + .update_subject_behavior( + &path.subject_id, + request.expected_behavior_revision, + request.behavior_md, + ) + .map_err(subjektiv_store_error)?; + Ok(Json(subjektiv_subject_response(&api, subject))) +} + fn require_subjektiv_subject( store: &crate::subjektiv::SubjektivStore, subject_id: &str, @@ -19030,6 +19393,23 @@ async fn scoped_subjektiv_memory_backend( resident, )) } + server_api::SubjektivMemoryBackendOperation::ResidentContext(_) => { + require_subjektiv_worker_authority( + authority, + SubjektivWorkerAuthority::Subject, + "resident subject context read", + )?; + let resident = store + .resident_context(&subject_id) + .map_err(subjektiv_store_error)?; + server_api::SubjektivMemoryBackendResponse::ResidentContext( + server_api::SubjektivResidentContextOutput { + behavior_md: resident.subject.behavior_md, + behavior_revision: resident.subject.behavior_revision, + memory_surface: resident_summary_output(resident.surface), + }, + ) + } server_api::SubjektivMemoryBackendOperation::Query(input) => { server_api::SubjektivMemoryBackendResponse::Query(subjektiv_memory_query( &store, @@ -20404,6 +20784,7 @@ fn subjektiv_store_error(error: crate::subjektiv::SubjektivError) -> Error { Error::WorkspacePermissionDenied(format!("subject_scope_mismatch: {error}")) } crate::subjektiv::SubjektivError::RevisionConflict { .. } + | crate::subjektiv::SubjektivError::SubjectBehaviorConflict { .. } | crate::subjektiv::SubjektivError::SurfaceGenerationConflict(_) => { Error::RepositoryConflict(format!("revision_conflict: {error}")) } @@ -22213,14 +22594,37 @@ async fn scoped_start_workspace_orchestrator( let mut disposition = "created"; if let Some(existing) = find_workspace_orchestrator(&api) { if workspace_orchestrator_is_online(&existing) { + drop(_guard); + // Already-online is not a new startup. Only retry outstanding delivery. + dispatch_orchestrator_queue_attention(&api, &existing.worker, true); return Ok(Json(workspace_orchestrator_response(&api, "existing"))); } + let attention = orchestrator_attention_state(&api, &existing.worker); + let before_restore = attention + .lock() + .unwrap_or_else(|p| p.into_inner()) + .accepted + .as_ref() + .map(|delivery| delivery.request_id.clone()); let restored = api.restore_workspace_worker(&existing.worker)?; if restored.state == server_api::WorkerRestoreState::Accepted { - *api.orchestrator_attention_fingerprint - .lock() - .unwrap_or_else(|poisoned| poisoned.into_inner()) = None; - dispatch_orchestrator_queue_attention(&api); + { + let mut attention = attention.lock().unwrap_or_else(|p| p.into_inner()); + // Retire only pre-Restore success. Concurrent reservations, unknown + // outcomes and newly accepted deliveries must survive Restore. + if attention.pending.is_none() + && attention.accepted.as_ref().map(|d| &d.request_id) == before_restore.as_ref() + { + attention.accepted = None; + } + // A reserved caller may already have decided not to send. Carry + // the explicit Restore check through its completion in that case. + if attention.sending { + attention.restore_recheck = true; + } + } + drop(_guard); + dispatch_orchestrator_queue_attention(&api, &existing.worker, false); return Ok(Json(workspace_orchestrator_response(&api, "restored"))); } if !diagnostics_indicate_unrecoverable_pending_workspace_restore(&restored.diagnostics) { @@ -22299,10 +22703,8 @@ async fn scoped_start_workspace_orchestrator( Some("builtin:orchestrator".to_string()), WorkerRegistryDisplayNamePolicy::UseProvided, )?; - *api.orchestrator_attention_fingerprint - .lock() - .unwrap_or_else(|poisoned| poisoned.into_inner()) = None; - dispatch_orchestrator_queue_attention(&api); + drop(_guard); + dispatch_orchestrator_queue_attention(&api, &worker.worker, false); Ok(Json(workspace_orchestrator_response(&api, disposition))) } @@ -27634,6 +28036,11 @@ async fn create_workspace_worker_inner( Ok(result) => result, Err(error) => return Err(error.into()), }; + let result = if resolved_subjektiv_attached { + restore_reused_subject_worker(&api, result)? + } else { + result + }; Ok(Json(record_browser_worker_spawn( &api, runtime_id, @@ -27643,6 +28050,52 @@ async fn create_workspace_worker_inner( )?)) } +fn restore_reused_subject_worker( + api: &WorkspaceApi, + mut result: WorkerSpawnResult, +) -> ApiResult { + if result.state != InternalWorkerOperationState::Accepted { + return Ok(result); + } + let Some(worker) = result.worker.as_ref() else { + return Ok(result); + }; + if worker.state != "stopped" { + return Ok(result); + } + + let worker_ref = worker.worker.clone(); + let restored = api.restore_workspace_worker(&worker_ref)?; + if restored.state != server_api::WorkerRestoreState::Accepted { + let message = runtime_diagnostics_message(&restored.diagnostics); + return Err(ApiError::with_diagnostics( + Error::RuntimeOperationFailed { + runtime_id: worker_ref.runtime_id, + code: "subject_worker_restore_rejected".to_string(), + message, + }, + restored.diagnostics, + )); + } + let worker = restored + .worker + .ok_or_else(|| Error::RuntimeOperationFailed { + runtime_id: worker_ref.runtime_id, + code: "subject_worker_restore_missing_summary".to_string(), + message: "Runtime restored the existing subject Worker without returning its summary" + .to_string(), + })?; + result.worker = Some(worker); + result.diagnostics.extend(restored.diagnostics); + result + .acceptance_evidence + .push(WorkerSpawnAcceptanceEvidence { + kind: "subject_worker_restored".to_string(), + detail: "the existing stopped subject Worker was restored".to_string(), + }); + Ok(result) +} + fn record_browser_worker_spawn( api: &WorkspaceApi, requested_runtime_id: String, @@ -31011,6 +31464,7 @@ fn link_worker_to_workdir( ) -> ApiResult<()> { let timestamp = now_registry_timestamp(); let record = WorkerWorkdirLinkRecord { + connection_id: String::new(), workspace_id: api.config.workspace_id.clone(), worker: worker_record.worker.clone(), workdir_id: workdir_id.to_string(), @@ -33150,6 +33604,7 @@ mod tests { .unwrap(); schedule_external_workdir_expiry(&api, &grant).unwrap(); let link = WorkerWorkdirLinkRecord { + connection_id: String::new(), workspace_id: TEST_WORKSPACE_ID.to_string(), worker: worker.clone(), workdir_id: grant.workdir_id.clone(), @@ -34118,6 +34573,7 @@ mod tests { }) .unwrap(); let repository_link = WorkerWorkdirLinkRecord { + connection_id: String::new(), workspace_id: TEST_WORKSPACE_ID.to_string(), worker: worker.clone(), workdir_id: "analysis-repository".to_string(), @@ -34127,6 +34583,7 @@ mod tests { unlinked_at: None, }; let external_link = WorkerWorkdirLinkRecord { + connection_id: String::new(), workspace_id: TEST_WORKSPACE_ID.to_string(), worker: worker.clone(), workdir_id: grant.working_directory_id.clone(), @@ -35456,7 +35913,7 @@ mod tests { updated_at: "2026-01-01T00:00:00Z".to_owned(), revoked_at: None, }, - false, + true, ) .unwrap(); } @@ -35898,6 +36355,7 @@ mod tests { }; api.store.upsert_workdir_registry(&workdir).unwrap(); let link = WorkerWorkdirLinkRecord { + connection_id: String::new(), workspace_id: TEST_WORKSPACE_ID.to_string(), worker: worker.worker.clone(), workdir_id: workdir.workdir_id.clone(), @@ -36002,6 +36460,7 @@ mod tests { .create_external_workdir_grant(&grant, &workdir) .unwrap(); let link = WorkerWorkdirLinkRecord { + connection_id: String::new(), workspace_id: TEST_WORKSPACE_ID.to_string(), worker: worker.worker.clone(), workdir_id: workdir.workdir_id.clone(), @@ -36902,6 +37361,7 @@ mod tests { .unwrap(); api.store .attach_worker_workdir(&WorkerWorkdirLinkRecord { + connection_id: String::new(), workspace_id: TEST_WORKSPACE_ID.to_string(), worker: worker.clone(), workdir_id: workdir_id.to_string(), @@ -39018,18 +39478,80 @@ mod tests { app.clone(), "POST", &format!("/api/w/{TEST_WORKSPACE_ID}/subjektiv/subjects"), - Some(serde_json::json!({ "role": " Browser author " })), + Some(serde_json::json!({ + "role": " Browser author ", + "behavior_md": "Prefer explicit evidence.\nAsk when uncertain." + })), &owner_token, StatusCode::CREATED, ) .await; assert_eq!(created["role"], " Browser author "); + assert_eq!( + created["behavior_md"], + "Prefer explicit evidence.\nAsk when uncertain." + ); + assert_eq!(created["behavior_revision"], 0); assert_eq!(created["state"], "active"); assert_eq!(created["store_revision"], 0); assert!(created.get("current_worker").is_none()); let created_id = created["id"].as_str().unwrap(); let persisted = store.subject(created_id).unwrap().unwrap(); assert_eq!(persisted.role.as_str(), " Browser author "); + assert_eq!(persisted.behavior_revision, 0); + let behavior_uri = + format!("/api/w/{TEST_WORKSPACE_ID}/subjektiv/subjects/{created_id}/behavior"); + request_json( + app.clone(), + "PATCH", + &behavior_uri, + Some(serde_json::json!({ + "expected_behavior_revision": 0, + "behavior_md": "Updated without rewriting history." + })), + StatusCode::UNAUTHORIZED, + ) + .await; + let updated = request_json_authenticated( + app.clone(), + "PATCH", + &behavior_uri, + Some(serde_json::json!({ + "expected_behavior_revision": 0, + "behavior_md": "Updated without rewriting history." + })), + &owner_token, + StatusCode::OK, + ) + .await; + assert_eq!(updated["behavior_md"], "Updated without rewriting history."); + assert_eq!(updated["behavior_revision"], 1); + request_json_authenticated( + app.clone(), + "PATCH", + &behavior_uri, + Some(serde_json::json!({ + "expected_behavior_revision": 0, + "behavior_md": "stale write" + })), + &owner_token, + StatusCode::CONFLICT, + ) + .await; + let cleared = request_json_authenticated( + app.clone(), + "PATCH", + &behavior_uri, + Some(serde_json::json!({ + "expected_behavior_revision": 1, + "behavior_md": "" + })), + &owner_token, + StatusCode::OK, + ) + .await; + assert_eq!(cleared["behavior_md"], ""); + assert_eq!(cleared["behavior_revision"], 2); assert!( api.store .current_worker_singleton_owner( @@ -39130,6 +39652,90 @@ mod tests { ); } + #[tokio::test] + async fn subjektiv_browser_behavior_create_update_clear_and_conflict_are_typed() { + let workspace = tempfile::tempdir().unwrap(); + init_clean_git_workspace(workspace.path()); + let api = test_api(workspace.path()).await; + + let (status, Json(created)) = scoped_create_subjektiv_subject( + State(api.clone()), + AxumPath(ScopedWorkspacePath { + workspace_id: TEST_WORKSPACE_ID.to_string(), + }), + Json(server_api::SubjektivSubjectCreateRequest { + role: "companion".to_string(), + behavior_md: "Prefer explicit evidence.".to_string(), + }), + ) + .await + .unwrap(); + assert_eq!(status, StatusCode::CREATED); + assert_eq!(created.behavior_revision, 0); + assert_eq!(created.behavior_md, "Prefer explicit evidence."); + + let Json(updated) = scoped_update_subjektiv_subject_behavior( + State(api.clone()), + AxumPath(ScopedSubjektivSubjectPath { + workspace_id: TEST_WORKSPACE_ID.to_string(), + subject_id: created.id.clone(), + }), + Json(server_api::SubjektivSubjectBehaviorUpdateRequest { + expected_behavior_revision: 0, + behavior_md: "Ask when uncertain.".to_string(), + }), + ) + .await + .unwrap(); + assert_eq!(updated.behavior_revision, 1); + assert_eq!(updated.behavior_md, "Ask when uncertain."); + + let conflict = scoped_update_subjektiv_subject_behavior( + State(api.clone()), + AxumPath(ScopedSubjektivSubjectPath { + workspace_id: TEST_WORKSPACE_ID.to_string(), + subject_id: created.id.clone(), + }), + Json(server_api::SubjektivSubjectBehaviorUpdateRequest { + expected_behavior_revision: 0, + behavior_md: "Stale edit".to_string(), + }), + ) + .await + .unwrap_err(); + assert_eq!(conflict.into_response().status(), StatusCode::CONFLICT); + + let Json(cleared) = scoped_update_subjektiv_subject_behavior( + State(api.clone()), + AxumPath(ScopedSubjektivSubjectPath { + workspace_id: TEST_WORKSPACE_ID.to_string(), + subject_id: created.id, + }), + Json(server_api::SubjektivSubjectBehaviorUpdateRequest { + expected_behavior_revision: 1, + behavior_md: String::new(), + }), + ) + .await + .unwrap(); + assert_eq!(cleared.behavior_revision, 2); + assert!(cleared.behavior_md.is_empty()); + + let oversized = scoped_create_subjektiv_subject( + State(api), + AxumPath(ScopedWorkspacePath { + workspace_id: TEST_WORKSPACE_ID.to_string(), + }), + Json(server_api::SubjektivSubjectCreateRequest { + role: "reviewer".to_string(), + behavior_md: "x".repeat(server_api::SUBJEKTIV_MAX_BEHAVIOR_BYTES + 1), + }), + ) + .await + .unwrap_err(); + assert_eq!(oversized.into_response().status(), StatusCode::BAD_REQUEST); + } + #[tokio::test] async fn subjektiv_browser_reads_are_bounded_scoped_and_revision_exact() { let workspace = tempfile::tempdir().unwrap(); @@ -39412,7 +40018,7 @@ mod tests { async fn ordinary_worker_launch_validates_optional_subjektiv_connection() { let workspace = tempfile::tempdir().unwrap(); init_clean_git_workspace(workspace.path()); - let api = test_api(workspace.path()).await; + let (api, execution) = test_api_with_recording_backend(workspace.path()).await; let store = open_subjektiv_store(&api).unwrap(); let active = store .create_subject(crate::subjektiv::SubjectRole::new("companion").unwrap()) @@ -39507,6 +40113,31 @@ mod tests { first.worker.singleton_key.as_deref(), Some(format!("subjektiv:{}", active.id).as_str()) ); + + let worker = RuntimeWorkerRef::new(&first.runtime_id, &first.worker_id); + let stopped = api + .runtime + .stop_worker( + &worker, + WorkerLifecycleRequest { + reason: Some("test subject Worker restart".to_string()), + ticket_assignment: None, + }, + ) + .unwrap(); + assert_eq!(stopped.state, InternalWorkerOperationState::Accepted); + assert_eq!(api.runtime.worker(&worker).unwrap().state, "stopped"); + execution.accept_restores(); + + let Json(restored) = create_workspace_worker( + State(api.clone()), + HeaderMap::new(), + Json(request(Some(&active.id), "builtin:companion", None)), + ) + .await + .unwrap(); + assert_eq!(restored.worker_id, first.worker_id); + assert_eq!(restored.worker.state, "idle"); } #[tokio::test] @@ -39527,7 +40158,10 @@ mod tests { assert_eq!(missing.status(), StatusCode::NOT_FOUND); let subject = open_subjektiv_store(&api) .unwrap() - .create_subject(crate::subjektiv::SubjectRole::new("companion").unwrap()) + .create_subject_with_behavior( + crate::subjektiv::SubjectRole::new("companion").unwrap(), + "Prefer explicit evidence.".to_string(), + ) .unwrap(); let request = CreateWorkspaceWorkerRequest { runtime_id: EMBEDDED_WORKER_RUNTIME_ID.to_string(), @@ -39646,6 +40280,33 @@ mod tests { } ) )); + let Json(resident_context) = scoped_subjektiv_memory_backend( + State(api.clone()), + AxumPath(ScopedWorkspacePath { + workspace_id: TEST_WORKSPACE_ID.to_string(), + }), + context.clone(), + Json(server_api::SubjektivMemoryBackendRequest { + operation: server_api::SubjektivMemoryBackendOperation::ResidentContext( + Default::default(), + ), + }), + ) + .await + .unwrap(); + assert!(matches!( + resident_context, + server_api::SubjektivMemoryBackendResponse::ResidentContext( + server_api::SubjektivResidentContextOutput { + behavior_md, + behavior_revision: 0, + memory_surface: memory::backend::MemoryResidentSummaryOutput { + availability: memory::backend::MemoryResidentSummaryAvailability::Ungenerated, + content: None, + }, + } + ) if behavior_md == "Prefer explicit evidence." + )); let missing_legacy = scoped_record_subjektiv_session( State(api.clone()), AxumPath(ScopedWorkspacePath { @@ -41241,6 +41902,7 @@ mod tests { .unwrap(); api.store .attach_worker_workdir(&WorkerWorkdirLinkRecord { + connection_id: String::new(), workspace_id: TEST_WORKSPACE_ID.to_string(), worker: RuntimeWorkerRef::new(EMBEDDED_WORKER_RUNTIME_ID, "7"), workdir_id: "managed".to_string(), @@ -46500,6 +47162,7 @@ mod tests { .unwrap(); api.store .attach_worker_workdir(&WorkerWorkdirLinkRecord { + connection_id: String::new(), workspace_id: TEST_WORKSPACE_ID.to_string(), worker: worker.clone(), workdir_id: workdir_id.clone(), @@ -47997,6 +48660,822 @@ mod tests { assert_eq!(attempts[0].0.worker_id.to_string(), orchestrator.worker_id); } + async fn attention_test_api() -> ( + tempfile::TempDir, + WorkspaceApi, + Arc, + ) { + let dir = tempfile::tempdir().unwrap(); + init_clean_git_workspace(dir.path()); + let (api, execution) = test_api_with_recording_backend(dir.path()).await; + let mut input = ticket::NewTicket::new("Attention backlog"); + input.workflow_state = Some(TicketWorkflowState::Queued); + set_test_ticket_target(&mut input, "test-repository", "HEAD"); + let ticket = browser_ticket_backend(&api).unwrap().create(input).unwrap(); + assign_test_orchestrator(&api, &ticket.id); + (dir, api, execution) + } + + async fn start_attention_test_orchestrator( + api: &WorkspaceApi, + ) -> BrowserWorkspaceOrchestratorResponse { + scoped_start_workspace_orchestrator( + State(api.clone()), + AxumPath(ScopedWorkspacePath { + workspace_id: TEST_WORKSPACE_ID.to_string(), + }), + ) + .await + .unwrap() + .0 + } + + #[tokio::test] + async fn orchestrator_attention_parallel_start_and_idle_observation_send_once() { + let (_dir, api, execution) = attention_test_api().await; + let reentrant_api = api.clone(); + execution.before_input_returns(move |worker| { + use protocol::subscription::SubscriptionWorkerState as S; + // The callback is inside Runtime send_input. Neither initial Idle nor + // a real turn end may deadlock or issue another in-flight delivery. + maybe_dispatch_orchestrator_turn_end( + &reentrant_api, + &worker.worker_id.to_string(), + None, + S::Idle, + ); + maybe_dispatch_orchestrator_turn_end( + &reentrant_api, + &worker.worker_id.to_string(), + Some(S::Running), + S::Idle, + ); + }); + let barrier = Arc::new(std::sync::Barrier::new(8)); + let results = std::thread::scope(|scope| { + let joins = (0..8) + .map(|_| { + let api = api.clone(); + let barrier = barrier.clone(); + scope.spawn(move || { + barrier.wait(); + futures::executor::block_on(start_attention_test_orchestrator(&api)) + }) + }) + .collect::>(); + joins + .into_iter() + .map(|j| j.join().unwrap()) + .collect::>() + }); + assert_eq!( + results + .iter() + .filter(|r| r.disposition == "created") + .count(), + 1 + ); + assert_eq!( + results + .iter() + .filter(|r| r.disposition == "existing") + .count(), + 7 + ); + assert_eq!(execution.take_inputs().len(), 1); + assert_eq!(execution.take_input_request_ids().len(), 1); + assert_eq!( + start_attention_test_orchestrator(&api).await.disposition, + "existing" + ); + assert!(execution.take_inputs().is_empty()); + } + + #[tokio::test] + async fn orchestrator_attention_barrier_callers_share_unsent_reservation() { + let (_dir, api, execution) = attention_test_api().await; + let started = start_attention_test_orchestrator(&api).await; + let worker = RuntimeWorkerRef::new( + EMBEDDED_WORKER_RUNTIME_ID, + started.worker.unwrap().worker_id, + ); + let state = orchestrator_attention_state(&api, &worker); + state.lock().unwrap().accepted = None; + execution.take_inputs(); + execution.take_input_request_ids(); + let barrier = Arc::new(std::sync::Barrier::new(8)); + let (entered_tx, entered_rx) = std::sync::mpsc::channel(); + let (release_tx, release_rx) = std::sync::mpsc::channel(); + execution.before_input_returns(move |_| { + entered_tx.send(()).unwrap(); + release_rx + .recv_timeout(std::time::Duration::from_secs(5)) + .unwrap(); + }); + std::thread::scope(|scope| { + let joins = (0..8) + .map(|_| { + let api = api.clone(); + let worker = worker.clone(); + let barrier = barrier.clone(); + scope.spawn(move || { + barrier.wait(); + dispatch_orchestrator_queue_attention(&api, &worker, false); + }) + }) + .collect::>(); + entered_rx + .recv_timeout(std::time::Duration::from_secs(5)) + .unwrap(); + assert!(state.lock().unwrap().sending); + assert!(state.lock().unwrap().accepted.is_none()); + // The success record does not exist yet. A ninth caller must still + // return immediately rather than compare/send twice or wait on Runtime. + dispatch_orchestrator_queue_attention(&api, &worker, false); + release_tx.send(()).unwrap(); + for join in joins { + join.join().unwrap(); + } + }); + assert_eq!(execution.take_inputs().len(), 1); + assert_eq!(execution.take_input_request_ids().len(), 1); + assert!(!state.lock().unwrap().sending); + assert!(state.lock().unwrap().accepted.is_some()); + } + + #[tokio::test] + async fn orchestrator_attention_restore_rechecks_reserved_noop_before_and_after_comparison() { + for pause_before_comparison in [true, false] { + let (_dir, api, execution) = attention_test_api().await; + let started = start_attention_test_orchestrator(&api).await; + let worker = RuntimeWorkerRef::new( + EMBEDDED_WORKER_RUNTIME_ID, + started.worker.unwrap().worker_id, + ); + let state = orchestrator_attention_state(&api, &worker); + let old_id = state + .lock() + .unwrap() + .accepted + .as_ref() + .unwrap() + .request_id + .clone(); + execution.take_inputs(); + execution.take_input_request_ids(); + api.runtime + .stop_worker( + &worker, + WorkerLifecycleRequest { + reason: None, + ticket_assignment: None, + }, + ) + .unwrap(); + execution.accept_restores(); + let (entered_tx, entered_rx) = std::sync::mpsc::channel(); + let (release_tx, release_rx) = std::sync::mpsc::channel(); + std::thread::scope(|scope| { + let api_for_caller = api.clone(); + let worker_for_caller = worker.clone(); + let caller = scope.spawn(move || { + let mut first = true; + with_orchestrator_attention_reservation( + &api_for_caller, + &worker_for_caller, + false, + |state, pending| { + if first && pause_before_comparison { + entered_tx.send(()).unwrap(); + release_rx + .recv_timeout(std::time::Duration::from_secs(5)) + .unwrap(); + } + check_orchestrator_queue_attention( + &api_for_caller, + &worker_for_caller, + state, + pending, + ); + if first && !pause_before_comparison { + // Old fingerprint matched: check returned without sending, + // but the exact same production reservation still exists. + assert_eq!( + state.lock().unwrap().accepted.as_ref().unwrap().request_id, + old_id + ); + entered_tx.send(()).unwrap(); + release_rx + .recv_timeout(std::time::Duration::from_secs(5)) + .unwrap(); + } + first = false; + }, + ); + }); + entered_rx + .recv_timeout(std::time::Duration::from_secs(5)) + .unwrap(); + assert!(state.lock().unwrap().sending); + let restored = futures::executor::block_on(start_attention_test_orchestrator(&api)); + assert_eq!(restored.disposition, "restored"); + let deferred = state.lock().unwrap().restore_recheck; + let old_retired = state.lock().unwrap().accepted.is_none(); + let inputs_before_release = execution.take_inputs(); + release_tx.send(()).unwrap(); + caller.join().unwrap(); + assert!(deferred); + assert!(old_retired); + assert!(inputs_before_release.is_empty()); + }); + assert_eq!(execution.take_inputs().len(), 1); + assert_eq!(execution.take_input_request_ids().len(), 1); + let state = state.lock().unwrap(); + assert!(state.accepted.is_some()); + assert!(!state.sending); + assert!(!state.restore_recheck); + } + } + + #[tokio::test] + async fn orchestrator_attention_restore_keeps_pending_unknown_delivery_and_id() { + let (_dir, api, execution) = attention_test_api().await; + execution.reject_inputs("unknown acceptance response"); + let started = start_attention_test_orchestrator(&api).await; + let worker = RuntimeWorkerRef::new( + EMBEDDED_WORKER_RUNTIME_ID, + started.worker.unwrap().worker_id, + ); + let state = orchestrator_attention_state(&api, &worker); + let pending = state.lock().unwrap().pending.clone().unwrap(); + execution.take_inputs(); + execution.take_input_request_ids(); + api.runtime + .stop_worker( + &worker, + WorkerLifecycleRequest { + reason: None, + ticket_assignment: None, + }, + ) + .unwrap(); + execution.accept_restores(); + *execution.input_failure.lock().unwrap() = None; + let restored = start_attention_test_orchestrator(&api).await; + assert_eq!(restored.disposition, "restored"); + let attempts = execution.take_inputs(); + assert_eq!(attempts.len(), 1); + assert_eq!(attempts[0].1, pending.content); + assert_eq!( + execution.take_input_request_ids(), + vec![Some(pending.request_id.clone())] + ); + assert!(state.lock().unwrap().pending.is_none()); + assert_eq!( + state.lock().unwrap().accepted.as_ref().unwrap().request_id, + pending.request_id + ); + } + + #[tokio::test] + async fn orchestrator_attention_unknown_response_retry_keeps_id_and_payload() { + let (_dir, api, execution) = attention_test_api().await; + execution.reject_inputs("accepted response lost (unknown transport outcome)"); + let started = start_attention_test_orchestrator(&api).await; + let worker = RuntimeWorkerRef::new( + EMBEDDED_WORKER_RUNTIME_ID, + started.worker.unwrap().worker_id, + ); + let state = orchestrator_attention_state(&api, &worker); + assert!(state.lock().unwrap().accepted.is_none()); + let pending = state.lock().unwrap().pending.clone().unwrap(); + let attempts = execution.take_inputs(); + let ids = execution.take_input_request_ids(); + assert_eq!(attempts.len(), 2); + assert_eq!(ids, vec![Some(pending.request_id.clone()); 2]); + // Change the backlog before retry: uncertain acceptance must never reuse + // the ID for a newly rendered payload. + let mut next = ticket::NewTicket::new("Changed backlog"); + next.workflow_state = Some(TicketWorkflowState::Queued); + set_test_ticket_target(&mut next, "test-repository", "HEAD"); + let next = browser_ticket_backend(&api).unwrap().create(next).unwrap(); + assign_test_orchestrator(&api, &next.id); + *execution.input_failure.lock().unwrap() = None; + assert_eq!( + start_attention_test_orchestrator(&api).await.disposition, + "existing" + ); + let retry = execution.take_inputs(); + assert_eq!(retry.len(), 1); + assert_eq!(retry[0].1, pending.content); + assert_eq!( + execution.take_input_request_ids(), + vec![Some(pending.request_id)] + ); + assert!(state.lock().unwrap().pending.is_none()); + assert!(state.lock().unwrap().accepted.is_some()); + use protocol::subscription::SubscriptionWorkerState as S; + maybe_dispatch_orchestrator_turn_end(&api, &worker.worker_id, Some(S::Running), S::Idle); + let changed = execution.take_inputs(); + assert_eq!(changed.len(), 1); + assert!(changed[0].1.contains("Changed backlog")); + } + + #[tokio::test] + async fn orchestrator_attention_subscription_snapshots_only_establish_baselines() { + use crate::runtime_subscription::BrokerSubscriptionEvent as B; + use protocol::subscription::{ + SubscriptionEventPayload, SubscriptionSnapshot, SubscriptionWorker, + SubscriptionWorkerState as S, + }; + let (_dir, api, execution) = attention_test_api().await; + let started = start_attention_test_orchestrator(&api).await; + let worker = RuntimeWorkerRef::new( + EMBEDDED_WORKER_RUNTIME_ID, + started.worker.unwrap().worker_id, + ); + let attention = orchestrator_attention_state(&api, &worker); + attention.lock().unwrap().accepted = None; + execution.take_inputs(); + let summary = |state: S| -> SubscriptionWorker { + serde_json::from_value(serde_json::json!({ + "worker_id": worker.worker_id, "state": state, + })) + .unwrap() + }; + let snapshot = |state| B::Snapshot { + snapshot: SubscriptionSnapshot::Workers { + workers: vec![summary(state)], + }, + }; + let event = |state| B::Event { + payload: SubscriptionEventPayload::WorkerUpserted { + worker: summary(state), + }, + }; + let mut states = HashMap::new(); + assert!(observe_orchestrator_turn_end( + &api, + &mut states, + snapshot(S::Idle) + )); + assert!(observe_orchestrator_turn_end( + &api, + &mut states, + event(S::Idle) + )); + observe_orchestrator_turn_end(&api, &mut states, event(S::Running)); + // Neither snapshot itself nor an Idle event after reconnect is turn-end evidence. + observe_orchestrator_turn_end( + &api, + &mut states, + B::Disconnected { + message: "fixture reconnect".into(), + }, + ); + observe_orchestrator_turn_end(&api, &mut states, snapshot(S::Idle)); + observe_orchestrator_turn_end(&api, &mut states, event(S::Idle)); + observe_orchestrator_turn_end(&api, &mut states, snapshot(S::Running)); + observe_orchestrator_turn_end(&api, &mut states, snapshot(S::Idle)); + assert!(attention.lock().unwrap().accepted.is_none()); + assert!(execution.take_inputs().is_empty()); + observe_orchestrator_turn_end(&api, &mut states, event(S::Running)); + observe_orchestrator_turn_end(&api, &mut states, event(S::Idle)); + assert_eq!(execution.take_inputs().len(), 1); + } + + // Real Controller and canonical Session storage with isolated local config. + // A closed loopback model endpoint prevents any production-provider access; + // notifications commit at the model boundary before that request can fail. + struct AttentionWorkerFactory { + root: PathBuf, + } + + impl AttentionWorkerFactory { + async fn controller( + &self, + worker_id: String, + restore: bool, + ) -> std::result::Result + { + let name = format!("attention-{worker_id}"); + let root = self.root.join("workers").join(&worker_id); + let store = session_store::CombinedStore::new( + session_store::WorkerSessionStore::new(root.join("session")) + .map_err(|e| e.to_string())?, + session_store::WorkerAggregateStore::new(&root, &name) + .map_err(|e| e.to_string())?, + ); + let manifest = manifest::WorkerManifest::from_toml(&format!( + r#" + [worker] + name = "{name}" + pwd = "./" + [model] + scheme = "anthropic" + model_id = "attention-test" + base_url = "http://127.0.0.1:1" + auth = {{ kind = "none" }} + [engine] + max_tokens = 10 + [scope] + allow = [] + "# + )) + .map_err(|e| e.to_string())?; + let context = worker::WorkerWorkspaceContext::local_filesystem(None); + let workspace_client = context.client_handle(); + let loader = worker::PromptCatalogSource::builtins_only(); + let worker = if restore { + worker::Worker::restore_from_worker_metadata_with_context( + &name, + manifest, + store, + loader, + context, + worker::WorkerFilesystemAuthority::None, + ) + .await + } else { + worker::Worker::from_manifest_with_context( + manifest, + store, + loader, + context, + worker::WorkerFilesystemAuthority::None, + ) + .await + } + .map_err(|e| e.to_string())?; + let started = worker::PreparedWorker::new( + worker.with_notification_coalesce_delay(std::time::Duration::from_millis(10)), + worker::WorkerBootstrapLayout::RuntimeManagedRun { + run_dir: root.join(uuid::Uuid::now_v7().to_string()), + bash_output_dir: root.join("bash-output"), + }, + worker::WorkerControllerTransport::InProcess, + ) + .start() + .await + .map_err(|e| e.to_string())?; + Ok(worker_runtime::worker_backend::RuntimeWorkerController { + handle: started.handle, + shutdown: Arc::new(tokio::sync::Mutex::new(Some(started.shutdown))), + controller_task: started.controller_task, + workspace_client, + }) + } + } + + #[async_trait::async_trait] + impl worker_runtime::worker_backend::RuntimeWorkerFactory for AttentionWorkerFactory { + async fn spawn_controller( + &self, + request: worker_runtime::execution::WorkerExecutionSpawnRequest, + ) -> std::result::Result + { + self.controller(request.worker_ref.worker_id.to_string(), false) + .await + } + async fn restore_controller( + &self, + request: worker_runtime::execution::WorkerExecutionRestoreRequest, + ) -> std::result::Result + { + self.controller(request.worker_ref.worker_id.to_string(), true) + .await + } + } + + use worker_runtime::execution as attention_execution; + + // Fault injection occurs AFTER the real Controller durably accepted input. + struct AttentionResponseLossBackend { + inner: Arc, + lose_next: std::sync::atomic::AtomicBool, + attempts: Mutex>, + } + + impl attention_execution::WorkerExecutionBackend for AttentionResponseLossBackend { + fn backend_id(&self) -> &str { + self.inner.backend_id() + } + fn spawn_worker( + &self, + r: attention_execution::WorkerExecutionSpawnRequest, + ) -> attention_execution::WorkerExecutionSpawnResult { + self.inner.spawn_worker(r) + } + fn preflight_restore( + &self, + r: &attention_execution::WorkerExecutionRestoreRequest, + ) -> std::result::Result<(), attention_execution::WorkerExecutionResult> { + self.inner.preflight_restore(r) + } + fn reconcile_restore( + &self, + r: attention_execution::WorkerExecutionRestoreRequest, + ) -> attention_execution::WorkerExecutionSpawnResult { + self.inner.reconcile_restore(r) + } + fn restore_worker( + &self, + r: attention_execution::WorkerExecutionRestoreRequest, + ) -> attention_execution::WorkerExecutionSpawnResult { + self.inner.restore_worker(r) + } + fn activate_restored_worker( + &self, + id: attention_execution::WorkerLifecycleOperationId, + handle: &attention_execution::WorkerExecutionHandle, + ) -> std::result::Result<(), String> { + self.inner.activate_restored_worker(id, handle) + } + fn dispatch_input( + &self, + handle: &attention_execution::WorkerExecutionHandle, + input: worker_runtime::interaction::WorkerInput, + ) -> attention_execution::WorkerExecutionResult { + self.attempts + .lock() + .unwrap() + .push(input.submission_request_id.clone().unwrap()); + let result = self.inner.dispatch_input(handle, input); + assert_eq!( + result.outcome, + attention_execution::WorkerExecutionOutcome::Accepted + ); + if self + .lose_next + .swap(false, std::sync::atomic::Ordering::SeqCst) + { + attention_execution::WorkerExecutionResult::errored( + attention_execution::WorkerExecutionOperation::Input, + "injected loss of accepted response", + ) + } else { + result + } + } + fn dispatch_method( + &self, + handle: &attention_execution::WorkerExecutionHandle, + method: protocol::Method, + ) -> attention_execution::WorkerExecutionResult { + self.inner.dispatch_method(handle, method) + } + fn stop_worker_operation( + &self, + r: attention_execution::WorkerExecutionStopRequest, + ) -> attention_execution::WorkerExecutionResult { + self.inner.stop_worker_operation(r) + } + fn worker_snapshot( + &self, + handle: &attention_execution::WorkerExecutionHandle, + ) -> Option { + self.inner.worker_snapshot(handle) + } + } + + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn orchestrator_attention_real_worker_start_restore_accept_and_commit_once_each() { + real_attention_worker_start_restore(false).await; + } + + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn orchestrator_attention_real_worker_lost_response_retry_commits_once() { + real_attention_worker_start_restore(true).await; + } + + async fn real_attention_worker_start_restore(lose_response: bool) { + use session_store::{LogEntry, Store, WorkerSessionStore}; + let dir = tempfile::tempdir().unwrap(); + init_clean_git_workspace(dir.path()); + let config = test_server_config(dir.path()); + let store = Arc::new(SqliteWorkspaceStore::open(&config.database_path).unwrap()); + seed_test_registered_workspace(store.as_ref(), &config) + .await + .unwrap(); + // Real Worker Runtime adapter/controller/storage, isolated from profile + // workspace callbacks and real LLM credentials. + let execution = Arc::new( + worker_runtime::worker_backend::WorkerRuntimeExecutionBackend::new( + AttentionWorkerFactory { + root: config.embedded_runtime_store_root.clone(), + }, + ) + .unwrap(), + ); + let execution = Arc::new(AttentionResponseLossBackend { + inner: execution, + lose_next: std::sync::atomic::AtomicBool::new(lose_response), + attempts: Mutex::new(Vec::new()), + }); + let api = WorkspaceApi::new_with_execution_backend(config, store, execution.clone()) + .await + .unwrap(); + let mut input = ticket::NewTicket::new("Real notification backlog"); + input.workflow_state = Some(TicketWorkflowState::Queued); + set_test_ticket_target(&mut input, "test-repository", "HEAD"); + let ticket = browser_ticket_backend(&api).unwrap().create(input).unwrap(); + assign_test_orchestrator(&api, &ticket.id); + let started = start_attention_test_orchestrator(&api).await; + let worker = RuntimeWorkerRef::new( + EMBEDDED_WORKER_RUNTIME_ID, + started.worker.unwrap().worker_id, + ); + let session = WorkerSessionStore::open_read_only( + api.config + .embedded_runtime_store_root + .join("workers") + .join(&worker.worker_id) + .join("session"), + ) + .unwrap(); + let entries = || { + let id = session.session_id().unwrap().unwrap(); + session + .list_segments(id) + .unwrap() + .into_iter() + .flat_map(|segment| session.read_all_read_only(id, segment).unwrap()) + .collect::>() + }; + let receipts = || { + entries() + .iter() + .rev() + .find_map(|entry| match entry { + LogEntry::Extension { + domain, payload, .. + } if domain == "worker.pending_activations.v1" => { + Some(payload["notification_receipts"].as_array().unwrap().clone()) + } + LogEntry::AnnotatedSystemItem { extensions, .. } => extensions + .iter() + .find(|e| e.domain == "worker.pending_activations.v1") + .map(|e| { + e.payload["notification_receipts"] + .as_array() + .unwrap() + .clone() + }), + _ => None, + }) + .unwrap() + }; + let commits = || { + entries().iter().filter(|entry| matches!(entry, + LogEntry::AnnotatedSystemItem { entry, .. } + if matches!(&entry.item, session_store::SystemItem::Notification { message, .. } + if message.starts_with("Queued Tickets require attention:")) + )).count() + }; + async fn wait_for_commits usize>(count: F, expected: usize) { + tokio::time::timeout(std::time::Duration::from_secs(5), async { + loop { + let actual = count(); + assert!(actual <= expected, "duplicate canonical notifications"); + if actual == expected { + break; + } + tokio::time::sleep(std::time::Duration::from_millis(10)).await; + } + }) + .await + .expect("controlled Worker notification commit"); + } + assert_eq!(receipts().len(), 1); + wait_for_commits(&commits, 1).await; + assert_eq!( + execution.attempts.lock().unwrap().len(), + if lose_response { 2 } else { 1 } + ); + let first_id = orchestrator_attention_state(&api, &worker) + .lock() + .unwrap() + .accepted + .as_ref() + .unwrap() + .request_id + .clone(); + use protocol::subscription::SubscriptionWorkerState as S; + for previous in [ + None, + Some(S::Idle), + Some(S::Stopped), + Some(S::Paused), + Some(S::Running), + ] { + maybe_dispatch_orchestrator_turn_end(&api, &worker.worker_id, previous, S::Idle); + } + assert_eq!( + start_attention_test_orchestrator(&api).await.disposition, + "existing" + ); + assert_eq!(receipts().len(), 1); + api.runtime + .stop_worker( + &worker, + WorkerLifecycleRequest { + reason: None, + ticket_assignment: None, + }, + ) + .unwrap(); + let restored = start_attention_test_orchestrator(&api).await; + assert_eq!(restored.disposition, "restored"); + let second_id = orchestrator_attention_state(&api, &worker) + .lock() + .unwrap() + .accepted + .as_ref() + .unwrap() + .request_id + .clone(); + assert_ne!(first_id, second_id); + wait_for_commits(&commits, 2).await; + let attempts = execution.attempts.lock().unwrap(); + assert_eq!(attempts.len(), if lose_response { 3 } else { 2 }); + assert_eq!( + attempts.iter().filter(|id| **id == first_id).count(), + if lose_response { 2 } else { 1 } + ); + assert_eq!(attempts.iter().filter(|id| **id == second_id).count(), 1); + drop(attempts); + let receipts = receipts(); + assert_eq!(receipts.len(), 2); + assert_eq!( + receipts + .iter() + .filter(|r| r["notification_request_id"] == first_id) + .count(), + 1 + ); + assert_eq!( + receipts + .iter() + .filter(|r| r["notification_request_id"] == second_id) + .count(), + 1 + ); + // Stop only this isolated test fixture; no live dogfooding Worker is touched. + api.runtime + .stop_worker( + &worker, + WorkerLifecycleRequest { + reason: None, + ticket_assignment: None, + }, + ) + .unwrap(); + } + + #[tokio::test] + async fn orchestrator_attention_restore_and_replacement_have_first_delivery() { + let (_dir, api, execution) = attention_test_api().await; + let started = start_attention_test_orchestrator(&api).await; + let worker = RuntimeWorkerRef::new( + EMBEDDED_WORKER_RUNTIME_ID, + started.worker.unwrap().worker_id, + ); + assert_eq!(execution.take_inputs().len(), 1); + api.runtime + .stop_worker( + &worker, + WorkerLifecycleRequest { + reason: None, + ticket_assignment: None, + }, + ) + .unwrap(); + execution.accept_restores(); + let restored = start_attention_test_orchestrator(&api).await; + assert_eq!(restored.disposition, "restored"); + assert_eq!(restored.worker.unwrap().worker_id, worker.worker_id); + assert_eq!(execution.take_inputs().len(), 1); + use protocol::subscription::SubscriptionWorkerState as S; + maybe_dispatch_orchestrator_turn_end(&api, &worker.worker_id, None, S::Idle); + assert_eq!( + start_attention_test_orchestrator(&api).await.disposition, + "existing" + ); + assert!(execution.take_inputs().is_empty()); + // This is a fixture Worker, not the running dogfooding Orchestrator. + api.runtime.delete_worker(&worker).unwrap(); + api.worker_projection + .publish_ordered(|| { + api.store + .delete_worker_registry(TEST_WORKSPACE_ID, &worker) + .map(|commit| ((), commit)) + }) + .unwrap(); + let replacement = start_attention_test_orchestrator(&api).await; + assert_ne!(replacement.worker.unwrap().worker_id, worker.worker_id); + assert_eq!(execution.take_inputs().len(), 1); + } + #[tokio::test] async fn orchestrator_running_to_idle_recovers_queued_ticket_without_notification_memory() { let dir = tempfile::tempdir().unwrap(); @@ -48008,7 +49487,6 @@ mod tests { set_test_ticket_target(&mut input, "test-repository", "HEAD"); let ticket_ref = backend.create(input).unwrap(); assign_test_orchestrator(&api, &ticket_ref.id); - *api.orchestrator_attention_fingerprint.lock().unwrap() = Some(ticket_ref.id.clone()); let Json(started) = scoped_start_workspace_orchestrator( State(api.clone()), @@ -48021,41 +49499,39 @@ mod tests { assert!(started.online); let startup_inputs = execution.take_inputs(); assert_eq!(startup_inputs.len(), 1); - assert_eq!( - api.orchestrator_attention_fingerprint - .lock() - .unwrap() - .as_deref(), - Some(ticket_ref.id.as_str()) - ); - *api.orchestrator_attention_fingerprint.lock().unwrap() = None; let worker_id = started.worker.as_ref().unwrap().worker_id.clone(); - maybe_dispatch_orchestrator_turn_end( - &api, - &worker_id, - Some(protocol::subscription::SubscriptionWorkerState::Idle), - protocol::subscription::SubscriptionWorkerState::Idle, - ); - assert!( - api.orchestrator_attention_fingerprint - .lock() - .unwrap() - .is_none() - ); - maybe_dispatch_orchestrator_turn_end( - &api, - &worker_id, - Some(protocol::subscription::SubscriptionWorkerState::Running), - protocol::subscription::SubscriptionWorkerState::Idle, - ); - + let worker = RuntimeWorkerRef::new(EMBEDDED_WORKER_RUNTIME_ID, &worker_id); + let attention = orchestrator_attention_state(&api, &worker); assert_eq!( - api.orchestrator_attention_fingerprint + attention .lock() .unwrap() - .as_deref(), - Some(ticket_ref.id.as_str()) + .accepted + .as_ref() + .unwrap() + .fingerprint, + ticket_ref.id ); + attention.lock().unwrap().accepted = None; + use protocol::subscription::SubscriptionWorkerState as S; + // Snapshot/reconnection and non-turn transitions cannot stand in for startup. + for previous in [None, Some(S::Idle), Some(S::Stopped), Some(S::Paused)] { + maybe_dispatch_orchestrator_turn_end(&api, &worker_id, previous, S::Idle); + assert!(attention.lock().unwrap().accepted.is_none()); + assert!(execution.take_inputs().is_empty()); + } + maybe_dispatch_orchestrator_turn_end(&api, &worker_id, Some(S::Running), S::Idle); + assert_eq!( + attention + .lock() + .unwrap() + .accepted + .as_ref() + .unwrap() + .fingerprint, + ticket_ref.id + ); + maybe_dispatch_orchestrator_turn_end(&api, &worker_id, Some(S::Running), S::Idle); let notifications = execution.take_inputs(); assert_eq!(notifications.len(), 1); assert_eq!(notifications[0].0.worker_id.to_string(), worker_id); @@ -50038,6 +51514,7 @@ mod tests { let runtime_worker_id = runtime_worker_id.parse::().unwrap(); api.store .attach_worker_workdir(&WorkerWorkdirLinkRecord { + connection_id: String::new(), workspace_id: api.config.workspace_id.clone(), worker: RuntimeWorkerRef::new("runtime-test", runtime_worker_id.to_string()), workdir_id: workdir_id.to_string(), @@ -50460,6 +51937,260 @@ mod tests { ); } + #[tokio::test] + async fn current_worker_workdir_catalog_enumerates_complete_stable_inventory() { + let mut fixture = manual_coder_assignment_fixture().await; + let identity = + worker_runtime::auth::RuntimeIdentityMaterial::generate(&fixture.worker.runtime_id) + .unwrap(); + configure_runtime_request_auth(&mut fixture.api, &identity, &fixture.worker.runtime_id); + let api = &fixture.api; + let template = api + .store + .list_workdir_registry(TEST_WORKSPACE_ID, 10) + .unwrap() + .remove(0); + let mut expected = api + .store + .list_workdir_registry(TEST_WORKSPACE_ID, 10) + .unwrap() + .into_iter() + .map(|record| record.workdir_id) + .collect::>(); + let mut oldest = None; + for index in 0..233 { + let legacy = index < 3; + let mut record = WorkdirRegistryRecord { + workdir_id: format!("000-catalog-{index:03}"), + display_name: Some(format!("Catalog {index}")), + // Timestamp order intentionally differs from stable ID order. + updated_at: format!("{index:05}"), + ..template.clone() + }; + if legacy || index % 2 == 1 { + let grant = ExternalWorkdirGrantRecord { + grant_id: format!("catalog-grant-{index}"), + workspace_id: TEST_WORKSPACE_ID.to_string(), + workdir_id: record.workdir_id.clone(), + provider_instance_id: "private-provider-instance".to_string(), + display_name: record.display_name.clone().unwrap(), + permissions: if legacy { "command_only" } else { "read_write" }.to_string(), + created_by: "test-account".to_string(), + created_at: "1".to_string(), + expires_at: None, + generation: 1, + status: "online".to_string(), + updated_at: "1".to_string(), + }; + record.source = WorkdirRegistrySource::ExternalGrant { + grant_id: grant.grant_id.clone(), + }; + api.store + .create_external_workdir_grant(&grant, &record) + .unwrap(); + } else { + api.store.upsert_workdir_registry(&record).unwrap(); + } + if !legacy { + expected.insert(record.workdir_id.clone()); + if oldest.is_none() { + oldest = Some(record.clone()); + } + } + } + let base = format!("/api/w/{TEST_WORKSPACE_ID}/workers/self/workdir-catalog"); + let request = |path: &str| { + runtime_source_request( + &identity, + Some(&fixture.worker.worker_id), + "GET", + path, + Vec::new(), + ) + }; + let response = build_router(api.clone()) + .oneshot(request(&format!("{base}?limit=3"))) + .await + .unwrap(); + assert_eq!(response.status(), StatusCode::OK); + let empty: server_api::CurrentWorkerWorkdirCatalogResponse = + serde_json::from_slice(&to_bytes(response.into_body(), usize::MAX).await.unwrap()) + .unwrap(); + assert!(empty.items.is_empty()); + assert_eq!(empty.next_cursor.as_deref(), Some("000-catalog-002")); + let mut cursor = None; + let mut seen = Vec::new(); + let mut pages = 0; + loop { + let path = match &cursor { + Some(cursor) => format!("{base}?limit=37&cursor={cursor}"), + None => format!("{base}?limit=37"), + }; + let response = build_router(api.clone()) + .oneshot(request(&path)) + .await + .unwrap(); + assert_eq!(response.status(), StatusCode::OK); + let bytes = to_bytes(response.into_body(), usize::MAX).await.unwrap(); + assert!(!String::from_utf8_lossy(&bytes).contains("private-provider-instance")); + let page: server_api::CurrentWorkerWorkdirCatalogResponse = + serde_json::from_slice(&bytes).unwrap(); + assert!(page.items.len() <= 37); + assert_eq!(page.revision, empty.revision); + seen.extend(page.items.into_iter().map(|item| item.working_directory_id)); + pages += 1; + if let Some(next) = page.next_cursor { + assert!(cursor.as_ref().is_none_or(|previous| previous < &next)); + cursor = Some(next); + } else { + break; + } + assert!(pages < 20); + } + assert!(seen.windows(2).all(|pair| pair[0] < pair[1])); + assert_eq!(seen.into_iter().collect::>(), expected); + assert!(pages > 6); + let oldest = oldest.unwrap(); + // Existing direct lookup agrees for the oldest visible External Workdir. + let Json(detail) = scoped_working_directory_detail( + State(api.clone()), + AxumPath(ScopedWorkingDirectoryPath { + workspace_id: TEST_WORKSPACE_ID.to_string(), + working_directory_id: oldest.workdir_id.clone(), + }), + ) + .await + .unwrap(); + let oldest_page = list_current_worker_workdir_catalog( + api, + server_api::CurrentWorkerWorkdirCatalogQuery { + limit: Some(1), + cursor: Some("000-catalog-002".to_string()), + }, + ) + .unwrap(); + assert_eq!(oldest_page.items, vec![detail.item]); + assert!( + !api.store + .list_workdir_registry(TEST_WORKSPACE_ID, 200) + .unwrap() + .iter() + .any(|record| record.workdir_id == oldest.workdir_id) + ); + for query in ["limit=0", "limit=101", "limit=-1", "cursor=", "cursor=%0A"] { + assert_eq!( + build_router(api.clone()) + .oneshot(request(&format!("{base}?{query}"))) + .await + .unwrap() + .status(), + StatusCode::BAD_REQUEST + ); + } + let default = list_current_worker_workdir_catalog(api, Default::default()).unwrap(); + assert_eq!(default.items.len(), 47); + let before = list_current_worker_workdir_catalog( + api, + server_api::CurrentWorkerWorkdirCatalogQuery { + limit: Some(3), + cursor: None, + }, + ) + .unwrap(); + let mut last = api + .store + .get_workdir_registry(TEST_WORKSPACE_ID, "000-catalog-232") + .unwrap() + .unwrap(); + last.display_name = Some("Updated outside the first page".to_string()); + api.store.upsert_workdir_registry(&last).unwrap(); + let changed = list_current_worker_workdir_catalog( + api, + server_api::CurrentWorkerWorkdirCatalogQuery { + limit: Some(3), + cursor: None, + }, + ) + .unwrap(); + assert_eq!(before.items, changed.items); + assert_ne!(before.revision, changed.revision); + let template_link = api + .store + .list_worker_workdir_links(TEST_WORKSPACE_ID, &fixture.worker) + .unwrap() + .remove(0); + let link = api + .store + .attach_worker_workdir(&WorkerWorkdirLinkRecord { + workdir_id: last.workdir_id.clone(), + alias: "catalog-occupancy".to_string(), + ..template_link + }) + .unwrap(); + let occupied = list_current_worker_workdir_catalog( + api, + server_api::CurrentWorkerWorkdirCatalogQuery { + limit: Some(3), + cursor: None, + }, + ) + .unwrap(); + assert_eq!(changed.items, occupied.items); + assert_ne!(changed.revision, occupied.revision); + api.store + .detach_worker_workdir_connection( + TEST_WORKSPACE_ID, + &fixture.worker, + &link.alias, + &link.connection_id, + "detach", + ) + .unwrap(); + assert_eq!( + changed.revision, + list_current_worker_workdir_catalog(api, Default::default()) + .unwrap() + .revision + ); + let restarted = SqliteWorkspaceStore::open(&api.config.database_path).unwrap(); + assert_eq!( + changed.revision, + restarted + .workdir_catalog_page(TEST_WORKSPACE_ID, 100, None) + .unwrap() + .revision + ); + let no_worker = runtime_source_request(&identity, None, "GET", &base, Vec::new()); + assert_eq!( + build_router(api.clone()) + .oneshot(no_worker) + .await + .unwrap() + .status(), + StatusCode::FORBIDDEN + ); + let mut forged = request(&base); + forged + .headers_mut() + .insert("x-yoi-worker-id", "foreign-worker".parse().unwrap()); + assert_eq!( + build_router(api.clone()) + .oneshot(forged) + .await + .unwrap() + .status(), + StatusCode::FORBIDDEN + ); + assert_eq!( + build_router(api.clone()) + .oneshot(Request::builder().uri(&base).body(Body::empty()).unwrap()) + .await + .unwrap() + .status(), + StatusCode::UNAUTHORIZED + ); + } + #[tokio::test] async fn current_worker_workdir_routes_require_a_live_runtime_worker_identity() { let workspace = tempfile::tempdir().unwrap(); @@ -50480,7 +52211,571 @@ mod tests { ) .await .unwrap(); - assert_eq!(response.status(), StatusCode::BAD_REQUEST); + assert_eq!(response.status(), StatusCode::FORBIDDEN); + } + + #[tokio::test] + async fn current_worker_attachment_list_and_stale_detach_use_signed_identity() { + let mut fixture = manual_coder_assignment_fixture().await; + let identity = + worker_runtime::auth::RuntimeIdentityMaterial::generate(&fixture.worker.runtime_id) + .unwrap(); + configure_runtime_request_auth(&mut fixture.api, &identity, &fixture.worker.runtime_id); + let api = &fixture.api; + let base = format!("/api/w/{TEST_WORKSPACE_ID}/workers/self/workdir-attachments"); + let request = |method: &str, path: &str| { + runtime_source_request( + &identity, + Some(&fixture.worker.worker_id), + method, + path, + Vec::new(), + ) + }; + let response = build_router(api.clone()) + .oneshot(request("GET", &format!("{base}?limit=1"))) + .await + .unwrap(); + assert_eq!(response.status(), StatusCode::OK); + let page: server_api::CurrentWorkerWorkdirAttachmentListResponse = + serde_json::from_slice(&to_bytes(response.into_body(), usize::MAX).await.unwrap()) + .unwrap(); + assert_eq!(page.workspace_id, TEST_WORKSPACE_ID); + assert_eq!(page.items.len(), 1); + assert_eq!(page.items[0].alias, "checkout"); + assert_eq!(page.next_offset, Some(1)); + let old = api + .store + .list_worker_workdir_links(TEST_WORKSPACE_ID, &fixture.worker) + .unwrap() + .into_iter() + .find(|link| link.alias == "checkout") + .unwrap(); + assert_eq!(page.items[0].connection_id, old.connection_id); + let response = build_router(api.clone()) + .oneshot(request("GET", &format!("{base}?limit=1&offset=1"))) + .await + .unwrap(); + assert_eq!(response.status(), StatusCode::OK); + let page: serde_json::Value = + serde_json::from_slice(&to_bytes(response.into_body(), usize::MAX).await.unwrap()) + .unwrap(); + assert_eq!(page["items"][0]["alias"], "docs"); + assert!(page["next_offset"].is_null()); + assert_eq!( + page["items"][0].as_object().unwrap().len(), + 4, + "no paths, credentials or materializer data" + ); + for query in ["limit=0", "limit=101", "offset=-1"] { + let response = build_router(api.clone()) + .oneshot(request("GET", &format!("{base}?{query}"))) + .await + .unwrap(); + assert_eq!(response.status(), StatusCode::BAD_REQUEST); + } + let mut forged = request("GET", &base); + forged + .headers_mut() + .insert("x-yoi-worker-id", "foreign-worker".parse().unwrap()); + assert_eq!( + build_router(api.clone()) + .oneshot(forged) + .await + .unwrap() + .status(), + StatusCode::FORBIDDEN + ); + let no_worker = runtime_source_request(&identity, None, "GET", &base, Vec::new()); + assert_eq!( + build_router(api.clone()) + .oneshot(no_worker) + .await + .unwrap() + .status(), + StatusCode::FORBIDDEN + ); + + api.store + .detach_worker_workdir_connection( + TEST_WORKSPACE_ID, + &fixture.worker, + "checkout", + &old.connection_id, + "first-detach", + ) + .unwrap(); + let replacement = api.store.attach_worker_workdir(&old).unwrap(); + assert_ne!(replacement.connection_id, old.connection_id); + // Leave one attachment so successful detach does not need a fixture provider session. + api.store + .detach_worker_workdir( + TEST_WORKSPACE_ID, + &fixture.worker, + Some(&fixture.docs_workdir_id), + "remove-docs", + ) + .unwrap(); + let root = tempfile::tempdir().unwrap(); + let session: WorkdirSessionHandle = Arc::new(workdir::LocalWorkdirSession::new( + manifest::Scope::writable(root.path()).unwrap(), + root.path().to_path_buf(), + )); + let provider_handle = session + .start_command(workdir::CommandRequest { + command: "sleep 30".to_string(), + timeout_secs: 60, + output_limit: 4096, + cwd: workdir::WorkdirPath::root(), + spill_dir: None, + tool_call_id: None, + }) + .await + .unwrap(); + let external = api.workdir_sessions.lock().unwrap().register_command( + fixture.worker.clone(), + "checkout".to_string(), + session.clone(), + provider_handle.clone(), + ); + let stale_path = format!( + "{base}/checkout?expected_connection_id={}", + old.connection_id + ); + let response = build_router(api.clone()) + .oneshot(request("DELETE", &stale_path)) + .await + .unwrap(); + assert_eq!(response.status(), StatusCode::CONFLICT); + assert!( + api.workdir_sessions + .lock() + .unwrap() + .command(&fixture.worker, "checkout", &external) + .is_some() + ); + assert_eq!( + session + .command_status(provider_handle.clone()) + .await + .unwrap(), + workdir::CommandStatus::Running + ); + assert_eq!( + api.store + .list_worker_workdir_links(TEST_WORKSPACE_ID, &fixture.worker) + .unwrap(), + vec![replacement.clone()] + ); + + // The same lock excludes command/SubWorker session activity before checking identity. + let lock = current_worker_session_lock(api, &fixture.worker); + let guard = lock.lock().await; + let mut detach = Box::pin(detach_current_worker_workdir( + api, + &fixture.worker, + "checkout", + Some(&old.connection_id), + )); + assert!( + tokio::time::timeout(std::time::Duration::from_millis(20), &mut detach) + .await + .is_err() + ); + assert_eq!( + session + .command_status(provider_handle.clone()) + .await + .unwrap(), + workdir::CommandStatus::Running + ); + drop(guard); + assert!(matches!( + detach.await.unwrap_err().error, + Error::WorkdirAttachmentConflict(_) + )); + + let current_path = format!( + "{base}/checkout?expected_connection_id={}", + replacement.connection_id + ); + let response = build_router(api.clone()) + .oneshot(request("DELETE", ¤t_path)) + .await + .unwrap(); + assert_eq!(response.status(), StatusCode::OK); + let response: server_api::CurrentWorkerWorkdirAttachmentResponse = + serde_json::from_slice(&to_bytes(response.into_body(), usize::MAX).await.unwrap()) + .unwrap(); + assert!(!response.attached); + assert!( + session.command_status(provider_handle).await.is_err(), + "matching detach cancels and closes command authority" + ); + assert!( + api.workdir_sessions + .lock() + .unwrap() + .command(&fixture.worker, "checkout", &external) + .is_none() + ); + assert!( + api.store + .list_worker_workdir_links(TEST_WORKSPACE_ID, &fixture.worker) + .unwrap() + .is_empty() + ); + let new_lifetime = api.store.attach_worker_workdir(&old).unwrap(); + assert_ne!(new_lifetime.connection_id, replacement.connection_id); + let response = build_router(api.clone()) + .oneshot(request("DELETE", &format!("{base}/checkout"))) + .await + .unwrap(); + assert_eq!( + response.status(), + StatusCode::OK, + "normal Tool's query-free detach stays unconditional" + ); + } + + #[tokio::test] + async fn current_worker_attachment_connection_lookup_precedes_paging_and_is_scoped() { + let mut fixture = manual_coder_assignment_fixture().await; + let identity = + worker_runtime::auth::RuntimeIdentityMaterial::generate(&fixture.worker.runtime_id) + .unwrap(); + configure_runtime_request_auth(&mut fixture.api, &identity, &fixture.worker.runtime_id); + let api = &fixture.api; + let template = api + .store + .list_worker_workdir_links(TEST_WORKSPACE_ID, &fixture.worker) + .unwrap() + .remove(0); + let workdir = api + .store + .get_workdir_registry(TEST_WORKSPACE_ID, &template.workdir_id) + .unwrap() + .unwrap(); + let mut target = None; + for index in 0..50 { + let workdir = WorkdirRegistryRecord { + workdir_id: format!("lookup-workdir-{index:03}"), + ..workdir.clone() + }; + api.store.upsert_workdir_registry(&workdir).unwrap(); + target = Some( + api.store + .attach_worker_workdir(&WorkerWorkdirLinkRecord { + workdir_id: workdir.workdir_id, + alias: format!("zz-lookup-{index:03}"), + ..template.clone() + }) + .unwrap(), + ); + } + let target = target.unwrap(); + let base = format!("/api/w/{TEST_WORKSPACE_ID}/workers/self/workdir-attachments"); + let request = |path: &str| { + runtime_source_request( + &identity, + Some(&fixture.worker.worker_id), + "GET", + path, + Vec::new(), + ) + }; + let response = build_router(api.clone()) + .oneshot(request(&base)) + .await + .unwrap(); + assert_eq!(response.status(), StatusCode::OK); + let first: server_api::CurrentWorkerWorkdirAttachmentListResponse = + serde_json::from_slice(&to_bytes(response.into_body(), usize::MAX).await.unwrap()) + .unwrap(); + assert_eq!(first.items.len(), 50); + assert_eq!(first.next_offset, Some(50)); + assert!( + !first + .items + .iter() + .any(|item| item.connection_id == target.connection_id) + ); + let lookup_path = format!("{base}?limit=1&connection_id={}", target.connection_id); + let response = build_router(api.clone()) + .oneshot(request(&lookup_path)) + .await + .unwrap(); + assert_eq!(response.status(), StatusCode::OK); + let page: server_api::CurrentWorkerWorkdirAttachmentListResponse = + serde_json::from_slice(&to_bytes(response.into_body(), usize::MAX).await.unwrap()) + .unwrap(); + assert_eq!(page.items.len(), 1); + assert_eq!(page.items[0].alias, target.alias); + assert_eq!(page.items[0].working_directory_id, target.workdir_id); + assert_eq!(page.items[0].connection_id, target.connection_id); + assert_eq!(page.next_offset, None); + assert_eq!( + page.revision, first.revision, + "lookup does not narrow the collection revision" + ); + + let foreign_worker_id = seed_cleanup_worker(api, 902, "normal"); + seed_cleanup_workdir(api, "foreign-lookup-workdir", "present", "clean"); + seed_cleanup_link(api, &foreign_worker_id, "foreign-lookup-workdir"); + let foreign = api + .store + .list_worker_workdir_links( + TEST_WORKSPACE_ID, + &RuntimeWorkerRef::new("runtime-test", &foreign_worker_id), + ) + .unwrap() + .remove(0); + for query in [ + format!("limit=1&connection_id={}&offset=1", target.connection_id), + format!("limit=1&connection_id={}", foreign.connection_id), + "limit=1&connection_id=nonexistent".to_string(), + format!("limit=1&connection_id={}", "x".repeat(128)), + ] { + let response = build_router(api.clone()) + .oneshot(request(&format!("{base}?{query}"))) + .await + .unwrap(); + assert_eq!(response.status(), StatusCode::OK); + let page: server_api::CurrentWorkerWorkdirAttachmentListResponse = + serde_json::from_slice(&to_bytes(response.into_body(), usize::MAX).await.unwrap()) + .unwrap(); + assert!(page.items.is_empty()); + assert_eq!(page.next_offset, None); + assert_eq!(page.revision, first.revision); + } + let mut forged = request(&lookup_path); + forged + .headers_mut() + .insert("x-yoi-worker-id", foreign_worker_id.parse().unwrap()); + assert_eq!( + build_router(api.clone()) + .oneshot(forged) + .await + .unwrap() + .status(), + StatusCode::FORBIDDEN + ); + for invalid in [ + String::new(), + "%20".to_string(), + "%0A".to_string(), + "x".repeat(129), + ] { + let response = build_router(api.clone()) + .oneshot(request(&format!("{base}?limit=1&connection_id={invalid}"))) + .await + .unwrap(); + assert_eq!(response.status(), StatusCode::BAD_REQUEST); + } + + let unchanged = + list_current_worker_workdir_attachments(api, &fixture.worker, Default::default()) + .unwrap(); + assert_eq!( + unchanged, first, + "another caller's connection cannot change this revision" + ); + api.store + .detach_worker_workdir_connection( + TEST_WORKSPACE_ID, + &fixture.worker, + &target.alias, + &target.connection_id, + "lookup-detach", + ) + .unwrap(); + let replacement = api.store.attach_worker_workdir(&target).unwrap(); + assert_ne!(replacement.connection_id, target.connection_id); + let reattached = + list_current_worker_workdir_attachments(api, &fixture.worker, Default::default()) + .unwrap(); + assert_eq!(reattached.items, first.items); + assert_eq!(reattached.next_offset, first.next_offset); + assert_ne!( + reattached.revision, first.revision, + "same alias and Workdir, new lifetime beyond page zero" + ); + let downgraded = api + .store + .attach_worker_workdir(&WorkerWorkdirLinkRecord { + capabilities: workdir::WorkdirSessionCapabilities::READ_ONLY, + ..replacement.clone() + }) + .unwrap(); + assert_eq!(downgraded.connection_id, replacement.connection_id); + let capability_changed = + list_current_worker_workdir_attachments(api, &fixture.worker, Default::default()) + .unwrap(); + assert_eq!(capability_changed.items, reattached.items); + assert_ne!(capability_changed.revision, reattached.revision); + let restarted = SqliteWorkspaceStore::open(&api.config.database_path).unwrap(); + let (restarted_page, restarted_revision) = restarted + .list_worker_workdir_links_page_with_revision( + TEST_WORKSPACE_ID, + &fixture.worker, + 51, + 0, + None, + ) + .unwrap(); + assert_eq!(restarted_page.len(), 51); + assert_eq!(restarted_revision, capability_changed.revision); + // A SQLite backup restores the same authoritative content/revision, without a sync store. + let source = rusqlite::Connection::open(&api.config.database_path).unwrap(); + let mut snapshot = rusqlite::Connection::open_in_memory().unwrap(); + rusqlite::backup::Backup::new(&source, &mut snapshot) + .unwrap() + .run_to_completion(128, std::time::Duration::from_millis(1), None) + .unwrap(); + let restored = SqliteWorkspaceStore::from_connection(snapshot).unwrap(); + assert_eq!( + restored + .list_worker_workdir_links_page_with_revision( + TEST_WORKSPACE_ID, + &fixture.worker, + 51, + 0, + None + ) + .unwrap(), + (restarted_page, restarted_revision) + ); + // Independent SQLite connections race a last-row capability update against exact lookup. + // The page and complete revision must always describe the same snapshot, not two reads. + let mut read_write_links = api + .store + .list_worker_workdir_links(TEST_WORKSPACE_ID, &fixture.worker) + .unwrap(); + read_write_links + .iter_mut() + .find(|link| link.connection_id == replacement.connection_id) + .unwrap() + .capabilities = workdir::WorkdirSessionCapabilities::READ_WRITE; + let read_only_links = api + .store + .list_worker_workdir_links(TEST_WORKSPACE_ID, &fixture.worker) + .unwrap(); + restarted + .replace_worker_workdir_link_capabilities( + TEST_WORKSPACE_ID, + &fixture.worker, + &read_write_links, + ) + .unwrap(); + let (_, read_write_revision) = restarted + .list_worker_workdir_links_page_with_revision( + TEST_WORKSPACE_ID, + &fixture.worker, + 1, + 0, + Some(&replacement.connection_id), + ) + .unwrap(); + assert_ne!(read_write_revision, capability_changed.revision); + let reader = SqliteWorkspaceStore::open(&api.config.database_path).unwrap(); + let barrier = Arc::new(std::sync::Barrier::new(2)); + std::thread::scope(|scope| { + let worker = &fixture.worker; + let writer_barrier = barrier.clone(); + scope.spawn(move || { + writer_barrier.wait(); + for index in 0..64 { + restarted + .replace_worker_workdir_link_capabilities( + TEST_WORKSPACE_ID, + worker, + if index % 2 == 0 { + &read_write_links + } else { + &read_only_links + }, + ) + .unwrap(); + } + }); + barrier.wait(); + for _ in 0..100 { + let (page, revision) = reader + .list_worker_workdir_links_page_with_revision( + TEST_WORKSPACE_ID, + worker, + 1, + 0, + Some(&replacement.connection_id), + ) + .unwrap(); + assert_eq!(page.len(), 1); + if revision == read_write_revision { + assert_eq!( + page[0].capabilities, + workdir::WorkdirSessionCapabilities::READ_WRITE + ); + } else { + assert_eq!(revision, capability_changed.revision); + assert_eq!( + page[0].capabilities, + workdir::WorkdirSessionCapabilities::READ_ONLY + ); + } + } + }); + let response = build_router(api.clone()) + .oneshot(request(&lookup_path)) + .await + .unwrap(); + assert_eq!(response.status(), StatusCode::OK); + let stale: server_api::CurrentWorkerWorkdirAttachmentListResponse = + serde_json::from_slice(&to_bytes(response.into_body(), usize::MAX).await.unwrap()) + .unwrap(); + assert!(stale.items.is_empty()); + assert_eq!(stale.next_offset, None); + let response = build_router(api.clone()) + .oneshot(request(&format!( + "{base}?limit=1&connection_id={}", + replacement.connection_id, + ))) + .await + .unwrap(); + assert_eq!(response.status(), StatusCode::OK); + let current: server_api::CurrentWorkerWorkdirAttachmentListResponse = + serde_json::from_slice(&to_bytes(response.into_body(), usize::MAX).await.unwrap()) + .unwrap(); + assert_eq!(current.items.len(), 1); + assert_eq!(current.items[0].connection_id, replacement.connection_id); + assert_eq!(current.next_offset, None); + } + + #[tokio::test] + async fn current_worker_attachment_failed_detach_preserves_connection_identity() { + let workspace = tempfile::tempdir().unwrap(); + init_clean_git_workspace(workspace.path()); + let api = test_api(workspace.path()).await; + let worker_id = seed_cleanup_worker(&api, 901, "normal"); + seed_cleanup_workdir(&api, "compensation-workdir", "present", "clean"); + seed_cleanup_link(&api, &worker_id, "compensation-workdir"); + let worker = RuntimeWorkerRef::new("runtime-test", worker_id); + let link = api + .store + .list_worker_workdir_links(TEST_WORKSPACE_ID, &worker) + .unwrap() + .remove(0); + // No live Runtime provider: runtime synchronization rejects the detach after mutation. + assert!( + detach_current_worker_workdir(&api, &worker, "attachment", Some(&link.connection_id)) + .await + .is_err() + ); + assert_eq!( + api.store + .list_worker_workdir_links(TEST_WORKSPACE_ID, &worker) + .unwrap(), + vec![link] + ); } #[tokio::test] @@ -54707,6 +57002,7 @@ mod tests { .unwrap(); api.store .attach_worker_workdir(&WorkerWorkdirLinkRecord { + connection_id: String::new(), workspace_id: TEST_WORKSPACE_ID.to_string(), worker: RuntimeWorkerRef::new("embedded-worker-runtime", &worker_id), workdir_id: workdir_id.to_string(), @@ -55104,6 +57400,7 @@ mod tests { .unwrap(); api.store .attach_worker_workdir(&WorkerWorkdirLinkRecord { + connection_id: String::new(), workspace_id: TEST_WORKSPACE_ID.to_string(), worker: RuntimeWorkerRef::new(EMBEDDED_WORKER_RUNTIME_ID, &worker_id), workdir_id: workdir_id.to_string(), diff --git a/crates/workspace-server/src/store.rs b/crates/workspace-server/src/store.rs index ecfebdec8..27a63c9ba 100644 --- a/crates/workspace-server/src/store.rs +++ b/crates/workspace-server/src/store.rs @@ -28,7 +28,7 @@ use crate::workspace_deletion::WorkspaceDeletionStore; use crate::{Error, Result}; const OLDEST_SCHEMA_VERSION: i64 = 50; -const LATEST_SCHEMA_VERSION: i64 = 77; +const LATEST_SCHEMA_VERSION: i64 = 78; const SCHEMA_BASELINE_NAME: &str = "workspace schema baseline"; const WORKSPACE_RUNTIME_BINDINGS_MIGRATION_NAME: &str = "workspace runtime bindings"; const RUNTIME_BINDING_AUDIT_MIGRATION_NAME: &str = "workspace Runtime binding revision and audit"; @@ -77,6 +77,7 @@ const ARCHIVE_OBSERVE_GRANTS_MIGRATION_NAME: &str = "preserve explicit observe grants for committed Worker Session archives"; const BACKEND_JOB_WORKER_CLEANUP_MIGRATION_NAME: &str = "durable terminal Backend Job Worker cleanup"; +const WORKDIR_CONNECTION_ID_MIGRATION_NAME: &str = "durable Workdir attachment connection identity"; const TICKET_SCHEMA_VERSION_WITH_TARGETS: i64 = 7; const TICKET_SCHEMA_BASELINE_NAME: &str = "ticket schema baseline"; const WORKER_REGISTRY_PROJECTION_SCHEMA: &str = r#" @@ -259,6 +260,11 @@ const MIGRATIONS: &[Migration] = &[ name: BACKEND_JOB_WORKER_CLEANUP_MIGRATION_NAME, apply: migrate_backend_job_worker_cleanup_v76_to_v77, }, + Migration { + version: 78, + name: WORKDIR_CONNECTION_ID_MIGRATION_NAME, + apply: migrate_workdir_connection_id_v77_to_v78, + }, ]; #[derive(Clone, Copy)] @@ -945,6 +951,23 @@ pub struct WorkdirRegistryRecord { pub updated_at: String, } +/// Projection inputs captured together with the catalog revision in one SQLite snapshot. +#[derive(Debug, Clone)] +pub struct WorkdirCatalogEntry { + pub record: WorkdirRegistryRecord, + pub repository_key: Option, + /// None for Repository records and unsupported legacy External grants. + pub external_grant_permissions: Option, + pub occupied_by: Option, +} + +#[derive(Debug, Clone)] +pub struct WorkdirCatalogPage { + pub entries: Vec, + pub next_cursor: Option, + pub revision: String, +} + #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] pub struct ExternalWorkdirGrantRecord { pub grant_id: String, @@ -963,6 +986,8 @@ pub struct ExternalWorkdirGrantRecord { #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] pub struct WorkerWorkdirLinkRecord { + /// Assigned by the ledger on attach; preserved only by explicit compensation. + pub connection_id: String, pub workspace_id: String, pub worker: RuntimeWorkerRef, pub workdir_id: String, @@ -1888,6 +1913,12 @@ pub trait ControlPlaneStore: Send + Sync + WorkspaceDeletionStore { workspace_id: &str, limit: usize, ) -> Result>; + fn workdir_catalog_page( + &self, + workspace_id: &str, + limit: u32, + cursor: Option<&str>, + ) -> Result; fn delete_workdir_registry(&self, workspace_id: &str, workdir_id: &str) -> Result; fn create_external_workdir_grant( @@ -1983,6 +2014,31 @@ pub trait ControlPlaneStore: Send + Sync + WorkspaceDeletionStore { expected_workdir_id: Option<&str>, unlinked_at: &str, ) -> Result>; + fn detach_worker_workdir_connection( + &self, + workspace_id: &str, + worker: &RuntimeWorkerRef, + alias: &str, + expected_connection_id: &str, + unlinked_at: &str, + ) -> Result; + fn restore_worker_workdir_connection(&self, record: &WorkerWorkdirLinkRecord) -> Result<()>; + fn list_worker_workdir_links_page( + &self, + workspace_id: &str, + worker: &RuntimeWorkerRef, + limit: u32, + offset: u32, + connection_id: Option<&str>, + ) -> Result>; + fn list_worker_workdir_links_page_with_revision( + &self, + workspace_id: &str, + worker: &RuntimeWorkerRef, + limit: u32, + offset: u32, + connection_id: Option<&str>, + ) -> Result<(Vec, String)>; fn worker_workdir_link_history_exists( &self, workspace_id: &str, @@ -8876,6 +8932,72 @@ impl ControlPlaneStore for SqliteWorkspaceStore { }) } + fn workdir_catalog_page( + &self, + workspace_id: &str, + limit: u32, + cursor: Option<&str>, + ) -> Result { + if limit == 0 || limit > 100 { + return Err(Error::InvalidInput( + "catalog page limit must be 1..=100".to_string(), + )); + } + if cursor.is_some_and(|id| { + id.trim().is_empty() || id.len() > 128 || id.chars().any(char::is_control) + }) { + return Err(Error::InvalidInput( + "catalog cursor must contain 1..=128 bytes without control characters".to_string(), + )); + } + self.with_conn(|conn| { + // Also fence writers using other SQLite connections, not only this mutex. + let tx = conn.unchecked_transaction()?; + let sql = workdir_catalog_select_sql(); + let mut hasher = Sha256::new(); + update_public_collection_digest(&mut hasher, &("workdir-catalog:v1", workspace_id))?; + { + let mut stmt = tx.prepare(&format!("{sql} WHERE wr.workspace_id = ?1 ORDER BY wr.workdir_id"))?; + let mut rows = stmt.query(params![workspace_id])?; + while let Some(row) = rows.next()? { + let (entry, connection_id) = read_workdir_catalog_entry(row)?; + let record = &entry.record; + // Safe projection fields and timestamps only: no paths, provider identity, + // credentials, Repository UUIDs, or raw internal records. + update_public_collection_digest(&mut hasher, &( + &record.workdir_id, &record.display_name, &entry.repository_key, + record.source.external_grant_id(), &entry.external_grant_permissions, + (&record.creation_selector, &record.creation_ref, &record.creation_tree), + (&record.current_selector, &record.current_ref, &record.current_tree), + record.observed_at_epoch_seconds, + (&record.materialization_status, &record.cleanliness), + (&record.created_at, &record.updated_at), + &entry.occupied_by, connection_id, + ))?; + } + } + let mut stmt = tx.prepare(&format!( + "{sql} WHERE wr.workspace_id = ?1 AND (?2 IS NULL OR wr.workdir_id > ?2) ORDER BY wr.workdir_id ASC LIMIT ?3" + ))?; + let mut rows = stmt.query(params![workspace_id, cursor, limit + 1])?; + let mut entries = Vec::new(); + while let Some(row) = rows.next()? { + entries.push(read_workdir_catalog_entry(row)?.0); + } + let next_cursor = if entries.len() > limit as usize { + entries.truncate(limit as usize); + entries.last().map(|entry| entry.record.workdir_id.clone()) + } else { + None + }; + Ok(WorkdirCatalogPage { + entries, + next_cursor, + revision: hasher.finalize().iter().map(|byte| format!("{byte:02x}")).collect::(), + }) + }) + } + fn delete_workdir_registry(&self, workspace_id: &str, workdir_id: &str) -> Result { self.with_conn(|conn| { let tx = rusqlite::Transaction::new_unchecked( @@ -9349,6 +9471,8 @@ impl ControlPlaneStore for SqliteWorkspaceStore { "reserved attachment finalization requires an active attachment".to_string(), )); } + let mut record = record.clone(); + record.connection_id = uuid::Uuid::now_v7().to_string(); self.with_conn(|conn| { let tx = rusqlite::Transaction::new_unchecked( conn, @@ -9370,11 +9494,12 @@ impl ControlPlaneStore for SqliteWorkspaceStore { } tx.execute( r#"INSERT INTO worker_workdir_links ( - workspace_id, runtime_id, worker_id, workdir_id, alias, capabilities, linked_at, unlinked_at - ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, NULL) + workspace_id, runtime_id, worker_id, workdir_id, alias, capabilities, linked_at, unlinked_at, connection_id + ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, NULL, ?8) ON CONFLICT(workspace_id, runtime_id, worker_id, workdir_id, alias) DO UPDATE SET capabilities = excluded.capabilities, linked_at = excluded.linked_at, + connection_id = excluded.connection_id, unlinked_at = NULL"#, params![ record.workspace_id, @@ -9384,6 +9509,7 @@ impl ControlPlaneStore for SqliteWorkspaceStore { record.alias, encode_workdir_link_capabilities(record.capabilities)?, record.linked_at, + record.connection_id, ], ) .map_err(|error| { @@ -9421,6 +9547,8 @@ impl ControlPlaneStore for SqliteWorkspaceStore { "a new attachment cannot already be unlinked".to_string(), )); } + let mut record = record.clone(); + record.connection_id = uuid::Uuid::now_v7().to_string(); self.with_conn(|conn| { let tx = rusqlite::Transaction::new_unchecked( conn, @@ -9456,7 +9584,7 @@ impl ControlPlaneStore for SqliteWorkspaceStore { } let active_for_alias = tx .query_row( - r#"SELECT workspace_id, runtime_id, worker_id, workdir_id, alias, capabilities, linked_at, unlinked_at + r#"SELECT workspace_id, runtime_id, worker_id, workdir_id, alias, capabilities, linked_at, unlinked_at, connection_id FROM worker_workdir_links WHERE workspace_id = ?1 AND runtime_id = ?2 AND worker_id = ?3 AND alias = ?4 AND unlinked_at IS NULL"#, @@ -9499,7 +9627,7 @@ impl ControlPlaneStore for SqliteWorkspaceStore { } let active_for_workdir = tx .query_row( - r#"SELECT workspace_id, runtime_id, worker_id, workdir_id, alias, capabilities, linked_at, unlinked_at + r#"SELECT workspace_id, runtime_id, worker_id, workdir_id, alias, capabilities, linked_at, unlinked_at, connection_id FROM worker_workdir_links WHERE workspace_id = ?1 AND workdir_id = ?2 AND unlinked_at IS NULL"#, params![record.workspace_id, record.workdir_id], @@ -9514,11 +9642,12 @@ impl ControlPlaneStore for SqliteWorkspaceStore { } let write = tx.execute( r#"INSERT INTO worker_workdir_links ( - workspace_id, runtime_id, worker_id, workdir_id, alias, capabilities, linked_at, unlinked_at - ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, NULL) + workspace_id, runtime_id, worker_id, workdir_id, alias, capabilities, linked_at, unlinked_at, connection_id + ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, NULL, ?8) ON CONFLICT(workspace_id, runtime_id, worker_id, workdir_id, alias) DO UPDATE SET capabilities = excluded.capabilities, linked_at = excluded.linked_at, + connection_id = excluded.connection_id, unlinked_at = NULL"#, params![ record.workspace_id, @@ -9528,6 +9657,7 @@ impl ControlPlaneStore for SqliteWorkspaceStore { record.alias, encode_workdir_link_capabilities(record.capabilities)?, record.linked_at, + record.connection_id, ], ); if let Err(error) = write { @@ -9556,7 +9686,7 @@ impl ControlPlaneStore for SqliteWorkspaceStore { rusqlite::TransactionBehavior::Immediate, )?; let mut stmt = tx.prepare( - r#"SELECT workspace_id, runtime_id, worker_id, workdir_id, alias, capabilities, linked_at, unlinked_at + r#"SELECT workspace_id, runtime_id, worker_id, workdir_id, alias, capabilities, linked_at, unlinked_at, connection_id FROM worker_workdir_links WHERE workspace_id = ?1 AND runtime_id = ?2 AND worker_id = ?3 AND unlinked_at IS NULL"#, @@ -9571,7 +9701,7 @@ impl ControlPlaneStore for SqliteWorkspaceStore { let mut persisted_identity = persisted .iter() - .map(|link| (link.alias.as_str(), link.workdir_id.as_str())) + .map(|link| (link.alias.as_str(), link.workdir_id.as_str(), link.connection_id.as_str())) .collect::>(); persisted_identity.sort_unstable(); let mut requested_identity = active_links @@ -9592,7 +9722,7 @@ impl ControlPlaneStore for SqliteWorkspaceStore { link.alias )) })?; - Ok((link.alias.as_str(), link.workdir_id.as_str())) + Ok((link.alias.as_str(), link.workdir_id.as_str(), link.connection_id.as_str())) }) .collect::>>()?; requested_identity.sort_unstable(); @@ -9644,7 +9774,7 @@ impl ControlPlaneStore for SqliteWorkspaceStore { )?; let active = tx .query_row( - r#"SELECT workspace_id, runtime_id, worker_id, workdir_id, alias, capabilities, linked_at, unlinked_at + r#"SELECT workspace_id, runtime_id, worker_id, workdir_id, alias, capabilities, linked_at, unlinked_at, connection_id FROM worker_workdir_links WHERE workspace_id = ?1 AND runtime_id = ?2 AND worker_id = ?3 AND (?4 IS NULL OR workdir_id = ?4) AND unlinked_at IS NULL"#, @@ -9683,6 +9813,202 @@ impl ControlPlaneStore for SqliteWorkspaceStore { }) } + fn detach_worker_workdir_connection( + &self, + workspace_id: &str, + worker: &RuntimeWorkerRef, + alias: &str, + expected_connection_id: &str, + unlinked_at: &str, + ) -> Result { + self.with_conn(|conn| { + let tx = rusqlite::Transaction::new_unchecked( + conn, + rusqlite::TransactionBehavior::Immediate, + )?; + let active = tx + .query_row( + r#"SELECT workspace_id, runtime_id, worker_id, workdir_id, alias, capabilities, + linked_at, unlinked_at, connection_id + FROM worker_workdir_links + WHERE workspace_id = ?1 AND runtime_id = ?2 AND worker_id = ?3 + AND alias = ?4 AND connection_id = ?5 AND unlinked_at IS NULL"#, + params![ + workspace_id, + worker.runtime_id, + worker.worker_id, + alias, + expected_connection_id + ], + read_worker_workdir_link_record, + ) + .optional()? + .ok_or_else(|| { + Error::WorkdirAttachmentConflict(format!( + "Workdir attachment `{alias}` connection changed during detach" + )) + })?; + let changed = tx.execute( + r#"UPDATE worker_workdir_links SET unlinked_at = ?6 + WHERE workspace_id = ?1 AND runtime_id = ?2 AND worker_id = ?3 + AND alias = ?4 AND connection_id = ?5 AND unlinked_at IS NULL"#, + params![ + workspace_id, + worker.runtime_id, + worker.worker_id, + alias, + expected_connection_id, + unlinked_at + ], + )?; + if changed != 1 { + return Err(Error::WorkdirAttachmentConflict(format!( + "Workdir attachment `{alias}` connection changed during detach" + ))); + } + tx.commit()?; + Ok(WorkerWorkdirLinkRecord { + unlinked_at: Some(unlinked_at.to_string()), + ..active + }) + }) + } + + fn restore_worker_workdir_connection(&self, record: &WorkerWorkdirLinkRecord) -> Result<()> { + self.with_conn(|conn| { + // Compensation reactivates only the same lifetime, never an intervening attach. + let changed = conn.execute( + r#"UPDATE worker_workdir_links SET unlinked_at = NULL + WHERE workspace_id = ?1 AND runtime_id = ?2 AND worker_id = ?3 + AND workdir_id = ?4 AND alias = ?5 AND connection_id = ?6 + AND unlinked_at IS NOT NULL + AND NOT EXISTS ( + SELECT 1 FROM worker_workdir_attachment_reservations + WHERE workspace_id = ?1 AND workdir_id = ?4 + )"#, + params![record.workspace_id, record.worker.runtime_id, record.worker.worker_id, + record.workdir_id, record.alias, record.connection_id], + ).map_err(|error| { + if matches!(error, rusqlite::Error::SqliteFailure(ref code, _) if code.extended_code == rusqlite::ffi::SQLITE_CONSTRAINT_UNIQUE) { + Error::WorkdirAttachmentConflict("Workdir acquired another active attachment during compensation".to_string()) + } else { + error.into() + } + })?; + if changed != 1 { + return Err(Error::WorkdirAttachmentConflict( + "Workdir attachment connection changed during compensation".to_string(), + )); + } + Ok(()) + }) + } + + fn list_worker_workdir_links_page( + &self, + workspace_id: &str, + worker: &RuntimeWorkerRef, + limit: u32, + offset: u32, + connection_id: Option<&str>, + ) -> Result> { + self.list_worker_workdir_links_page_with_revision( + workspace_id, + worker, + limit, + offset, + connection_id, + ) + .map(|(links, _)| links) + } + + fn list_worker_workdir_links_page_with_revision( + &self, + workspace_id: &str, + worker: &RuntimeWorkerRef, + limit: u32, + offset: u32, + connection_id: Option<&str>, + ) -> Result<(Vec, String)> { + if limit == 0 || limit > 101 { + return Err(Error::InvalidInput( + "attachment page limit must be 1..=101".to_string(), + )); + } + if connection_id.is_some_and(|id| { + id.trim().is_empty() || id.len() > 128 || id.chars().any(char::is_control) + }) { + return Err(Error::InvalidInput( + "attachment connection_id must contain 1..=128 bytes without control characters" + .to_string(), + )); + } + self.with_conn(|conn| { + let tx = conn.unchecked_transaction()?; + let mut hasher = Sha256::new(); + update_public_collection_digest( + &mut hasher, + &( + "workdir-connections:v1", + workspace_id, + &worker.runtime_id, + &worker.worker_id, + ), + )?; + { + let mut stmt = tx.prepare( + "SELECT alias, connection_id, workdir_id, capabilities FROM worker_workdir_links + WHERE workspace_id = ?1 AND runtime_id = ?2 AND worker_id = ?3 + AND unlinked_at IS NULL ORDER BY alias" + )?; + let mut rows = + stmt.query(params![workspace_id, worker.runtime_id, worker.worker_id])?; + while let Some(row) = rows.next()? { + let alias: String = row.get(0)?; + let id: String = row.get(1)?; + let workdir: String = row.get(2)?; + let capabilities = + decode_workdir_link_capabilities(&row.get::<_, String>(3)?, 3).map_err( + |_| Error::Store("invalid public connection capabilities".to_string()), + )?; + update_public_collection_digest( + &mut hasher, + &(alias, id, workdir, capabilities), + )?; + } + } + let mut stmt = tx.prepare( + r#"SELECT workspace_id, runtime_id, worker_id, workdir_id, alias, capabilities, + linked_at, unlinked_at, connection_id + FROM worker_workdir_links + WHERE workspace_id = ?1 AND runtime_id = ?2 AND worker_id = ?3 + AND unlinked_at IS NULL + AND (?6 IS NULL OR connection_id = ?6) + ORDER BY alias LIMIT ?4 OFFSET ?5"#, + )?; + let rows = stmt.query_map( + params![ + workspace_id, + worker.runtime_id, + worker.worker_id, + limit, + offset, + connection_id + ], + read_worker_workdir_link_record, + )?; + let links = rows.collect::, _>>()?; + Ok(( + links, + hasher + .finalize() + .iter() + .map(|byte| format!("{byte:02x}")) + .collect::(), + )) + }) + } + fn worker_workdir_link_history_exists( &self, workspace_id: &str, @@ -9708,7 +10034,7 @@ impl ControlPlaneStore for SqliteWorkspaceStore { ) -> Result> { self.with_conn(|conn| { let mut stmt = conn.prepare( - r#"SELECT workspace_id, runtime_id, worker_id, workdir_id, alias, capabilities, linked_at, unlinked_at + r#"SELECT workspace_id, runtime_id, worker_id, workdir_id, alias, capabilities, linked_at, unlinked_at, connection_id FROM worker_workdir_links WHERE workspace_id = ?1 AND runtime_id = ?2 AND worker_id = ?3 AND unlinked_at IS NULL ORDER BY linked_at DESC"#, @@ -9729,7 +10055,7 @@ impl ControlPlaneStore for SqliteWorkspaceStore { ) -> Result> { self.with_conn(|conn| { let mut stmt = conn.prepare( - r#"SELECT workspace_id, runtime_id, worker_id, workdir_id, alias, capabilities, linked_at, unlinked_at + r#"SELECT workspace_id, runtime_id, worker_id, workdir_id, alias, capabilities, linked_at, unlinked_at, connection_id FROM worker_workdir_links WHERE workspace_id = ?1 AND workdir_id = ?2 AND unlinked_at IS NULL ORDER BY linked_at DESC"#, @@ -9750,7 +10076,7 @@ impl ControlPlaneStore for SqliteWorkspaceStore { ) -> Result> { self.with_conn(|conn| { conn.query_row( - r#"SELECT workspace_id, runtime_id, worker_id, workdir_id, alias, capabilities, linked_at, unlinked_at + r#"SELECT workspace_id, runtime_id, worker_id, workdir_id, alias, capabilities, linked_at, unlinked_at, connection_id FROM worker_workdir_links WHERE workspace_id = ?1 AND workdir_id = ?2 ORDER BY linked_at DESC, rowid DESC @@ -10497,6 +10823,7 @@ fn read_worker_workdir_link_record( capabilities, linked_at: row.get(6)?, unlinked_at: row.get(7)?, + connection_id: row.get(8)?, }) } @@ -11442,6 +11769,96 @@ fn require_expected_ticket_assignment( ))) } +// Length-framed canonical JSON prevents ambiguous concatenations and never hashes raw ledgers. +fn update_public_collection_digest(hasher: &mut Sha256, value: &impl Serialize) -> Result<()> { + let bytes = serde_json::to_vec(value) + .map_err(|_| Error::Store("unable to encode public collection metadata".to_string()))?; + hasher.update((bytes.len() as u64).to_be_bytes()); + hasher.update(bytes); + Ok(()) +} + +fn workdir_catalog_select_sql() -> String { + format!( + "SELECT wr.*, r.repository_key, g.permissions, + l.runtime_id, l.worker_id, w.display_name, l.linked_at, l.connection_id + FROM ({}) wr + LEFT JOIN repositories r ON r.workspace_id = wr.workspace_id AND r.repository_id = wr.repository_id + LEFT JOIN external_workdir_grants g ON g.workspace_id = wr.workspace_id AND g.grant_id = wr.external_grant_id + LEFT JOIN worker_workdir_links l ON l.rowid = ( + SELECT rowid FROM worker_workdir_links + WHERE workspace_id = wr.workspace_id AND workdir_id = wr.workdir_id AND unlinked_at IS NULL + ORDER BY linked_at DESC, rowid DESC LIMIT 1 + ) + LEFT JOIN worker_registry w ON w.workspace_id = l.workspace_id AND w.runtime_id = l.runtime_id AND w.worker_id = l.worker_id", + workdir_registry_select_sql("") + ) +} + +fn read_workdir_catalog_entry( + row: &rusqlite::Row<'_>, +) -> Result<(WorkdirCatalogEntry, Option)> { + let record = read_workdir_registry_record(row)?; + let repository_key: Option = row.get(18)?; + let external_grant_permissions = match &record.source { + WorkdirRegistrySource::Repository { .. } => { + if repository_key.is_none() { + return Err(Error::Store( + "Workdir catalog Repository is unavailable".to_string(), + )); + } + None + } + WorkdirRegistrySource::ExternalGrant { .. } => { + let permissions: Option = row.get(19)?; + match permissions.as_deref() { + Some("read_only") => Some(workdir::workspace::WorkdirPermissionSummary { + read: true, + write: false, + command: false, + }), + Some("read_write") => Some(workdir::workspace::WorkdirPermissionSummary { + read: true, + write: true, + command: false, + }), + Some("read_write_command") => Some(workdir::workspace::WorkdirPermissionSummary { + read: true, + write: true, + command: true, + }), + Some("command_only" | "read_command") => None, + _ => { + return Err(Error::Store( + "Workdir catalog External grant permissions are unavailable".to_string(), + )); + } + } + } + }; + let runtime_id: Option = row.get(20)?; + let occupied_by = match runtime_id { + Some(runtime_id) => Some(workdir::workspace::WorkingDirectoryOccupancy { + runtime_id, + worker_id: row.get(21)?, + display_name: row.get::<_, Option>(22)?.ok_or_else(|| { + Error::Store("Workdir catalog occupant is unavailable".to_string()) + })?, + linked_at: row.get(23)?, + }), + None => None, + }; + Ok(( + WorkdirCatalogEntry { + record, + repository_key, + external_grant_permissions, + occupied_by, + }, + row.get(24)?, + )) +} + fn workdir_registry_select_sql(where_clause: &str) -> String { format!( "SELECT workspace_id, workdir_id, display_name, source_kind, runtime_id, repository_id, external_grant_id, \ @@ -14486,6 +14903,27 @@ fn migrate_backend_job_worker_cleanup_v76_to_v77(conn: &Connection) -> Result<() Ok(()) } +fn migrate_workdir_connection_id_v77_to_v78(conn: &Connection) -> Result<()> { + use rusqlite::TransactionBehavior; + let current = current_schema_version(conn)?; + if current != 77 { + return Err(Error::Store(format!( + "expected schema version 77 before {WORKDIR_CONNECTION_ID_MIGRATION_NAME} migration, found {current}" + ))); + } + let tx = rusqlite::Transaction::new_unchecked(conn, TransactionBehavior::Exclusive)?; + tx.execute_batch( + "ALTER TABLE worker_workdir_links ADD COLUMN connection_id TEXT NOT NULL DEFAULT ''; + UPDATE worker_workdir_links SET connection_id = lower(hex(randomblob(16)));", + )?; + tx.execute( + "INSERT INTO __yoi_schema_migrations (version, name) VALUES (?1, ?2)", + params![78_i64, WORKDIR_CONNECTION_ID_MIGRATION_NAME], + )?; + tx.commit()?; + Ok(()) +} + fn create_latest_workspace_schema(conn: &Connection) -> Result<()> { conn.execute_batch("DROP TABLE IF EXISTS typed_ticket_targets;")?; conn.execute_batch(include_str!("latest_schema.sql"))?; @@ -14546,6 +14984,7 @@ fn create_latest_workspace_schema(conn: &Connection) -> Result<()> { ); CREATE INDEX typed_ticket_targets_workspace_repository ON typed_ticket_targets(workspace_id, repository_key, ticket_id); + ALTER TABLE worker_workdir_links ADD COLUMN connection_id TEXT NOT NULL DEFAULT ''; ALTER TABLE worker_workdir_links ADD COLUMN capabilities TEXT NOT NULL CHECK (capabilities IN ( @@ -15610,6 +16049,46 @@ mod tests { Ok(()) } + #[test] + fn workdir_connection_migration_backfills_distinct_durable_lifetimes() { + let conn = Connection::open_in_memory().unwrap(); + conn.execute_batch( + "CREATE TABLE __yoi_schema_migrations (version INTEGER PRIMARY KEY, name TEXT NOT NULL); + INSERT INTO __yoi_schema_migrations VALUES (77, 'workspace schema baseline'); + CREATE TABLE worker_workdir_links (alias TEXT, linked_at TEXT, unlinked_at TEXT); + INSERT INTO worker_workdir_links VALUES ('checkout', 'same-time', NULL), ('docs', 'same-time', 'removed');", + ).unwrap(); + migrate_workdir_connection_id_v77_to_v78(&conn).unwrap(); + assert_eq!(current_schema_version(&conn).unwrap(), 78); + let rows = || { + let mut stmt = conn.prepare("SELECT alias, linked_at, unlinked_at, connection_id FROM worker_workdir_links ORDER BY alias").unwrap(); + stmt.query_map([], |row| { + Ok(( + row.get::<_, String>(0)?, + row.get::<_, String>(1)?, + row.get::<_, Option>(2)?, + row.get::<_, String>(3)?, + )) + }) + .unwrap() + .collect::, _>>() + .unwrap() + }; + let migrated = rows(); + assert_eq!(migrated[0].0, "checkout"); + assert_eq!(migrated[0].1, "same-time"); + assert_eq!(migrated[0].2, None); + assert_eq!(migrated[1].2.as_deref(), Some("removed")); + assert!(!migrated[0].3.is_empty()); + assert_ne!(migrated[0].3, migrated[1].3); + assert!(migrate_workdir_connection_id_v77_to_v78(&conn).is_err()); + assert_eq!( + rows(), + migrated, + "a retry cannot replace an already durable connection ID" + ); + } + #[test] fn worker_workdir_link_capability_codec_round_trips_only_canonical_sets() { for (capabilities, encoded) in WORKDIR_LINK_CAPABILITY_ENCODINGS { @@ -17052,6 +17531,10 @@ mod tests { version: 77, name: BACKEND_JOB_WORKER_CLEANUP_MIGRATION_NAME.to_string(), }, + WorkspaceSchemaMigrationStep { + version: 78, + name: WORKDIR_CONNECTION_ID_MIGRATION_NAME.to_string(), + }, ] ); @@ -17139,6 +17622,7 @@ mod tests { 77, BACKEND_JOB_WORKER_CLEANUP_MIGRATION_NAME.to_string(), ), + (78, WORKDIR_CONNECTION_ID_MIGRATION_NAME.to_string()), ] ); assert!(!table_exists(conn, "trusted_runtime_records")?); @@ -17490,7 +17974,7 @@ mod tests { .collect::>(), vec![ 52, 53, 54, 55, 56, 57, 58, 59, 60, 61, 62, 63, 64, 65, 66, 67, 68, 69, 70, 71, 72, - 73, 74, 75, 76, 77 + 73, 74, 75, 76, 77, 78 ] ); SqliteWorkspaceStore::migrate_database(&path).unwrap(); @@ -17499,7 +17983,7 @@ mod tests { current_schema_version(&conn).unwrap(), LATEST_SCHEMA_VERSION ); - assert_eq!(workspace_schema_migration_history(&conn).unwrap().len(), 28); + assert_eq!(workspace_schema_migration_history(&conn).unwrap().len(), 29); assert!(column_exists(&conn, "worker_workdir_links", "alias").unwrap()); assert!(column_exists(&conn, "worker_workdir_links", "capabilities").unwrap()); assert!(!column_exists(&conn, "worker_workdir_links", "role").unwrap()); @@ -20855,6 +21339,7 @@ INSERT INTO worker_registry ( store.upsert_workdir_registry(&unmanaged_workdir).unwrap(); let link = WorkerWorkdirLinkRecord { + connection_id: String::new(), workspace_id: "local-dev".to_string(), worker: worker.worker.clone(), workdir_id: workdir.workdir_id.clone(), @@ -20883,12 +21368,10 @@ INSERT INTO worker_registry ( store.attach_worker_workdir(&link), Err(Error::WorkdirAttachmentConflict(_)) )); - assert_eq!( - store - .finalize_reserved_worker_workdir_attachment(&link, "spawn-1") - .unwrap(), - link - ); + let link = store + .finalize_reserved_worker_workdir_attachment(&link, "spawn-1") + .unwrap(); + assert!(!link.connection_id.is_empty()); assert_eq!(store.attach_worker_workdir(&link).unwrap(), link); let downgraded_link = WorkerWorkdirLinkRecord { capabilities: workdir::WorkdirSessionCapabilities::READ_ONLY, @@ -20958,10 +21441,7 @@ INSERT INTO worker_registry ( store.attach_worker_workdir(&invalid_capabilities), Err(Error::InvalidInput(_)) )); - assert_eq!( - store.attach_worker_workdir(&second_attachment).unwrap(), - second_attachment - ); + let second_attachment = store.attach_worker_workdir(&second_attachment).unwrap(); assert_eq!( store .list_worker_workdir_links("local-dev", &worker.worker) @@ -20991,6 +21471,157 @@ INSERT INTO worker_registry ( store.detach_worker_workdir("local-dev", &worker.worker, Some("wrong-workdir"), "6",), Err(Error::WorkdirAttachmentConflict(_)) )); + let old_connection = downgraded_link.connection_id.clone(); + let removed = store + .detach_worker_workdir_connection( + "local-dev", + &worker.worker, + "checkout", + &old_connection, + "5", + ) + .unwrap(); + store.restore_worker_workdir_connection(&removed).unwrap(); + assert_eq!( + store + .list_worker_workdir_links_page("local-dev", &worker.worker, 1, 0, None) + .unwrap()[0], + downgraded_link + ); + store + .detach_worker_workdir_connection( + "local-dev", + &worker.worker, + "checkout", + &old_connection, + "5", + ) + .unwrap(); + // Reusing the exact record, including alias, Workdir ID and timestamp, starts a fresh lifetime. + let replacement = store.attach_worker_workdir(&downgraded_link).unwrap(); + assert_ne!(replacement.connection_id, old_connection); + assert!(matches!( + store.detach_worker_workdir_connection( + "local-dev", + &worker.worker, + "checkout", + &old_connection, + "6", + ), + Err(Error::WorkdirAttachmentConflict(_)) + )); + assert!(matches!( + store.restore_worker_workdir_connection(&removed), + Err(Error::WorkdirAttachmentConflict(_)) + )); + assert!(matches!( + store.replace_worker_workdir_link_capabilities( + "local-dev", + &worker.worker, + &[downgraded_link.clone(), second_attachment.clone()], + ), + Err(Error::WorkdirAttachmentConflict(_)) + )); + assert_eq!(store.attach_worker_workdir(&link).unwrap(), replacement); + assert_eq!( + store + .list_worker_workdir_links_page("local-dev", &worker.worker, 1, 0, None) + .unwrap(), + vec![replacement.clone()] + ); + assert_eq!( + store + .list_worker_workdir_links_page("local-dev", &worker.worker, 1, 1, None) + .unwrap(), + vec![second_attachment.clone()] + ); + assert!( + store + .list_worker_workdir_links_page("local-dev", &worker.worker, 1, 2, None) + .unwrap() + .is_empty() + ); + assert!( + store + .list_worker_workdir_links_page("foreign", &worker.worker, 1, 0, None) + .unwrap() + .is_empty() + ); + assert!( + store + .list_worker_workdir_links_page( + "local-dev", + &RuntimeWorkerRef::new("foreign", worker.worker.worker_id.clone()), + 1, + 0, + None + ) + .unwrap() + .is_empty() + ); + assert_eq!( + store + .list_worker_workdir_links_page( + "local-dev", + &worker.worker, + 1, + 0, + Some(&second_attachment.connection_id) + ) + .unwrap(), + vec![second_attachment.clone()], + "connection filtering must precede paging" + ); + for (workspace_id, identity, connection_id) in [ + ( + "foreign", + worker.worker.clone(), + second_attachment.connection_id.as_str(), + ), + ( + "local-dev", + second_worker.worker.clone(), + second_attachment.connection_id.as_str(), + ), + ( + "local-dev", + RuntimeWorkerRef::new("foreign", worker.worker.worker_id.clone()), + second_attachment.connection_id.as_str(), + ), + ("local-dev", worker.worker.clone(), old_connection.as_str()), + ("local-dev", worker.worker.clone(), "nonexistent"), + ] { + assert!( + store + .list_worker_workdir_links_page( + workspace_id, + &identity, + 1, + 0, + Some(connection_id) + ) + .unwrap() + .is_empty() + ); + } + drop(store); + let store = SqliteWorkspaceStore::open(&db).unwrap(); + assert_eq!( + store + .list_worker_workdir_links_page("local-dev", &worker.worker, 1, 0, None) + .unwrap(), + vec![replacement] + ); + assert!(matches!( + store.detach_worker_workdir_connection( + "local-dev", + &worker.worker, + "checkout", + &old_connection, + "6", + ), + Err(Error::WorkdirAttachmentConflict(_)) + )); let detached = store .detach_worker_workdir("local-dev", &worker.worker, Some(&workdir.workdir_id), "6") .unwrap() @@ -21001,10 +21632,7 @@ INSERT INTO worker_registry ( .worker_workdir_link_history_exists("local-dev", &worker.worker) .unwrap() ); - assert_eq!( - store.attach_worker_workdir(&workdir_conflict).unwrap(), - workdir_conflict - ); + let workdir_conflict = store.attach_worker_workdir(&workdir_conflict).unwrap(); drop(store); let reopened = SqliteWorkspaceStore::open(&db).unwrap(); diff --git a/crates/workspace-server/src/subjektiv.rs b/crates/workspace-server/src/subjektiv.rs index 1e9564954..7172f8736 100644 --- a/crates/workspace-server/src/subjektiv.rs +++ b/crates/workspace-server/src/subjektiv.rs @@ -11,7 +11,7 @@ use std::collections::HashSet; use chrono::{SecondsFormat, Utc}; use memory::extract::{CandidateKind, STAGING_SCHEMA_VERSION, StagingEvidence, StagingRecord}; use memory::schema::{SourceEvidenceRef, SourceRef}; -use rusqlite::{OptionalExtension, Transaction, params}; +use rusqlite::{Connection, OptionalExtension, Transaction, params}; use serde::{Deserialize, Deserializer, Serialize}; use uuid::Uuid; @@ -24,6 +24,8 @@ pub const SUBJEKTIV_SCHEMA_VERSION: u32 = 1; pub const SUBJEKTIV_FEATURE_ID: &str = "subjektiv"; pub const SUBJECT_WORKER_SINGLETON_PREFIX: &str = "subjektiv:"; pub const MAX_STAGING_ANCHORS: usize = 10; +/// Maximum UTF-8 size of the user-managed Subject behavior document. +pub const MAX_SUBJECT_BEHAVIOR_BYTES: usize = server_api::SUBJEKTIV_MAX_BEHAVIOR_BYTES; /// Deterministic surface-generation policy. Input estimates use the repository's /// provider-independent UTF-8 byte estimate (`ceil(bytes / 4)`). The material /// payload is capped below the whole-input budget to reserve room for the fixed @@ -74,6 +76,14 @@ pub enum SubjektivError { expected: u64, actual: u64, }, + #[error( + "subject `{subject_id}` behavior revision conflict: expected {expected}, current {actual}" + )] + SubjectBehaviorConflict { + subject_id: String, + expected: u64, + actual: u64, + }, #[error("surface generation conflict: {0}")] SurfaceGenerationConflict(String), #[error("memory `{memory_id}` cannot transition from {from:?} to {to:?}")] @@ -136,6 +146,14 @@ pub struct SubjectRecord { pub schema_version: u32, pub id: String, pub role: SubjectRole, + /// User-managed, host-injected behavior document. This is not Memory and + /// Memory lifecycle operations must preserve it byte-for-byte. + #[serde(default)] + pub behavior_md: String, + /// Monotonic CAS generation for behavior updates, independent of Memory's + /// `store_revision`. + #[serde(default)] + pub behavior_revision: u64, pub state: SubjectState, /// Monotonic generation incremented exactly once for every committed Memory /// creation or revision for this subject. @@ -500,6 +518,12 @@ pub struct ResidentSurface { pub snapshot: Option, } +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct SubjectResidentContext { + pub subject: SubjectRecord, + pub surface: ResidentSurface, +} + #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[serde(tag = "kind", rename_all = "snake_case")] pub enum MemoryRevisionTarget { @@ -986,6 +1010,17 @@ END; Ok(()) } +fn add_subject_behavior(transaction: &Transaction<'_>) -> crate::feature_storage::Result<()> { + transaction.execute_batch( + r#" +ALTER TABLE subjects ADD COLUMN behavior_md TEXT NOT NULL DEFAULT ''; +ALTER TABLE subjects ADD COLUMN behavior_revision INTEGER NOT NULL DEFAULT 0 + CHECK (behavior_revision >= 0); +"#, + )?; + Ok(()) +} + static MIGRATIONS: &[FeatureMigration] = &[ FeatureMigration::new(1, "create subjektiv subject memory store", create_schema), FeatureMigration::new( @@ -1003,6 +1038,7 @@ static MIGRATIONS: &[FeatureMigration] = &[ "add bounded surface generation state and runs", add_surface_generation_state, ), + FeatureMigration::new(5, "add user-managed subject behavior", add_subject_behavior), ]; pub const REGISTRATION: FeatureRegistration = @@ -1070,11 +1106,23 @@ impl SubjektivStore { /// Issues a random host-owned subject id. It is never derived from Worker, /// Profile, Session, or Memory content. pub fn create_subject(&self, role: SubjectRole) -> Result { + self.create_subject_with_behavior(role, String::new()) + } + + /// Creates a Subject with its optional user-managed behavior document. + pub fn create_subject_with_behavior( + &self, + role: SubjectRole, + behavior_md: String, + ) -> Result { + validate_subject_behavior(&behavior_md)?; let timestamp = now(); let record = SubjectRecord { schema_version: SUBJEKTIV_SCHEMA_VERSION, id: issued_id("subject"), role, + behavior_md, + behavior_revision: 0, state: SubjectState::Active, store_revision: 0, created_at: timestamp.clone(), @@ -1084,11 +1132,13 @@ impl SubjektivStore { self.database.try_transaction(|transaction| { transaction.execute( "INSERT INTO subjects ( - subject_id, role, state, store_revision, record_json, created_at, updated_at - ) VALUES (?1, ?2, 'active', 0, ?3, ?4, ?5)", + subject_id, role, behavior_md, behavior_revision, state, + store_revision, record_json, created_at, updated_at + ) VALUES (?1, ?2, ?3, 0, 'active', 0, ?4, ?5, ?6)", params![ record.id, record.role.as_str(), + record.behavior_md, raw, record.created_at, record.updated_at @@ -1111,6 +1161,51 @@ impl SubjektivStore { }) } + /// Replaces or clears the user-managed behavior document using its own CAS + /// generation. Memory revisions do not participate in this generation. + pub fn update_subject_behavior( + &self, + subject_id: &str, + expected_behavior_revision: u64, + behavior_md: String, + ) -> Result { + validate_subject_behavior(&behavior_md)?; + self.database.try_transaction(|transaction| { + let mut subject = require_subject(transaction, subject_id)?; + if subject.behavior_revision != expected_behavior_revision { + return Err(SubjektivError::SubjectBehaviorConflict { + subject_id: subject_id.to_string(), + expected: expected_behavior_revision, + actual: subject.behavior_revision, + }); + } + if subject.behavior_md == behavior_md { + return Ok(subject); + } + subject.behavior_md = behavior_md; + subject.behavior_revision = + subject.behavior_revision.checked_add(1).ok_or_else(|| { + SubjektivError::InvalidRecord("subject behavior revision overflow".to_string()) + })?; + subject.updated_at = now(); + let raw = serde_json::to_string(&subject)?; + transaction.execute( + "UPDATE subjects + SET behavior_md = ?2, behavior_revision = ?3, + record_json = ?4, updated_at = ?5 + WHERE subject_id = ?1", + params![ + subject.id, + subject.behavior_md, + to_i64(subject.behavior_revision)?, + raw, + subject.updated_at + ], + )?; + Ok(subject) + }) + } + /// Validates an explicit product launch connection against this Workspace's /// subject store and the already-resolved Profile policy. Subject knowledge /// alone is insufficient: only an active local record produces host launch material. @@ -2021,60 +2116,17 @@ impl SubjektivStore { pub fn resident_surface(&self, subject_id: &str) -> Result { self.database.try_with_connection(|connection| { let subject = require_subject_in_connection(connection, subject_id)?; - let state = connection - .query_row( - "SELECT store_revision, status, snapshot_id - FROM surface_generation_state WHERE subject_id = ?1", - [subject_id], - |row| { - Ok(( - row.get::<_, i64>(0)?, - row.get::<_, String>(1)?, - row.get::<_, Option>(2)?, - )) - }, - ) - .optional()?; - let Some((revision, status, snapshot_id)) = state else { - return Ok(ResidentSurface { - availability: SurfaceAvailability::Ungenerated, - snapshot: None, - }); - }; - let revision = u64::try_from(revision).map_err(|_| { - SubjektivError::InvalidRecord("surface state revision is negative".into()) - })?; - if revision != subject.store_revision || status == "dirty" { - return Ok(ResidentSurface { - availability: SurfaceAvailability::Stale, - snapshot: None, - }); - } - if status == "failed" { - return Ok(ResidentSurface { - availability: SurfaceAvailability::Failed, - snapshot: None, - }); - } - let snapshot_id = snapshot_id.ok_or_else(|| { - SubjektivError::InvalidRecord("ready surface state has no snapshot id".into()) - })?; - let raw = connection.query_row( - "SELECT snapshot_json FROM surface_snapshots - WHERE subject_id = ?1 AND snapshot_id = ?2", - params![subject_id, snapshot_id], - |row| row.get::<_, String>(0), - )?; - let snapshot = parse_surface_snapshot(&raw)?; - if snapshot.built_from_store_revision != subject.store_revision { - return Err(SubjektivError::InvalidRecord( - "ready surface snapshot generation does not match subject".into(), - )); - } - Ok(ResidentSurface { - availability: SurfaceAvailability::Ready, - snapshot: Some(snapshot), - }) + resident_surface_in_connection(connection, &subject) + }) + } + + /// Atomically reads user-managed behavior and the independently-fresh + /// generated Memory surface from one SQLite connection snapshot. + pub fn resident_context(&self, subject_id: &str) -> Result { + self.database.try_transaction(|transaction| { + let subject = require_subject(transaction, subject_id)?; + let surface = resident_surface_in_connection(transaction, &subject)?; + Ok(SubjectResidentContext { subject, surface }) }) } @@ -3414,6 +3466,87 @@ fn validate_range(kind: &str, range: [u64; 2]) -> Result<()> { } } +fn resident_surface_in_connection( + connection: &Connection, + subject: &SubjectRecord, +) -> Result { + let state = connection + .query_row( + "SELECT store_revision, status, snapshot_id + FROM surface_generation_state WHERE subject_id = ?1", + [&subject.id], + |row| { + Ok(( + row.get::<_, i64>(0)?, + row.get::<_, String>(1)?, + row.get::<_, Option>(2)?, + )) + }, + ) + .optional()?; + let Some((revision, status, snapshot_id)) = state else { + return Ok(ResidentSurface { + availability: SurfaceAvailability::Ungenerated, + snapshot: None, + }); + }; + let revision = u64::try_from(revision) + .map_err(|_| SubjektivError::InvalidRecord("surface state revision is negative".into()))?; + if revision != subject.store_revision || status == "dirty" { + return Ok(ResidentSurface { + availability: SurfaceAvailability::Stale, + snapshot: None, + }); + } + if status == "failed" { + return Ok(ResidentSurface { + availability: SurfaceAvailability::Failed, + snapshot: None, + }); + } + let snapshot_id = snapshot_id.ok_or_else(|| { + SubjektivError::InvalidRecord("ready surface state has no snapshot id".into()) + })?; + let raw = connection.query_row( + "SELECT snapshot_json FROM surface_snapshots + WHERE subject_id = ?1 AND snapshot_id = ?2", + params![subject.id, snapshot_id], + |row| row.get::<_, String>(0), + )?; + let snapshot = parse_surface_snapshot(&raw)?; + if snapshot.built_from_store_revision != subject.store_revision { + return Err(SubjektivError::InvalidRecord( + "ready surface snapshot generation does not match subject".into(), + )); + } + Ok(ResidentSurface { + availability: SurfaceAvailability::Ready, + snapshot: Some(snapshot), + }) +} + +pub fn validate_subject_behavior(value: &str) -> Result<()> { + if value.len() > MAX_SUBJECT_BEHAVIOR_BYTES { + return Err(SubjektivError::InvalidRecord(format!( + "subject behavior must be at most {MAX_SUBJECT_BEHAVIOR_BYTES} bytes" + ))); + } + if !value.is_empty() && value.trim().is_empty() { + return Err(SubjektivError::InvalidRecord( + "subject behavior must be empty or contain non-whitespace text".to_string(), + )); + } + if value + .chars() + .any(|character| character.is_control() && !matches!(character, '\n' | '\r' | '\t')) + { + return Err(SubjektivError::InvalidRecord( + "subject behavior contains an unsupported control character".to_string(), + )); + } + Ok(()) +} + fn validate_label(kind: &str, value: &str) -> Result<()> { validate_nonempty(kind, value)?; if value.len() > 256 || value.chars().any(char::is_control) { @@ -3668,6 +3801,67 @@ mod tests { assert!(!complete.has_more); } + #[test] + fn subject_behavior_is_bounded_revisioned_and_independent_from_memory_revision() { + let temp = tempfile::tempdir().unwrap(); + let (_manager, _workspace, store) = open_store(temp.path(), "workspace-a"); + let subject = store + .create_subject_with_behavior(role(), "Prefer explicit evidence.".to_string()) + .unwrap(); + assert_eq!(subject.behavior_revision, 0); + assert_eq!(subject.store_revision, 0); + + let updated = store + .update_subject_behavior( + &subject.id, + 0, + "Prefer explicit evidence.\nAsk when uncertain.".to_string(), + ) + .unwrap(); + assert_eq!(updated.behavior_revision, 1); + assert_eq!(updated.store_revision, 0); + + store + .create_memory( + &subject.id, + draft( + "A confirmed observation", + "exercise Memory lifecycle isolation", + ), + ) + .unwrap(); + let after_memory = store.subject(&subject.id).unwrap().unwrap(); + assert_eq!(after_memory.behavior_md, updated.behavior_md); + assert_eq!(after_memory.behavior_revision, 1); + assert_eq!(after_memory.store_revision, 1); + + let (_reopened_manager, _reopened_workspace, reopened) = + open_store(temp.path(), "workspace-a"); + let persisted = reopened.subject(&subject.id).unwrap().unwrap(); + assert_eq!(persisted.behavior_md, updated.behavior_md); + assert_eq!(persisted.behavior_revision, 1); + assert!(matches!( + store.update_subject_behavior(&subject.id, 0, String::new()), + Err(SubjektivError::SubjectBehaviorConflict { + expected: 0, + actual: 1, + .. + }) + )); + + let unchanged = store + .update_subject_behavior(&subject.id, 1, updated.behavior_md.clone()) + .unwrap(); + assert_eq!(unchanged.behavior_revision, 1); + let cleared = store + .update_subject_behavior(&subject.id, 1, String::new()) + .unwrap(); + assert_eq!(cleared.behavior_revision, 2); + assert!(cleared.behavior_md.is_empty()); + assert!(validate_subject_behavior(&"x".repeat(MAX_SUBJECT_BEHAVIOR_BYTES + 1)).is_err()); + assert!(validate_subject_behavior(" ").is_err()); + } + #[test] fn attributed_candidates_preserve_revision_and_surface_reference_continuity() { let temp = tempfile::tempdir().unwrap(); @@ -3892,10 +4086,38 @@ mod tests { )) .unwrap(); let store = SubjektivStore::open(&workspace, ®istration).unwrap(); - store.create_subject(role()).unwrap().id + let timestamp = now(); + let subject_id = issued_id("subject"); + let mut raw = serde_json::json!({ + "schema_version": SUBJEKTIV_SCHEMA_VERSION, + "id": subject_id, + "role": role(), + "state": SubjectState::Active, + "store_revision": 0, + "created_at": timestamp.clone(), + "updated_at": timestamp.clone(), + }); + let raw = serde_json::to_string(&raw.take()).unwrap(); + store + .database + .try_transaction(|transaction| -> Result<()> { + transaction.execute( + "INSERT INTO subjects ( + subject_id, role, state, store_revision, + record_json, created_at, updated_at + ) VALUES (?1, ?2, 'active', 0, ?3, ?4, ?5)", + params![subject_id, role().as_str(), raw, timestamp, timestamp], + )?; + Ok(()) + }) + .unwrap(); + subject_id }; let (_manager, _workspace, store) = open_store(&root, "workspace-a"); + let migrated_subject = store.subject(&subject_id).unwrap().unwrap(); + assert!(migrated_subject.behavior_md.is_empty()); + assert_eq!(migrated_subject.behavior_revision, 0); let lifecycle_attribution = attribution( &subject_id, "runtime-1", @@ -3935,26 +4157,50 @@ mod tests { )) .unwrap(); let store = SubjektivStore::open(&workspace, ®istration).unwrap(); - let subject = store.create_subject(role()).unwrap(); + let subject_id = issued_id("subject"); + let timestamp = now(); + let subject_json = serde_json::to_string(&serde_json::json!({ + "schema_version": SUBJEKTIV_SCHEMA_VERSION, + "id": subject_id, + "role": role(), + "state": SubjectState::Active, + "store_revision": 0, + "created_at": timestamp, + "updated_at": timestamp, + })) + .unwrap(); let snapshot = SurfaceSnapshot { schema_version: SUBJEKTIV_SCHEMA_VERSION, id: "legacy-surface".into(), - subject_id: subject.id.clone(), + subject_id: subject_id.clone(), body_md: "Legacy summary without generation-policy evidence.".into(), memory_refs: Vec::new(), - built_from_store_revision: subject.store_revision, + built_from_store_revision: 0, created_at: now(), }; store .database .try_transaction(|transaction| -> Result<()> { + transaction.execute( + "INSERT INTO subjects ( + subject_id, role, state, store_revision, + record_json, created_at, updated_at + ) VALUES (?1, ?2, 'active', 0, ?3, ?4, ?5)", + params![ + subject_id, + role().as_str(), + subject_json, + timestamp, + timestamp + ], + )?; transaction.execute( "INSERT INTO surface_snapshots ( subject_id, snapshot_id, built_from_store_revision, snapshot_json, created_at ) VALUES (?1, ?2, ?3, ?4, ?5)", params![ - subject.id, + subject_id, snapshot.id, to_i64(snapshot.built_from_store_revision)?, serde_json::to_string(&snapshot)?, @@ -3964,12 +4210,12 @@ mod tests { transaction.execute( "INSERT INTO surface_snapshot_seals (subject_id, snapshot_id) VALUES (?1, ?2)", - params![subject.id, snapshot.id], + params![subject_id, snapshot.id], )?; Ok(()) }) .unwrap(); - (subject.id, snapshot.id) + (subject_id, snapshot.id) }; let (_manager, _workspace, store) = open_store(&root, "workspace-a"); diff --git a/docs/README.md b/docs/README.md index c2dd751f9..29bd3ec29 100644 --- a/docs/README.md +++ b/docs/README.md @@ -35,7 +35,9 @@ 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/feature-chat-invocations.md`](design/feature-chat-invocations.md) — declarative `/` invocation identity, grammar, completion, typed draft, attachment staging, execution, and authority boundaries. +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. +32. [`design/feature-chat-invocations.md`](design/feature-chat-invocations.md) — declarative `/` invocation identity, grammar, completion, typed draft, attachment staging, execution, and authority boundaries. ## 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/workdir-wip-projection.md b/docs/design/workdir-wip-projection.md new file mode 100644 index 000000000..bace39405 --- /dev/null +++ b/docs/design/workdir-wip-projection.md @@ -0,0 +1,186 @@ +# Repository, Workdir and attachment WIP projection + +## Objects and operations + +The Host owns these root namespaces, using the same `WipMountRegistry` +namespace/resolver/contribution contracts as Tickets, Objectives and Merge Requests: + +```text +/ +├── repositories list +│ └── read +├── workdirs list, create +│ └── read, attach, delete (when supported) +└── workdir-attachments list + └── read, detach +``` + +Operations are Interface declarations, **not child files**. There is no +`/features/manage-workdir`, no writable Repository catalog and no Repository +content tree here. `/checkouts` content projection is a separate provider (T-699), +as is Workspace configuration (T-695). Management Objects do not grant access to +unattached contents. A future content provider should return its entrance from +an authorized, live attachment; it must not infer content/command capability +from catalog or lifecycle access. + +`path` in inventory results is an absolute WIP Object path. It is a reference, +not an OS path, a capability grant, or an instruction to reinterpret another +string argument as a route. Source Repository references use `repository_path`; +attachment references use `workdir_path`. Attach results include +`attachments_path` to discover the current connection and its lifetime ID. + +### Identity and route encoding + +Repository identity is the formal Backend `repository_key`, never its display +name or a guessed `main`. Workdir identity is `working_directory_id`, never a +Worker-local alias. A segment containing only ASCII letters, digits, `-`, `_` +is unchanged. All other UTF-8 identities are encoded as `~` followed by lowercase +hexadecimal UTF-8 bytes. The `~` marker is itself always encoded. Only the +canonical encoding is accepted; no alternate spellings, recursive URL decoding, +case folding, slash traversal or namespace fallback are accepted. For example, +`repo/name` is `/repositories/~7265706f2f6e616d65`. This is injective and accepts +existing valid keys, including Unicode and slash-containing identities. + +Attachment identity is a fresh opaque Backend connection ID for each lifetime, +including detach/reattach to the **same** alias and Workdir. Read exposes alias, +Workdir ID and effective attachment capabilities as attributes. Workdir existence +and Worker connection are separate Objects. Detaching a connection does not +remove the Workdir or delete a logical settings space. + +## Feature and authority separation + +`feature.workdir_catalog.enabled` independently supplies read-only Repository, +Workdir and attachment reference operations. It defaults enabled so a WIP Worker +with Workspace authority and appropriate permissions can find registered keys +without Ticket or conversation hints. It does not add ordinary Tools or require +a filesystem/Bash location. Standalone Workers without Workspace authority have +no Workspace catalog mounted. + +`feature.manage_workdir.enabled` supplies management operation implementations +on the **same Objects** using `contribute_operations` and +`contribute_dynamic_operations`. It remains default disabled. It neither owns +another resolver nor confers permission. The object provider is unique; duplicate +namespace, route, operation name or inconsistent Interface contributions fail at +registration through the common registry. + +Native permission identities are: + +| Operation | Manifest permission identity | Bound identity in permission input | +| --- | --- | --- | +| repositories.list | RepositoryList | list arguments | +| repository.read | RepositoryRead | repository_key | +| workdirs.list | WorkdirList | list arguments | +| workdir.read | WorkdirRead | working_directory_id | +| workdirs.create | WorkdirCreate | original create arguments | +| workdir.attach | WorkdirAttach | working_directory_id + alias | +| workdir.delete | WorkdirDelete | working_directory_id + reason | +| attachments.list | WorkdirAttachmentList | list arguments | +| attachment.read | WorkdirAttachmentRead | connection_id | +| attachment.detach | WorkdirDetach | current bound alias | + +Read permissions gate direct item resolution as well as list item visibility. +The manifest's existing default policy is retained; `ask` is denied fail-closed. +An argument-dependent policy may advertise an operation that could be allowed, +but every call checks its exact reconstructed input before execution. Mutation +arguments cannot override path-bound Workdir or connection identity. Having +RepositoryRead is not WorkdirCreate; inventory/management permissions are not +file access, delegation or command execution grants. Backend caller/Workspace +identity, source proof, attachment capability ceilings and lifecycle policy are +checked by the existing paths on every request. + +## Contextual Interfaces and stale observations + +Providers opt into common Host contextual Interfaces. Operation publication is +the intersection of registered implementation, current target support and current +subject policy. The collection reference feature can remain available while the +management feature is disabled. External Workdirs without cleanup authority do +not expose delete. Only currently supported lifecycle operations are advertised; +no delete/command is added to logical attachments solely because they appear as +workspaces. + +A contextual Interface reference is opaque and path-qualified as +`/@/`. Clients must use the returned reference, not +construct it. Fetch and call freshly resolve the same Object and filtered +Interface; base references cannot fetch an unfiltered descriptor. Descriptor and +Object validators invalidate old observations after target/connection/permission +changes. Validators are managed by the WIP Client, not manually by the LLM. +Cached Interface possession and known direct paths never bypass current checks. +Historic session text remains append-only and is not rewritten or replayed. + +## Backend lifecycle and safe output + +Catalogs are request-time projections of existing Backend authority, not another +store or synchronization job. WIP list pages default to 50 and accept 1–100 +items. Repository pages sort the existing complete Repository catalog by stable +key. Workdir pages use the signed caller-bound Backend +`workers/self/workdir-catalog` operation, with SQL keyset paging over the existing +Workdir registry in stable ID order. This is not the legacy bounded +`working-directories` browser/Tool snapshot: Workdirs older than its 200-record +cap remain reachable. `next_cursor` advances over scanned rows even when the +current read policy or unsupported legacy source makes a page sparse or empty; +`empty` describes that page, and `has_more` remains authoritative for progress. +Attachment pages use the Backend's SQL-bounded, alias-ordered limit/offset query +and opaque `offset:` cursors. Both Workdir and attachment responses supply an +opaque complete-set `revision` derived from existing authoritative records in +the same database snapshot as the page, not by hashing the first page. The Host +uses this complete-set revision for the collection Object validator. A lifetime +or capability change beyond row 50 therefore invalidates old collection +observations without adding a ledger or revision store. A cursor is not +a snapshot or authority grant: restart listing after concurrent collection +changes. Filtering by exact `connection_id` is applied in the caller-scoped +Backend ledger before paging, so direct item lookup also works beyond the first +page. Unknown/expired IDs yield no item, never a current alias substitute. +Repository projection uses an explicit field +allowlist: formal key, kind/provider, any existing display metadata and explicit +nullable default selector. Source URI/path, credentials, remotes, Runtime +connection information and internal fingerprints are not model-visible. +Workdir projection includes stable ID, optional display name, public source kind +and Repository key, existing selector/ref/tree, status, cleanliness and occupancy +when available. Cleanup handles and External grant internals are not forwarded. +No missing attributes or implicit selectors are fabricated. Backend errors are +sanitized rather than forwarding raw diagnostic bodies with possible host paths. + +Create and attach reuse the ordinary Workdir backend, request identity, +Worker-session router, mutation lock and Internal SubWorker lifecycle hooks. +Creation does **not** attach. Attach installs the Backend-attenuated session +capabilities, never a fabricated `ALL` capability for External Workdirs. Detach +uses the same begin/finish/cancel-detach path and command/operation exclusion as +Tools; the Backend checks expected connection identity under its existing +current-Worker lifecycle lock before closing sessions, then conditionally +updates that exact row in the existing connection ledger. Alias reuse cannot +retarget an old connection operation. Compensation retains the original +connection identity. Existing removal dispositions `removed`, `retained`, and +`attention_required`, plus retryability, remain intact. + +Stale identity/validator rejection is distinct from unknown operation outcome. +Transport failure, invalid post-dispatch response or uncertain lifecycle failure +is **not** proof that no effect occurred; it must not be automatically retried. +Inspect current state or request operator guidance. No new retry or attachment +ledger is introduced by WIP. + +When native management is mounted, `WorkdirList/Create/Attach/Detach/Delete` are +claimed as represented native capabilities, so `/tools/Workdir*` is not also +published. Ordinary Tools mode keeps its names and inputs. Existing native +Ticket/Objective/Merge Request routes retain their T-698 contracts. + +## First-Workspace example + +1. `WipDiscover(path="/", depth=1)` finds `repositories`, `workdirs` and + `workdir-attachments` when permitted. +2. Inspect the returned Repository collection Interface and call `list` with a + bounded limit. An empty registered/visible catalog explicitly returns an empty + list; do not invent a key. Follow `next_cursor` when supplied. +3. Discover the returned item `path`, inspect its Interface and call `read`. + `default_selector: null` means no configured default, not `main`. +4. Inspect the Workdir collection and `create` using the discovered formal key, + with optional selector, display name and Runtime under the existing contract. +5. Follow created `item.path`, discover/inspect and `attach` with a chosen + Worker-local alias. This is separate from creation. +6. Follow `attachments_path`, `list`, then discover/read the returned connection + path to confirm alias, Workdir and effective capabilities. +7. Call that connection's `detach` with no identity override. The observed + lifetime ID guards alias reuse; the Workdir remains in its inventory. + +Each step uses current WIP observations and no Ticket-derived key, host path, +Runtime endpoint or manual validator. Explicitly refresh/reset discovery after +external changes and before reconstructing a rejected stale call. diff --git a/docs/design/worker-wip-mode.md b/docs/design/worker-wip-mode.md index 012a5d768..f77be0daf 100644 --- a/docs/design/worker-wip-mode.md +++ b/docs/design/worker-wip-mode.md @@ -52,21 +52,47 @@ 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[/]` | + +The built-in Repository/Workdir reference provider allocates `/repositories`, `/workdirs`, and `/workdir-attachments` using this contract; `manage-workdir` contributes operations rather than owning another Object. See [Repository, Workdir and attachment WIP projection](workdir-wip-projection.md) for identity encoding, reference/management permissions, attachment lifetime safety, and first-Workspace discovery. `/checkouts` content projection remains a separate provider. + +### Registering a subsequent provider + +1. Allocate `allocate_namespace("repository", "repositories")`. The returned `WipNamespaceRoute::root()` is `/repositories`; the owner is independent of any Feature registration name. +2. Mount a native collection `WipProjection` at that root with capability `repository:collection`, matching Object name and one declared interface. Static Objects underneath the root use the same capability owner prefix. A mounted collection retains its own identity, interfaces, and validator even when it has static children. +3. For dynamic items, register one `WipDynamicMount` on the collection with capability `repository:item`. A foreign capability owner is rejected. Its `WipDynamicItemResolver` owns reference acceptance, Object identity, and current Object validator. It returns only a bounded direct child; it must not reinterpret a reference as another namespace or grant permission from existence. +4. The reference provider supplies its read operations and handlers independently of management Feature enablement. An Object-only provider may use an empty operation descriptor. Enabled management Features register `WipOperationContribution` for static Objects or `WipDynamicOperationContribution` for a dynamic family; they do not allocate the namespace again or replace its resolver. +5. Contributions use the existing interface reference and identical format, documentation, and type declarations, supplying only their new operations. Interface references are global: extending a descriptor that is also mounted elsewhere must not create different descriptors/validators under the same reference (registration fails). Use separate references for collection and item contracts. Failed contributions leave the registry unchanged. +6. Handlers are responsible for checking current target capability and current subject authorization before dispatch. Registration/discovery is metadata, not a grant. A resolver/handler must retain the original permission identity and domain audit/failure contracts. Revocation after discovery must fail before provider dispatch. Compatibility mounts are restricted to `/tools/` and cannot bypass namespace ownership or republish legacy routes. + +Tests `management_enablement_is_independent_of_read_and_operation_permissions` and `contributed_interface_invalidates_observations_and_restore_requires_discovery` cover all eight enablement/read/manage combinations, direct-call rejection, effective descriptor changes, fresh runtime isolation under the same Worker identity, reset, and post-observation revocation. ## 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 +111,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: @@ -96,6 +124,8 @@ The adapter keeps these outcomes distinct: - **disconnected/timeout after dispatch**: `wip-client` records an unknown outcome; - **operation outcome unknown**: an executing provider fails without a rollback/non-execution guarantee, a response cannot be completed after dispatch, or the call future is force-closed. +Cooperative cancellation is scoped to the active execution's selected operation and exact resolved handler instance. Static/dynamic dispatchers forward `cancel_operation` only to that operation's provider, once per cancellation request, never to unrelated contributors or every alias backed by a shared handler. The runtime pins the dispatcher used for execution rather than resolving the Object again on cancellation, since dynamic resolvers may return stateful instances. Providers may implement `cancel_operation` for operation-specific control or the shared `cancel` default. Terminal completion or a dropped execution future releases the active binding; unknown/finished execution IDs do not invoke any provider. A cancellation request is not itself terminal confirmation. + An unknown mutating outcome must never be retried automatically. The model must inspect external state or ask for operator guidance. Retrieval failures may be explicitly refreshed. ## Measurement diff --git a/docs/development/ui-ux/subject-memory-information.md b/docs/development/ui-ux/subject-memory-information.md new file mode 100644 index 000000000..2a2ff687c --- /dev/null +++ b/docs/development/ui-ux/subject-memory-information.md @@ -0,0 +1,133 @@ +# Subject / Memory information audit (T-692) + +## Decision and scope + +The previous Subjects index exposed `Store revision` alongside identity, state and +update time; Subject detail led with technical metadata and raw surface states. +These numbers require implementation knowledge before they help a user choose a +Subject or understand what is available. The revised hierarchy starts with +identity, connection, committed records and generated-context availability. + +This is a presentation change, not a change to storage, freshness comparisons, +permissions, generation, or Worker history. T-688's ordinary Worker launch and +T-689's independent Subject creation remain the actual operation paths. T-693 +owns editable user-managed behavior: that content must remain distinct from this +page's context generated from committed Memories. A successful read or a current +surface does not attest what is already in any running Worker's history. + +## Field classification and before/after priorities + +| Information | Classification and reason | Presentation decision | +| --- | --- | --- | +| Subject role and ID | Normal identification. Similar or identical roles need a stable identifier for selection and comparison. | Keep role as heading and the complete wrapping ID visible in index and detail. | +| Subject active/retired state | Normal operation decision: whether a new Worker connection can use the Subject. | Replace raw `active` with `Available`; keep `Retired`. Detail explains connection eligibility, not an assertion that a Worker is running. | +| Current Worker | Normal connection decision, derived from the existing Backend singleton projection. | Promote `Connected` / `Not connected` and display name. Do not infer ownership from store revision or claim all Workers received current Memory. Worker start remains the ordinary launch form. | +| Committed-Memory presence | Normal reading decision, independent from surface generation or candidates. | Show `Available` / `None` only on the successful first bounded page. On later pages say `N on this page`; errors say `Unavailable`. Never infer counts from revision or scan all pages. | +| List counts | Normal navigation context, not a store-wide statistic. | Label `N shown` and `more available`, preserving existing bounded cursor pagination. | +| Resident availability | Normal context decision. A missing surface is not missing committed Memory. | `Ready to use`, `Ready, no context`, `Not generated`, `Needs refresh`, `Generation failed`, and request `Unavailable` are distinct. These describe the current generated store artifact, not content truth or Worker-history propagation. | +| Resident body and generated time | Normal content/read freshness context. | Keep the shared Markdown renderer, generated date and exact snapshot reference count. Ready-empty explains that a valid snapshot has no body; stale/failed do not display a partial or old snapshot. | +| Subject store revision | Diagnostic only: internal committed-Memory change number, not record count, schema version or quality score. | Remove it from the index's normal columns. Retain the exact value under detail's `Technical details`, with its meaning explained. Backend comparisons are untouched. | +| Subject update time | Secondary metadata; not a context-injection or generation completion timestamp. | Label `Last changed`; on detail put it with Subject diagnostics rather than competing with the availability summary. | +| Snapshot ID and built-from revision | Diagnostic correlation, distinct from per-Memory revisions. | Retain under `Surface sources and diagnostics`, labelled `Generated from Subject revision` and explained. | +| Surface Memory references | Detailed audit/reachability. | Preserve reference IDs, immutable revision values and Memory links in the disclosure. | +| Memory claim, kind, lifecycle state, excerpt and body | Normal content decision. Active, resolved and retracted are not interchangeable. | Retain list scanning and body reading. Keep the Markdown renderer and current/historical distinction. | +| Individual Memory revision and history | Detailed content comparison, also useful for identifying a selected version. | Explicit `Memory revision` labels, separate from Subject counters. Preserve immutable history, selected revision links and revision pagination. | +| Candidate provenance, evidence, origin, source refs and derivations | Detailed audit, required to understand why a record exists. | Retain IDs, ranges, summaries, excerpts, origins and derived-revision links. Counts say `shown` except where the API provides exact per-candidate totals. Preserve body and evidence continuation links. | +| Raw field names / duplicated general revision terminology | Unnecessary in the normal hierarchy; multiple unrelated counters are misleading. | Replace bare revision labels and raw surface enums with purpose-specific labels. Do not delete API fields, stored information, diagnostics or their detailed access. | + +No diagnostic datum was removed from Subject detail, surface detail or Memory +provenance/history. The index no longer shows a store revision, but the exact +value remains one click away in Subject detail. No invented regeneration, +editing or recovery action was added; `Needs refresh` does not mean an update job +is running. Creation still creates only a Subject, and selection still uses the +existing ordinary launch contract. + +## Candidate-only verification + +Candidates are not committed records and are not an input to this page. The +Subject loader performs just three bounded reads: Subject, resident surface and +`/memories?limit=100[&cursor=...]`. It does not read or count staging records. + +The existing real-store regression +`subjektiv::tests::candidate_decisions_reject_conflicts_and_roll_back_partial_writes` +stages a candidate and attempts an invalid apply. It asserts the candidate has no +resolution and the committed Memory list remains empty. The component regression +`candidate-only Subjects show no committed Memories, not an inferred candidate count` +uses that read projection (store revision 0, ungenerated surface, empty successful +committed list) and checks `None`, `No committed Memories yet.`, `Not connected` +and `Not generated`, with no resident body. Thus a staged candidate does not turn +into a committed-Memory count or a claim that context has been generated. + +The store test proves the persistence boundary; the component test proves the +rendering of its public projection. These are separate boundary tests, **not** a +live browser-to-Backend candidate-creation E2E. The existing `ungenerated` capture +represents the same visible projection, but does not itself stage a candidate. + +## Visual review and validation evidence + +The restored implementation is in `fbb09e65` and `4cf6c130`; this report/test +supplement does not change rendered markup, styling, copy or data contracts. + +- Scenarios: `tools/web-ux/scenarios/memory-subjects-{light,dark}.json`. +- Before: `target/web-ux/t-692-before-{light,dark}` at `cc979874`. +- After: `target/web-ux/t-692-final2-{light,dark}` at clean `4cf6c130`. +- Comparisons: `target/web-ux/t-692-final2-{light,dark}-comparison`. +- Browser interactions: `target/web-ux/t-692-browser-interactions`. +- Persona: synthetic Workspace fixture; permitted creation and intentional + permission-rejection responses. No real account or private store data used. +- Viewports: 1440, 768, 390 and 320px; light and dark themes. +- States: connected/unconnected, active/retired, successful empty lists, ready + long Markdown, ready-empty, ungenerated, stale, failed, unavailable HTTP503, + create form and create permission error; expanded technical disclosures. +- The takeover implementer personally inspected individual before/after images + for index at 768px, representative detail at desktop/mobile and Memory detail, + then ready-empty mobile, ungenerated narrow, stale desktop, failed mobile, + endpoint error at 320px, mobile surface sources, compact Subject diagnostics + and mobile permission error. Oversized contact sheets were rejected by the + image provider; individual viewport PNGs were inspected instead. +- Findings/result: primary state groups replace counters without losing IDs or + document access; long IDs wrap, status is textual rather than color-only, + mobile groups stack, diagnostics remain readable, and error state regions stay + separate. No new visual defect identified. **Pass for the audited scope.** +- Final bundles contain 64 captures each. `completed-with-errors` is expected: + intentional creation HTTP403, endpoint HTTP503/associated aborted requests, + and the deliberately failed surface's alert are retained as evidence. Healthy + capture states have empty error arrays; this is not a blanket claim that all + captures were error-free. Review-context diagnostics are empty. +- The focused production-shell browser test proves main-owned vertical scrolling + with wheel/keyboard/touch, 320px/mobile shell behavior, no page-wide overflow, + local table/code scroll, Markdown safety, creation/launch separation, bounded + pagination and Memory/source/revision reachability. + +Independent review at source `4cf6c130` ran root `cargo check`, +`cargo fmt --all -- --check`, diff hygiene, Web check (0 errors/warnings), 12 +Memory API tests, 452 full Web unit tests, 134 component tests (8 Memory tests), +production build, the focused production-shell browser test (1), and Web-UX unit +tests (16), all passing. + +The takeover implementer then validated this report/test supplement with: + +- `cargo test -p yoi-workspace-server --lib candidate_decisions_reject_conflicts_and_roll_back_partial_writes`: 1 passed. +- Root `cargo check`, `cargo fmt --all -- --check`, and `git diff --check HEAD`: passed. +- Targeted Memory component tests: 9 passed, including the added candidate-only projection test. +- Web `deno task check`: 0 errors/warnings; `deno task test`: 452 passed; + `deno task test:component`: 135 passed. +- Scoped `deno fmt --check` on the changed TypeScript test: passed. +- JSON audit of both final2 bundles: 64 captures each, exact clean source + `4cf6c130`, and no errors outside the deliberate permission/error/failed routes. + +The supplement changes only a document and a test, so it does not require new +rendered-UI captures. Rereview records and the final approved Ticket handoff link +this report to the new exact source. + +Known non-blocking validation diagnostics: component runs emitted incidental +localhost:3000 connection-refused messages while passing; the production build +warned about a large chunk. A broader Web-UX typecheck fails in unchanged +`tools/web-ux/src/processes.ts:44` on a `TextDecoderStream` generic incompatibility; +this report does not claim that broad check passed. + +No live-Backend browser interactions, real Worker model-request propagation, +regeneration, or server/runtime process E2E were performed. No Rust production +crate changed in T-692; the supplemental store test exercises an existing +contract without changing that contract. Backend counters, comparison logic, +storage schema and feature behavior remain intact. diff --git a/docs/report/2026-10-05-review-severity-tool-contract.md b/docs/report/2026-10-05-review-severity-tool-contract.md new file mode 100644 index 000000000..69d1a9c64 --- /dev/null +++ b/docs/report/2026-10-05-review-severity-tool-contract.md @@ -0,0 +1,21 @@ +# Review finding severity contract mismatch during T-697 + +The independent Reviewer completed source/requirement analysis and validation, +then its one `ReviewMergeRequest` submission was rejected with HTTP 422 because +it used severity `medium`. The tool exposed a free-form string, while Backend +validation accepts only `blocker`, `major`, `minor`, or `note`. No verdict was +recorded by that failed call. The child preserved the source and reported the +blocked submission rather than loop-retrying a mutation. + +The parent inspected the committed review session and fresh MR authority, +confirmed the same request/source/Ticket snapshot with no verdict, and submitted +a fresh child turn to correct the input contract. The Reviewer independently +selected supported severities and recorded `request_changes` successfully on +the same MR. No alternate token/context, CLI/storage write, source movement or +replacement MR was used to compensate for the rejection. + +Suggested improvement: expose finding severity as the typed Backend enum in the +LLM tool schema, and document allowed values in the Reviewer prompt. Rejecting +an unadvertised enum after a costly review should not prevent durable findings +from reaching the MR. This report is tool feedback, not a T-697 product +constraint or a request to alter review judgments. diff --git a/docs/report/2026-10-05-review-verdict-schema-rejection.md b/docs/report/2026-10-05-review-verdict-schema-rejection.md new file mode 100644 index 000000000..7d5e9ce2e --- /dev/null +++ b/docs/report/2026-10-05-review-verdict-schema-rejection.md @@ -0,0 +1,30 @@ +# Reviewer verdict のvalidation rejectionとone-call制約 + +## 確認した事実 + +T-703のMR `01a10b3b-87ec-7093-b0c4-a00a78095695`、source +`3174142bd1314fe34c5de7b6a66654ff1c2a8af2` の独立ReviewerはRestoreとno-op +attention checkの競合を見つけ、`ReviewMergeRequest`でrequest_changesを提出しようとした。 +severityに`medium`を指定したためHTTP 422となった。Backendの許容値は +`blocker` / `major` / `minor` / `note` だった。 + +親の`ShowMergeRequest`再読でthreadはReviewRequested seq2までであり、verdictが +永続化されていないことを確認した。Reviewerは「ReviewMergeRequestを正確に一度だけ +呼ぶ」という指示に従い、validation rejection後も再提出を行わなかった。親のfollow-upでも +同じ境界を報告したため、再提出を強制せずReviewerを停止した。 + +## 障壁 + +正しいBackend拒否を迂回すべきではない一方、mutationが行われていない入力validation失敗が +一回限りの提出枠を消費する扱いだと、レビュー済みの指摘がMR authorityに残らずpendingになる。 +セッションでの報告をverdictや承認の代わりにはできない。 + +## 改善案(このTicketの実装対象ではない) + +- Reviewer-facing tool schemaにseverityのBackend enumを明示し、不正値を入力時に防ぐ。 +- one-call指示について「一度のdurable verdict」と「validationで拒否された呼出」を区別できるか + 設計上判断する。無制限再提出や記録済みverdictの変更を認める提案ではない。 +- mutationなし/retry可否をtyped errorとして返せると、親とReviewerの双方が推測なしに扱える。 + +T-703では元のMR/source selectorを保持し、指摘を修正したcommitを通常pushした後、fresh +exact-source reviewで修正内容とその証拠をMRに記録する。未記録のverdictを承認扱いしない。 diff --git a/docs/report/2026-10-05-reviewer-subworker-stopped-before-run.md b/docs/report/2026-10-05-reviewer-subworker-stopped-before-run.md new file mode 100644 index 000000000..38f6fd395 --- /dev/null +++ b/docs/report/2026-10-05-reviewer-subworker-stopped-before-run.md @@ -0,0 +1,18 @@ +# Reviewer SubWorkerがreview開始前に停止する + +## 状況 + +T-698の公開済みMerge Requestに対して、`builtin:reviewer`を指定し、対象Ticket/Merge Request、writable delegated scope、command grantを明示してReviewer SubWorkerを3回起動した。 + +いずれも`review_requested` eventは正しいsource refとTicket revisionを記録したが、SubWorkerは直後に`Stopped`となった。session observationには最初のuser taskだけが存在し、assistant output、tool call、typed review verdictは記録されなかった。Merge Request threadにもapprove/request_changesは追加されなかった。 + +## 障壁 + +CoderはReviewer approvalを代行できず、親Workerには`ReviewMergeRequest` authorityがないため、実装・公開・検証が完了していてもfresh approvalを取得できない。同じ入力を再送するだけでは診断情報が増えず、review requestだけが複数残る。 + +## 改善案 + +- Reviewer起動がmodel run前に停止した場合、停止理由を親へ構造化して通知する。 +- `review_requested`を記録した後にReviewer sessionの開始に失敗した場合、request eventと失敗eventを関連付け、未解決reviewとして明示する。 +- profile/provider初期化、review worktree準備、capability bindingのどの段階で失敗したかを、credentialやhost pathを露出しないbounded diagnosticとして提供する。 +- 同一sourceへの再試行時は、先行Reviewerが一度も実行されていないことを識別できるようにする。 diff --git a/docs/report/2026-10-05-subworker-cwd-attachment-root.md b/docs/report/2026-10-05-subworker-cwd-attachment-root.md new file mode 100644 index 000000000..61ad4f90b --- /dev/null +++ b/docs/report/2026-10-05-subworker-cwd-attachment-root.md @@ -0,0 +1,33 @@ +# SubWorker cwd attachment-root ambiguity during T-697 + +## Observed barrier + +A delegated backend investigation was spawned with `cwd=crates/workspace-server` +and explicit writable rules for both `crates/workspace-server` and +`crates/server-api`, plus repository-root read access. The child reported that +its available attachment exposed only the workspace-server crate; its observed +file reads used `src/server.rs`/`src/lib.rs` and it could not access server-api. +It also had no `ShowTicket` or parent-notification tool, despite the parent +having typed Ticket authority. No implementation changes were made by that child. + +The parent inspected the committed child session, released the idle child, and +respawned at the repository root with the same delegated write boundaries and +an explicit summary of the authoritative Ticket requirements. That child could +then implement the cross-crate API boundary. No filesystem capability rejection +was worked around through another write path. + +## Suggested improvement + +The advertised SubWorker `cwd` contract says that it changes only the tool +working directory, not attachment roots. Show the effective attachment aliases, +logical roots and readable/writable scopes explicitly to the child and parent, +and test cross-crate reads/writes for a non-root cwd. If cwd intentionally +narrows or rebases an attachment, document that instead of presenting it as +only a command default. A denied/inaccessible sibling diagnostic should identify +the effective logical attachment root without disclosing unauthorized paths. + +Also expose which typed domain and parent-messaging tools will be available +before spawning. Delegated implementation requires authoritative requirements; +where Ticket reads are unavailable, the parent must deliberately provide the +bounded contract rather than assume the child can recover it by ID. This report +records tool ergonomics only; it is not a new T-697 product requirement. diff --git a/openapi/server-api.json b/openapi/server-api.json index 820743bcf..63ffb52df 100644 --- a/openapi/server-api.json +++ b/openapi/server-api.json @@ -9807,9 +9807,32 @@ ], "type": "object" }, + "SubjektivSubjectBehaviorUpdateRequest": { + "additionalProperties": false, + "properties": { + "behavior_md": { + "type": "string" + }, + "expected_behavior_revision": { + "format": "uint64", + "maximum": 9007199254740991, + "minimum": 0, + "type": "integer" + } + }, + "required": [ + "expected_behavior_revision", + "behavior_md" + ], + "type": "object" + }, "SubjektivSubjectCreateRequest": { "additionalProperties": false, "properties": { + "behavior_md": { + "default": "", + "type": "string" + }, "role": { "type": "string" } @@ -9875,6 +9898,15 @@ "SubjektivSubjectResponse": { "additionalProperties": false, "properties": { + "behavior_md": { + "type": "string" + }, + "behavior_revision": { + "format": "uint64", + "maximum": 9007199254740991, + "minimum": 0, + "type": "integer" + }, "created_at": { "type": "string" }, @@ -9911,6 +9943,8 @@ "required": [ "id", "role", + "behavior_md", + "behavior_revision", "state", "store_revision", "created_at", @@ -15286,7 +15320,7 @@ "info": { "title": "Yoi Server API", "version": "0.1.0", - "x-yoi-source-digest": "sha256:eddff23c6405c11e8becfef09473d6833647bcaba9243abae189db7f840c1896" + "x-yoi-source-digest": "sha256:47064776569f7f208533f24d56ac80c0db6055d508ab29fb0a780c2c66387081" }, "openapi": "3.1.0", "paths": { @@ -29623,6 +29657,123 @@ ] } }, + "/api/w/{workspace_id}/subjektiv/subjects/{subject_id}/behavior": { + "patch": { + "operationId": "subjektiv_subject_behavior_update", + "parameters": [ + { + "explode": false, + "in": "path", + "name": "workspace_id", + "required": true, + "schema": { + "$ref": "#/components/schemas/string" + }, + "style": "simple" + }, + { + "explode": false, + "in": "path", + "name": "subject_id", + "required": true, + "schema": { + "$ref": "#/components/schemas/string" + }, + "style": "simple" + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SubjektivSubjectBehaviorUpdateRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SubjektivSubjectResponse" + } + } + }, + "description": "Successful response" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RepositoryApiError" + } + } + }, + "description": "Error response" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RepositoryApiError" + } + } + }, + "description": "Error response" + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RepositoryApiError" + } + } + }, + "description": "Error response" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RepositoryApiError" + } + } + }, + "description": "Error response" + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RepositoryApiError" + } + } + }, + "description": "Error response" + }, + "500": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RepositoryApiError" + } + } + }, + "description": "Error response" + } + }, + "security": [ + { + "bearerAuth": [] + }, + { + "browserSession": [] + } + ] + } + }, "/api/w/{workspace_id}/subjektiv/subjects/{subject_id}/memories": { "get": { "operationId": "subjektiv_memory_list", diff --git a/resources/prompts/catalog.dcdl b/resources/prompts/catalog.dcdl index d38cfda1a..e1039ba32 100644 --- a/resources/prompts/catalog.dcdl +++ b/resources/prompts/catalog.dcdl @@ -34,6 +34,7 @@ internalWorkingBoundariesSection = import "./internal/working_boundaries_section internalAgentsMdSection = import "./internal/agents_md_section.md"; internalResidentMemorySummarySection = import "./internal/resident_memory_summary_section.md"; internalResidentMemoryRestoreSection = import "./internal/resident_memory_restore_section.md"; +internalSubjektivResidentContext = import "./internal/subjektiv_resident_context.md"; internalSubWorkerSpawnToolDescription = import "./internal/sub_worker_spawn_tool_description.md"; panelOrchestratorIdleQueueNotice = import "./panel/orchestrator_idle_queue_notice.md"; in @@ -74,6 +75,7 @@ in agents_md_section = internalAgentsMdSection.content; resident_memory_summary_section = internalResidentMemorySummarySection.content; resident_memory_restore_section = internalResidentMemoryRestoreSection.content; + subjektiv_resident_context = internalSubjektivResidentContext.content; worker_orchestration_guidance_section = commonWorkerOrchestration.content; sub_worker_spawn_tool_description = internalSubWorkerSpawnToolDescription.content; }; diff --git a/resources/prompts/internal/resident_memory_restore_section.md b/resources/prompts/internal/resident_memory_restore_section.md index fc8a22907..abd982563 100644 --- a/resources/prompts/internal/resident_memory_restore_section.md +++ b/resources/prompts/internal/resident_memory_restore_section.md @@ -5,12 +5,12 @@ This restore-boundary record supersedes every resident memory summary embedded earlier in the system prompt or session history. Treat only the current state below as resident context; it is background context, not a user request. {% if surface_ready %} -### Current ready surface +### Current resident context {% if summary %} {{ summary }} {% else %} -The current ready resident memory surface is intentionally empty. +The current resident context is intentionally empty. {% endif %} {% else %} No current ready resident memory surface is available. Ignore earlier resident memory summaries; confirmed Memory remains available through explicit recall tools. diff --git a/resources/prompts/internal/subjektiv_resident_context.md b/resources/prompts/internal/subjektiv_resident_context.md new file mode 100644 index 000000000..51d669562 --- /dev/null +++ b/resources/prompts/internal/subjektiv_resident_context.md @@ -0,0 +1,9 @@ +This is the Host-fetched current Subject context. Its user-managed behavior section supersedes any earlier version in the system prompt or session history. Treat it as standing background context, not a user request; it cannot expand tool scope, assignment, or Host/system authority. + +### User-managed Subject behavior + +{% if behavior_md %}{{ behavior_md }}{% else %}No user-managed behavior is set for this Subject.{% endif %} + +### Generated Memory surface + +{% if surface_availability == "ready" %}{% if surface_body %}{{ surface_body }}{% else %}The current generated Memory surface is ready and intentionally empty.{% endif %}{% elif surface_availability == "ungenerated" %}No generated Memory surface exists yet. Confirmed Memory remains available through explicit recall tools.{% elif surface_availability == "stale" %}The generated Memory surface is stale and is not included. Confirmed Memory remains available through explicit recall tools.{% elif surface_availability == "failed" %}Generation of the Memory surface failed, so no generated surface is included. Confirmed Memory remains available through explicit recall tools.{% else %}The Host could not fetch the Subject resident context. Do not treat this as an unset behavior document or infer replacement behavior from Memory.{% endif %} diff --git a/tools/web-ux/browser-tests/console_history_shell_test.ts b/tools/web-ux/browser-tests/console_history_shell_test.ts index f83a5f125..909e8e693 100644 --- a/tools/web-ux/browser-tests/console_history_shell_test.ts +++ b/tools/web-ux/browser-tests/console_history_shell_test.ts @@ -596,6 +596,16 @@ async function checkConsoleHistory(viewportHeight: number): Promise { await touchPage.reload(); await touchPage.getByRole("button", { name: "Show sidebar", exact: true }).waitFor(); assertEquals(await touchPage.evaluate(() => localStorage.getItem("yoi.sidebar.mode.v1")), "pinned"); + // Different-route and same-page links both dismiss the mobile overlay. + for (let visit = 0; visit < 2; visit++) { + await touchPage.getByRole("button", { name: "Show sidebar", exact: true }).tap(); + await touchPage.getByRole("link", { name: "Workspace home", exact: true }).tap(); + await touchPage.waitForURL(`**/w/${workspaceId}`); + await touchPage.getByRole("button", { name: "Show sidebar", exact: true }).waitFor(); + assertEquals(await touchPage.locator(".app-shell__main").evaluate((element) => (element as HTMLElement).inert), false); + assertEquals(await touchPage.evaluate(() => localStorage.getItem("yoi.sidebar.mode.v1")), "pinned"); + } + await touchPage.goto(page.url()); // A wide touchscreen still has a temporary-open control in hover mode. await touchPage.setViewportSize({ width: 1024, height: 600 }); await touchPage.getByRole("button", { name: "Unpin sidebar", exact: true }).tap(); diff --git a/tools/web-ux/browser-tests/console_pending_shell_test.ts b/tools/web-ux/browser-tests/console_pending_shell_test.ts index 7be6c4046..57e6f7a16 100644 --- a/tools/web-ux/browser-tests/console_pending_shell_test.ts +++ b/tools/web-ux/browser-tests/console_pending_shell_test.ts @@ -64,6 +64,104 @@ Deno.test("Console pending scopes are independent and match Tasks row typography `${url}/w/console-history-review/workers/W-900-console-fixture/console?pending=${mode}`, ); await page.locator(".task-mini-row").waitFor(); + const metadata = page.getByLabel("Worker model and context", { exact: true }); + const statusGeometry = await metadata.evaluate((status) => { + const task = document.querySelector(".task-mini-row")!; + const composer = document.querySelector(".console-composer")!; + const metrics = (node: Element) => { + const style = getComputedStyle(node); + return [style.fontFamily, style.fontSize, style.lineHeight]; + }; + const rect = status.getBoundingClientRect(); + return { + typography: metrics(status), + taskTypography: metrics(task), + height: rect.height, + belowComposer: rect.top >= composer.getBoundingClientRect().bottom, + alignedWithTasks: rect.left + parseFloat(getComputedStyle(status).paddingLeft) === task.getBoundingClientRect().left, + visible: rect.bottom <= innerHeight, + wrap: getComputedStyle(status).flexWrap, + pageOverflow: document.documentElement.scrollWidth > innerWidth, + }; + }); + assertEquals(statusGeometry.typography, statusGeometry.taskTypography); + assertEquals(statusGeometry.height, 24); + assertEquals(statusGeometry.belowComposer, true); + assertEquals(statusGeometry.alignedWithTasks, true); + assertEquals(statusGeometry.visible, true); + assertEquals(statusGeometry.wrap, "nowrap"); + assertEquals(statusGeometry.pageOverflow, false); + const details = metadata.getByRole("button", { name: "Details", exact: true }); + assertEquals(await page.locator(".console-header").count(), 0); + const detailsGeometry = await details.evaluate((button) => { + const row = button.closest(".worker-context-status")!; + const rect = button.getBoundingClientRect(); + const rowRect = row.getBoundingClientRect(); + return { + rightAligned: Math.abs(rect.right - (rowRect.right - parseFloat(getComputedStyle(row).paddingRight))) < 1, + centered: Math.abs(rect.top + rect.height / 2 - (rowRect.top + rowRect.height / 2)) < 1, + width: rect.width, + }; + }); + assertEquals(detailsGeometry.rightAligned, true); + assertEquals(detailsGeometry.centered, true); + assertEquals(detailsGeometry.width, 24); + const overview = metadata.getByRole("switch", { name: "Overview", exact: true }); + const controls = await overview.evaluate((group) => { + const row = group.closest(".worker-context-status")!; + const rowRect = row.getBoundingClientRect(); + const buttons = [...row.querySelectorAll("button")]; + return { + buttonsInsideRow: buttons.every((button) => { + const rect = button.getBoundingClientRect(); + return rect.left >= rowRect.left && rect.right <= rowRect.right && + rect.top >= rowRect.top && rect.bottom <= rowRect.bottom; + }), + heights: buttons.map((button) => button.getBoundingClientRect().height), + modeFont: getComputedStyle(buttons[0]).fontFamily, + divider: getComputedStyle(buttons[1]).borderLeftWidth, + beforeDetails: group.nextElementSibling?.getAttribute("aria-label") === "Details", + }; + }); + assertEquals(controls.buttonsInsideRow, true); + assertEquals(controls.heights, [24, 24]); + assertEquals(controls.modeFont.includes("mono"), false); + assertEquals(controls.divider, "0px"); + assertEquals(controls.beforeDetails, true); + const onSize = (await overview.boundingBox())!; + assert(onSize.width < 90, "Switch and fixed label should use less than 90px"); + assertEquals(await overview.getAttribute("aria-checked"), "true"); + await overview.focus(); + assertEquals(await overview.evaluate((button) => getComputedStyle(button).outlineStyle), "solid"); + await page.keyboard.press("Enter"); + assertEquals(await overview.getAttribute("aria-checked"), "false"); + assertEquals((await overview.textContent())!.trim(), "Overview"); + assertEquals((await overview.boundingBox())!.width, onSize.width); + if (mode === "both") { + await Deno.mkdir(join(root, "target/web-ux/console-footer-switch"), { recursive: true }); + await page.screenshot({ path: join(root, `target/web-ux/console-footer-switch/${colorScheme}-${width}-off.png`) }); + } + await page.keyboard.press("Space"); + assertEquals(await overview.getAttribute("aria-checked"), "true"); + assertEquals((await overview.boundingBox())!.width, onSize.width); + await overview.locator("span").last().click(); + assertEquals(await overview.getAttribute("aria-checked"), "false"); + await overview.locator(".console-view-toggle-track").click(); + assertEquals(await overview.getAttribute("aria-checked"), "true"); + await details.focus(); + await page.keyboard.press("Enter"); + assertEquals(await details.getAttribute("aria-expanded"), "true"); + await page.getByRole("button", { name: "Close", exact: true }).waitFor(); + await page.keyboard.press("Space"); + assertEquals(await details.getAttribute("aria-expanded"), "false"); + await details.click(); + assertEquals(await details.getAttribute("aria-expanded"), "true"); + await page.getByRole("button", { name: "Close", exact: true }).click(); + assertEquals(await details.getAttribute("aria-expanded"), "false"); + if (mode === "both") { + await Deno.mkdir(join(root, "target/web-ux/console-footer-switch"), { recursive: true }); + await page.screenshot({ path: join(root, `target/web-ux/console-footer-switch/${colorScheme}-${width}.png`) }); + } const queue = mode === "both" || mode === "queue"; const notifications = ["both", "notifications", "legacy"].includes(mode); assertEquals( diff --git a/tools/web-ux/browser-tests/memory_document_fixture_server.ts b/tools/web-ux/browser-tests/memory_document_fixture_server.ts index 3cd9cc001..4fdb49d4a 100644 --- a/tools/web-ux/browser-tests/memory_document_fixture_server.ts +++ b/tools/web-ux/browser-tests/memory_document_fixture_server.ts @@ -66,7 +66,15 @@ ${"detail-wide-code-".repeat(18)} `; const subjects = [ - subject(representativeSubjectId, "Release coordination", "active", 42), + subject( + representativeSubjectId, + "Release coordination", + "active", + 42, + "Prefer explicit evidence over assumptions.\nAsk before irreversible actions, and state uncertainty plainly.", + 3, + connectedWorker("Release coordination Worker with a deliberately long display name"), + ), subject(emptySubjectId, "Empty ready subject", "active", 0), subject(staleSubjectId, "Stale surface subject", "active", 19), subject(failedSubjectId, "Failed surface subject", "active", 8), @@ -75,14 +83,49 @@ const subjects = [ ]; const pagedSubject = subject(pagedSubjectId, "Subject on the next page", "active", 1); -function subject(id: string, role: string, state: "active" | "retired", storeRevision: number) { +function connectedWorker(displayName: string) { + return { + runtime_id: "embedded-worker-runtime", + worker_id: "release-coordination-worker", + host_id: "fixture-host", + display_name: displayName, + label: displayName, + profile: "builtin:companion", + singleton_key: `subjektiv:${representativeSubjectId}`, + tags: [], + workspace: { + visibility: "workspace", + identity: workspaceId, + workspace_id: workspaceId, + }, + state: "idle", + last_seen_at: "2026-01-02T03:04:05Z", + pinned: false, + retention_state: "retained", + implementation: { kind: "embedded", display_hint: "Fixture Worker" }, + diagnostics: [], + }; +} + +function subject( + id: string, + role: string, + state: "active" | "retired", + storeRevision: number, + behaviorMd = "", + behaviorRevision = 0, + currentWorker?: ReturnType, +) { return { id, role, + behavior_md: behaviorMd, + behavior_revision: behaviorRevision, state, store_revision: storeRevision, created_at: "2026-01-01T00:00:00Z", updated_at: "2026-01-02T03:04:05Z", + ...(currentWorker ? { current_worker: currentWorker } : {}), }; } @@ -125,7 +168,7 @@ function memoriesFor(subjectId: string) { 1, "working_assumption", "retracted", - "T-672 UNIQUE END MARKER", + "Memory list end marker for scroll validation", ), ]; } @@ -235,7 +278,7 @@ function memoryDetail(memoryId: string, requestedRevision: number | null) { evidence_id: "evidence-message-0001", origin: { kind: "human_input", account_id: "account-fixture" }, evidence_kind: "message", - label: "T-672 product direction", + label: "Memory information design source", summary: "Bounded source reference retained with the committed revision.", }], source_refs_total: 1, @@ -403,7 +446,10 @@ Deno.serve({ hostname: "127.0.0.1", port }, async (request) => { const subjectsPath = `/api/w/${workspaceId}/subjektiv/subjects`; if (url.pathname === subjectsPath) { if (request.method === "POST") { - const payload = await request.json().catch(() => null) as { role?: unknown } | null; + const payload = await request.json().catch(() => null) as { + role?: unknown; + behavior_md?: unknown; + } | null; subjectCreateRequests.push(payload); if (!payload || typeof payload.role !== "string" || payload.role.trim().length === 0) { return json({ error: "Bad Request", message: "subject role must not be empty" }, 400); @@ -411,9 +457,12 @@ Deno.serve({ hostname: "127.0.0.1", port }, async (request) => { if (payload.role === "Permission denied") { return json({ error: "Forbidden", message: "workspace permission denied" }, 403); } + if (typeof payload.behavior_md !== "string") { + return json({ error: "Bad Request", message: "subject behavior must be a string" }, 400); + } createdSubjectCount += 1; const id = `created-subject-${String(createdSubjectCount).padStart(4, "0")}`; - const created = subject(id, payload.role, "active", 0); + const created = subject(id, payload.role, "active", 0, payload.behavior_md); createdSubjectIds.add(id); subjects.unshift(created); return json(created, 201); @@ -442,6 +491,27 @@ Deno.serve({ hostname: "127.0.0.1", port }, async (request) => { const foundSubject = subjects.find((item) => item.id === subjectId); if (!foundSubject) return json({}, 404); + if (parts.length === 2 && parts[1] === "behavior" && request.method === "PATCH") { + const payload = await request.json().catch(() => null) as { + expected_behavior_revision?: unknown; + behavior_md?: unknown; + } | null; + if ( + !payload || + typeof payload.expected_behavior_revision !== "number" || + typeof payload.behavior_md !== "string" + ) { + return json({ error: "Bad Request", message: "invalid behavior update" }, 400); + } + if (payload.expected_behavior_revision !== foundSubject.behavior_revision) { + return json({ error: "Conflict", message: "subject behavior revision conflict" }, 409); + } + foundSubject.behavior_md = payload.behavior_md; + foundSubject.behavior_revision += 1; + foundSubject.updated_at = "2026-01-03T04:05:06Z"; + return json(foundSubject); + } + if (request.method !== "GET") return new Response("method not allowed", { status: 405 }); if (parts.length === 1) return json(foundSubject); if (parts.length === 2 && parts[1] === "surface") return json(surfaceFor(subjectId)); if (parts.length === 2 && parts[1] === "memories") { diff --git a/tools/web-ux/browser-tests/memory_document_shell_test.ts b/tools/web-ux/browser-tests/memory_document_shell_test.ts index c0913082a..615b280df 100644 --- a/tools/web-ux/browser-tests/memory_document_shell_test.ts +++ b/tools/web-ux/browser-tests/memory_document_shell_test.ts @@ -16,7 +16,7 @@ const memoryId = const subjectPageCursor = "fixture-subject-page-2"; const createInteractionArtifacts = join( repositoryRoot, - "target/web-ux/t-689-browser-interactions", + "target/web-ux/t-692-browser-interactions", ); async function freePort(): Promise { @@ -51,7 +51,7 @@ async function markerIsInsideMain(page: Page): Promise { const document = (globalThis as any).document; const main = document.querySelector("main"); const marker = [...document.querySelectorAll("h3")].find((heading) => - heading.textContent === "T-672 UNIQUE END MARKER" + heading.textContent === "Memory list end marker for scroll validation" ); if (!main || !marker) return false; const mainRect = main.getBoundingClientRect(); @@ -111,6 +111,7 @@ async function openSubjectPage( await hideSidebar.click(); await showSidebar.waitFor(); + await page.waitForTimeout(250); assertEquals(await showSidebar.getAttribute("aria-expanded"), "false"); assertEquals( await page.locator("main").evaluate((main) => (main as unknown as { inert: boolean }).inert), @@ -217,6 +218,13 @@ Deno.test("production subject Memory and Worker launch shells preserve exact sco }); page.on("pageerror", (error) => errors.push(String(error))); await openSubjectPage(page, baseUrl, scenario.mobile); + assertEquals( + await page.getByRole("article", { name: "User-managed Subject behavior" }).count(), + 1, + ); + await page.screenshot({ + path: join(createInteractionArtifacts, `subject-${scenario.label}.png`), + }); const initial = await mainScrollState(page); assert(initial.scrollHeight > initial.clientHeight); @@ -262,7 +270,7 @@ Deno.test("production subject Memory and Worker launch shells preserve exact sco assertEquals(await page.locator('a[href="https://example.com"]').count(), 1); assertEquals(await page.locator('a[href^="javascript:"]').count(), 0); assertEquals( - await page.getByRole("article", { name: "Resident surface" }).locator("img, script") + await page.getByRole("article", { name: "Resident context" }).locator("img, script") .count(), 0, ); @@ -282,7 +290,7 @@ Deno.test("production subject Memory and Worker launch shells preserve exact sco await page.locator("main").hover(); await page.mouse.wheel(0, 100_000); } else if (scenario.label === "keyboard") { - await page.getByText("Surface provenance", { exact: true }).focus(); + await page.getByText("Surface sources and diagnostics", { exact: true }).focus(); await page.keyboard.press("End"); } else { await swipeMainToEnd(page); @@ -313,17 +321,17 @@ Deno.test("production subject Memory and Worker launch shells preserve exact sco level: 1, }).waitFor(); assertEquals(await detailPage.getByText("candidate-release-decision-0001").count(), 1); - assertEquals(await detailPage.getByText("T-672 product direction").count(), 1); + assertEquals(await detailPage.getByText("Memory information design source").count(), 1); assertEquals(await detailPage.getByText("memory-derived-source-0002").count(), 1); assertEquals(await detailPage.getByRole("heading", { name: "Revision history" }).count(), 1); - assertEquals(await detailPage.getByText("Revision 3", { exact: true }).count(), 1); - assertEquals(await detailPage.getByText("Revision 2", { exact: true }).count(), 1); - assertEquals(await detailPage.getByText("Revision 1", { exact: true }).count(), 0); + assertEquals(await detailPage.getByText("Memory revision 3", { exact: true }).count(), 1); + assertEquals(await detailPage.getByText("Memory revision 2", { exact: true }).count(), 1); + assertEquals(await detailPage.getByText("Memory revision 1", { exact: true }).count(), 0); await detailPage.getByRole("navigation", { name: "Revision history pages" }).getByRole( "link", { name: "Next page →" }, ).click(); - await detailPage.getByText("Revision 1", { exact: true }).waitFor(); + await detailPage.getByText("Memory revision 1", { exact: true }).waitFor(); assert(new URL(detailPage.url()).searchParams.has("revision_cursor")); assertEquals( await detailPage.getByRole("navigation", { name: "Revision history pages" }).getByRole( @@ -352,6 +360,15 @@ Deno.test("production subject Memory and Worker launch shells preserve exact sco const paginationPage = await paginationContext.newPage(); await paginationPage.goto(`${baseUrl}/w/${workspaceId}/memory`); await paginationPage.getByRole("heading", { name: "Subjects", level: 1 }).waitFor(); + assertEquals( + await paginationPage.getByText( + "Release coordination Worker with a deliberately long display name", + { exact: true }, + ).count(), + 1, + ); + assertEquals(await paginationPage.getByText("Store revision", { exact: true }).count(), 0); + assert((await paginationPage.getByText("Not connected", { exact: true }).count()) > 0); assertEquals(await paginationPage.getByText("Subject on the next page").count(), 0); await paginationPage.getByRole("navigation", { name: "Subject pages" }).getByRole( "link", @@ -425,7 +442,9 @@ Deno.test("production subject Memory and Worker launch shells preserve exact sco const createPage = await createContext.newPage(); const mutationPaths: string[] = []; createPage.on("request", (request) => { - if (request.method() === "POST") mutationPaths.push(new URL(request.url()).pathname); + if (["POST", "PATCH"].includes(request.method())) { + mutationPaths.push(new URL(request.url()).pathname); + } }); await createPage.goto(`${baseUrl}/w/${workspaceId}/memory?cursor=${subjectPageCursor}`); await createPage.getByText("Subject on the next page").waitFor(); @@ -444,26 +463,65 @@ Deno.test("production subject Memory and Worker launch shells preserve exact sco await createPage.getByRole("button", { name: "Cancel" }).click(); await createPage.getByRole("button", { name: "New Subject" }).click(); await createPage.getByRole("textbox", { name: "Role" }).fill(" Release steward "); + await createPage.getByRole("textbox", { name: /Behavior/ }).fill( + "Challenge unsupported assumptions before release.", + ); await createPage.getByRole("button", { name: "Create Subject" }).click(); await createPage.waitForURL("**/memory/created-subject-0001"); await createPage.getByRole("heading", { name: "Release steward", level: 1 }).waitFor(); + assertEquals( + await createPage.getByRole("article", { name: "User-managed Subject behavior" }).count(), + 1, + ); + assert( + (await createPage.getByRole("article", { + name: "User-managed Subject behavior", + }).textContent())?.includes("Challenge unsupported assumptions before release."), + ); assertEquals( await createPage.locator("code.subject-id", { hasText: "created-subject-0001" }).count(), 1, ); - const currentWorker = createPage.locator(".subject-facts div").filter({ - hasText: "Current worker", + const currentWorker = createPage.getByText("Worker connection", { exact: true }).locator( + "..", + ); + assert((await currentWorker.textContent())?.includes("Not connected")); + await createPage.getByText("Resident context has not been generated.").waitFor(); + await createPage.getByText("No committed Memories yet.").waitFor(); + await createPage.getByRole("button", { name: "Edit" }).click(); + const behaviorEditor = createPage.getByRole("textbox", { name: "Subject behavior" }); + await behaviorEditor.fill( + "Challenge unsupported assumptions before release.\nName the evidence and any remaining uncertainty.", + ); + await createPage.screenshot({ + path: join(createInteractionArtifacts, "behavior-edit-desktop-light.png"), + }); + await createPage.getByRole("button", { name: "Save behavior" }).click(); + await createPage.getByText(/Behavior storage confirmed at revision 1/).waitFor(); + assert( + (await createPage.getByRole("status").filter({ + hasText: "This page does not confirm application", + }).textContent())?.includes("does not confirm application"), + ); + await createPage.screenshot({ + path: join(createInteractionArtifacts, "behavior-saved-desktop-light.png"), }); - assert((await currentWorker.textContent())?.includes("None")); assertEquals(mutationPaths, [ `/api/w/${workspaceId}/subjektiv/subjects`, `/api/w/${workspaceId}/subjektiv/subjects`, + `/api/w/${workspaceId}/subjektiv/subjects/created-subject-0001/behavior`, ]); const createEvidence = await (await fetch(`${baseUrl}/fixture/subject-create-requests`)) .json(); assertEquals(createEvidence, { count: 2, - requests: [{ role: "Permission denied" }, { role: " Release steward " }], + requests: [ + { role: "Permission denied", behavior_md: "" }, + { + role: " Release steward ", + behavior_md: "Challenge unsupported assumptions before release.", + }, + ], }); await assertNoPageWideOverflow(createPage); await createContext.close(); @@ -472,21 +530,29 @@ Deno.test("production subject Memory and Worker launch shells preserve exact sco const stateCases = [ { id: "empty-subject", - text: "Resident surface is ready and empty.", + text: "Resident context is current but empty.", + role: "status" as const, + }, + { + id: "stale-subject", + text: "Resident context needs to be refreshed.", role: "status" as const, }, - { id: "stale-subject", text: "Resident surface is stale.", role: "status" as const }, { id: "failed-subject", - text: "Resident surface generation failed.", + text: "Resident context generation failed.", role: "alert" as const, }, { id: "ungenerated-subject", - text: "Resident surface has not been generated.", + text: "Resident context has not been generated.", role: "status" as const, }, - { id: "error-subject", text: "Resident surface unavailable.", role: "alert" as const }, + { + id: "error-subject", + text: "Resident context status unavailable.", + role: "alert" as const, + }, ]; for (const stateCase of stateCases) { const page = await stateContext.newPage(); @@ -494,8 +560,14 @@ Deno.test("production subject Memory and Worker launch shells preserve exact sco const state = page.getByRole(stateCase.role).filter({ hasText: stateCase.text }); await state.waitFor(); assert((await state.textContent())?.includes(stateCase.text)); + if (stateCase.id === "stale-subject") { + assertEquals(await page.getByText("A resolved stale-surface lesson").count(), 1); + } + if (stateCase.id === "failed-subject") { + assertEquals(await page.getByText("A retracted failed-surface constraint").count(), 1); + } if (stateCase.id !== "empty-subject") { - assertEquals(await page.getByRole("article", { name: "Resident surface" }).count(), 0); + assertEquals(await page.getByRole("article", { name: "Resident context" }).count(), 0); } await assertNoPageWideOverflow(page); await page.close(); diff --git a/tools/web-ux/scenarios/memory-subjects-dark.json b/tools/web-ux/scenarios/memory-subjects-dark.json index 7fbdebfb0..bfb3de69b 100644 --- a/tools/web-ux/scenarios/memory-subjects-dark.json +++ b/tools/web-ux/scenarios/memory-subjects-dark.json @@ -122,9 +122,33 @@ "dataState": "Ready generated Markdown surface with long content, unsafe Markdown, wide table/code, provenance refs, and three lifecycle states.", "ready": { "kind": "selector", - "selector": "[data-memory-view='subject'][data-memory-state='ready'] article[aria-label='Resident surface']" + "selector": "[data-memory-view='subject'][data-memory-state='ready'] article[aria-label='Resident context']" }, - "capturePoints": [{ "id": "initial", "label": "Ready subject" }] + "capturePoints": [ + { "id": "initial", "label": "Ready subject" }, + { + "id": "subject-diagnostics", + "label": "Subject diagnostics", + "interaction": [ + { "action": "click", "selector": "details.subject-technical-details summary" } + ], + "ready": { + "kind": "selector", + "selector": "details.subject-technical-details[open]" + } + }, + { + "id": "surface-diagnostics", + "label": "Surface sources and diagnostics", + "interaction": [ + { "action": "click", "selector": "details.surface-details summary" } + ], + "ready": { + "kind": "selector", + "selector": "details.surface-details[open]" + } + } + ] }, { "id": "detail", @@ -150,6 +174,18 @@ }, "capturePoints": [{ "id": "initial", "label": "Ready empty" }] }, + { + "id": "ungenerated", + "label": "Ungenerated subject context", + "path": "/w/memory-review/memory/ungenerated-subject", + "goal": "Distinguish a Subject with no generated resident context from lost committed Memory.", + "dataState": "Ungenerated resident context and a successful empty committed Memory list.", + "ready": { + "kind": "selector", + "selector": "[data-memory-view='subject'][data-memory-state='ungenerated']" + }, + "capturePoints": [{ "id": "initial", "label": "Not generated" }] + }, { "id": "stale", "label": "Stale subject surface", diff --git a/tools/web-ux/scenarios/memory-subjects-light.json b/tools/web-ux/scenarios/memory-subjects-light.json index 9ddf6f803..905f15372 100644 --- a/tools/web-ux/scenarios/memory-subjects-light.json +++ b/tools/web-ux/scenarios/memory-subjects-light.json @@ -122,9 +122,33 @@ "dataState": "Ready generated Markdown surface with long content, unsafe Markdown, wide table/code, provenance refs, and three lifecycle states.", "ready": { "kind": "selector", - "selector": "[data-memory-view='subject'][data-memory-state='ready'] article[aria-label='Resident surface']" + "selector": "[data-memory-view='subject'][data-memory-state='ready'] article[aria-label='Resident context']" }, - "capturePoints": [{ "id": "initial", "label": "Ready subject" }] + "capturePoints": [ + { "id": "initial", "label": "Ready subject" }, + { + "id": "subject-diagnostics", + "label": "Subject diagnostics", + "interaction": [ + { "action": "click", "selector": "details.subject-technical-details summary" } + ], + "ready": { + "kind": "selector", + "selector": "details.subject-technical-details[open]" + } + }, + { + "id": "surface-diagnostics", + "label": "Surface sources and diagnostics", + "interaction": [ + { "action": "click", "selector": "details.surface-details summary" } + ], + "ready": { + "kind": "selector", + "selector": "details.surface-details[open]" + } + } + ] }, { "id": "detail", @@ -150,6 +174,18 @@ }, "capturePoints": [{ "id": "initial", "label": "Ready empty" }] }, + { + "id": "ungenerated", + "label": "Ungenerated subject context", + "path": "/w/memory-review/memory/ungenerated-subject", + "goal": "Distinguish a Subject with no generated resident context from lost committed Memory.", + "dataState": "Ungenerated resident context and a successful empty committed Memory list.", + "ready": { + "kind": "selector", + "selector": "[data-memory-view='subject'][data-memory-state='ungenerated']" + }, + "capturePoints": [{ "id": "initial", "label": "Not generated" }] + }, { "id": "stale", "label": "Stale subject surface", diff --git a/web/workspace/src/lib/generated/companion-api.ts b/web/workspace/src/lib/generated/companion-api.ts index 7a359765e..e8d1f0a6a 100644 --- a/web/workspace/src/lib/generated/companion-api.ts +++ b/web/workspace/src/lib/generated/companion-api.ts @@ -1,6 +1,6 @@ // @generated by yoi-companion-openapi-typescript@1. Do not edit. // Input: openapi/server-api.json -// Input contract: 0.1.0 (sha256:eddff23c6405c11e8becfef09473d6833647bcaba9243abae189db7f840c1896) +// Input contract: 0.1.0 (sha256:47064776569f7f208533f24d56ac80c0db6055d508ab29fb0a780c2c66387081) // Options: companion-operation-schema-closure,openapi-3.1,strict-integer-ranges,sorted-output // Output: web/workspace/src/lib/generated/companion-api.ts // Regenerate/check: cargo run -q -p server-api --example generate_companion_api_types -- --check diff --git a/web/workspace/src/lib/generated/memory-api.ts b/web/workspace/src/lib/generated/memory-api.ts index f91d69fb5..e4154515b 100644 --- a/web/workspace/src/lib/generated/memory-api.ts +++ b/web/workspace/src/lib/generated/memory-api.ts @@ -9,6 +9,10 @@ export const MEMORY_API_LIMITS = { maxIdentifierBytes: 512, } as const; +export const SUBJEKTIV_API_LIMITS = { + maxBehaviorBytes: 16384, +} as const; + export type DiagnosticSeverity = "info" | "warning" | "error"; export type Diagnostic = { code: string, severity: DiagnosticSeverity, message: string, }; @@ -35,9 +39,11 @@ export type MemoryStagingListResponse = { limit: number, returned_count: number, export type SubjektivSubjectState = "active" | "retired"; -export type SubjektivSubjectCreateRequest = { role: string, }; +export type SubjektivSubjectCreateRequest = { role: string, behavior_md?: string, }; -export type SubjektivSubjectResponse = { id: string, role: string, state: SubjektivSubjectState, store_revision: number, created_at: string, updated_at: string, current_worker?: | import("./worker-launch-api").WorkerLaunchWorkerSummary | null, }; +export type SubjektivSubjectBehaviorUpdateRequest = { expected_behavior_revision: number, behavior_md: string, }; + +export type SubjektivSubjectResponse = { id: string, role: string, behavior_md: string, behavior_revision: number, state: SubjektivSubjectState, store_revision: number, created_at: string, updated_at: string, current_worker?: | import("./worker-launch-api").WorkerLaunchWorkerSummary | null, }; export type SubjektivSubjectListResponse = { limit: number, items: Array, next_cursor?: string | null, has_more: boolean, }; diff --git a/web/workspace/src/lib/generated/repository-api.ts b/web/workspace/src/lib/generated/repository-api.ts index a2f189a09..829ce3ecc 100644 --- a/web/workspace/src/lib/generated/repository-api.ts +++ b/web/workspace/src/lib/generated/repository-api.ts @@ -1,6 +1,6 @@ // @generated by yoi-repository-openapi-typescript@1. Do not edit. // Input: openapi/server-api.json -// Input contract: 0.1.0 (sha256:eddff23c6405c11e8becfef09473d6833647bcaba9243abae189db7f840c1896) +// Input contract: 0.1.0 (sha256:47064776569f7f208533f24d56ac80c0db6055d508ab29fb0a780c2c66387081) // Options: repository-operation-schema-closure,openapi-3.1,format:uint64=safe-number,strict-safe-integers,sorted-output // Output: web/workspace/src/lib/generated/repository-api.ts // Regenerate/check: cargo run -q -p server-api --example generate_repository_openapi_types -- --check diff --git a/web/workspace/src/lib/generated/runtime-api.ts b/web/workspace/src/lib/generated/runtime-api.ts index 2a951bf71..f887f8350 100644 --- a/web/workspace/src/lib/generated/runtime-api.ts +++ b/web/workspace/src/lib/generated/runtime-api.ts @@ -1,6 +1,6 @@ // @generated by yoi-runtime-openapi-typescript@1. Do not edit. // Input: openapi/server-api.json -// Input contract: 0.1.0 (sha256:eddff23c6405c11e8becfef09473d6833647bcaba9243abae189db7f840c1896) +// Input contract: 0.1.0 (sha256:47064776569f7f208533f24d56ac80c0db6055d508ab29fb0a780c2c66387081) // Options: runtime-operation-schema-closure,openapi-3.1,strict-integer-ranges,sorted-output // Output: web/workspace/src/lib/generated/runtime-api.ts // Regenerate/check: cargo run -q -p server-api --example generate_runtime_api_types -- --check diff --git a/web/workspace/src/lib/generated/ticket-api.ts b/web/workspace/src/lib/generated/ticket-api.ts index b894fd650..ac5aec6bb 100644 --- a/web/workspace/src/lib/generated/ticket-api.ts +++ b/web/workspace/src/lib/generated/ticket-api.ts @@ -1,6 +1,6 @@ // @generated by yoi-ticket-browser-openapi-typescript@1. Do not edit. // Input: openapi/server-api.json -// Input contract: 0.1.0 (sha256:eddff23c6405c11e8becfef09473d6833647bcaba9243abae189db7f840c1896) +// Input contract: 0.1.0 (sha256:47064776569f7f208533f24d56ac80c0db6055d508ab29fb0a780c2c66387081) // Options: ticket-objective-merge-request-browser-operation-schema-closure,openapi-3.1,strict-integer-ranges,sorted-output // Output: web/workspace/src/lib/generated/ticket-api.ts // Regenerate/check: cargo run -q -p server-api --example generate_ticket_api_types -- --check diff --git a/web/workspace/src/lib/generated/workdir-api.ts b/web/workspace/src/lib/generated/workdir-api.ts index 0fa79bece..f06b36ce5 100644 --- a/web/workspace/src/lib/generated/workdir-api.ts +++ b/web/workspace/src/lib/generated/workdir-api.ts @@ -1,6 +1,6 @@ // @generated by yoi-workdir-openapi-typescript@1. Do not edit. // Input: openapi/server-api.json -// Input contract: 0.1.0 (sha256:f9f0f8918dbcce386e94d5368fa938f02565ca98f9f67f07537bd88ed8c49870) +// Input contract: 0.1.0 (sha256:47064776569f7f208533f24d56ac80c0db6055d508ab29fb0a780c2c66387081) // Options: workdir-operation-schema-closure,openapi-3.1,strict-integer-ranges,sorted-output // Output: web/workspace/src/lib/generated/workdir-api.ts // Regenerate/check: cargo run -q -p server-api --example generate_workdir_api_types -- --check diff --git a/web/workspace/src/lib/generated/worker-launch-api.ts b/web/workspace/src/lib/generated/worker-launch-api.ts index 75552425d..dc4241a3e 100644 --- a/web/workspace/src/lib/generated/worker-launch-api.ts +++ b/web/workspace/src/lib/generated/worker-launch-api.ts @@ -1,6 +1,6 @@ // @generated by yoi-worker-openapi-typescript@1. Do not edit. // Input: openapi/server-api.json -// Input contract: 0.1.0 (sha256:eddff23c6405c11e8becfef09473d6833647bcaba9243abae189db7f840c1896) +// Input contract: 0.1.0 (sha256:47064776569f7f208533f24d56ac80c0db6055d508ab29fb0a780c2c66387081) // Options: worker-operation-schema-closure,openapi-3.1,strict-integer-ranges,sorted-output // Output: web/workspace/src/lib/generated/worker-launch-api.ts // Regenerate/check: cargo run -q -p server-api --example generate_worker_launch_api_types -- --check diff --git a/web/workspace/src/lib/workspace/api/workers.test.ts b/web/workspace/src/lib/workspace/api/workers.test.ts index 247b7f41c..b70336003 100644 --- a/web/workspace/src/lib/workspace/api/workers.test.ts +++ b/web/workspace/src/lib/workspace/api/workers.test.ts @@ -33,6 +33,7 @@ import { parseWorkerLaunchOptionsResponse, parseWorkerSummary, } from "./workers.ts"; +import { loadJson } from "./http.ts"; const worker = { runtime_id: "runtime-a", @@ -111,6 +112,92 @@ Deno.test("Worker summary parser enforces observation freshness", () => { assertEquals(observedStopped.state, "stopped"); }); +const workerStates = [ + { kind: "idle" }, + { kind: "busy", state: { kind: "run", state: "running" } }, + { kind: "busy", state: { kind: "run", state: "pausing" } }, + { kind: "busy", state: { kind: "run", state: "paused" } }, + { kind: "busy", state: { kind: "run", state: "cancelling" } }, + { kind: "busy", state: { kind: "maintenance", state: "compacting" } }, +] as const; + +Deno.test("Worker summary parser preserves the optional submission completion fence in every state", () => { + for (const state of workerStates) { + for (const completion of [ + {}, + { last_finished_submission_request_id: null }, + { last_finished_submission_request_id: "completed-submit" }, + ]) { + const snapshot = { last_command_id: 7, ...completion, state }; + const parsed = parseWorkerSummary({ + ...worker, + resource_key: "W-1", + availability: "observed", + worker_state: snapshot, + }); + assertEquals(parsed.worker_state, snapshot); + } + } +}); + +Deno.test("Worker summary parser validates submission completion fences without accepting unknown fields", () => { + for (const state of workerStates) { + for (const invalid of [0, false, {}, [], "x".repeat(65_537)]) { + assertThrows( + () => parseWorkerSummary({ + ...worker, + resource_key: "W-1", + availability: "observed", + worker_state: { + last_command_id: 7, + last_finished_submission_request_id: invalid, + state, + }, + }), + Error, + "worker_state.last_finished_submission_request_id", + ); + } + assertThrows( + () => parseWorkerSummary({ + ...worker, + resource_key: "W-1", + availability: "observed", + worker_state: { + last_command_id: 7, + last_finished_submission_request_id: "completed-submit", + state, + unexpected: true, + }, + }), + Error, + "unknown field unexpected", + ); + } +}); + +Deno.test("Console Worker API loading accepts a completed submission snapshot", async () => { + const snapshot = { + last_command_id: 0, + last_finished_submission_request_id: "completed-submit", + state: { kind: "idle" }, + }; + const result = await loadJson( + () => Promise.resolve(Response.json({ + ...worker, + resource_key: "W-1", + availability: "observed", + worker_state: snapshot, + })), + "/api/w/workspace-a/workers/W-1", + undefined, + parseWorkerSummary, + { diagnosticLabel: "Worker API", maxResponseBytes: 8 * 1024 * 1024 }, + ); + assertEquals(result.error, null); + assertEquals(result.data?.worker_state, snapshot); +}); + Deno.test("Worker summary parser preserves attachment-effective External permissions", () => { const attachment = { alias: "sessions", diff --git a/web/workspace/src/lib/workspace/api/workers.ts b/web/workspace/src/lib/workspace/api/workers.ts index 0cdb72e60..e1f765a7c 100644 --- a/web/workspace/src/lib/workspace/api/workers.ts +++ b/web/workspace/src/lib/workspace/api/workers.ts @@ -262,7 +262,16 @@ function workerStateSnapshot( label: string, ): WorkerStateSnapshot { const snapshot = record(value, label); - exact(snapshot, ["last_command_id", "state"], label); + exact( + snapshot, + ["last_command_id", "last_finished_submission_request_id", "state"], + label, + ); + const lastFinishedSubmissionRequestId = optional( + snapshot.last_finished_submission_request_id, + `${label}.last_finished_submission_request_id`, + string, + ); const state = record(snapshot.state, `${label}.state`); const kind = string(state.kind, `${label}.state.kind`); if (kind === "idle") { @@ -272,6 +281,7 @@ function workerStateSnapshot( snapshot.last_command_id, `${label}.last_command_id`, ), + last_finished_submission_request_id: lastFinishedSubmissionRequestId, state: { kind: "idle" }, }; } @@ -291,6 +301,7 @@ function workerStateSnapshot( snapshot.last_command_id, `${label}.last_command_id`, ), + last_finished_submission_request_id: lastFinishedSubmissionRequestId, state: { kind: "busy", state: busyKind === "run" diff --git a/web/workspace/src/lib/workspace/config-source/ConfigSourceEditor.browser.spec.ts b/web/workspace/src/lib/workspace/config-source/ConfigSourceEditor.browser.spec.ts new file mode 100644 index 000000000..6355eaa4d --- /dev/null +++ b/web/workspace/src/lib/workspace/config-source/ConfigSourceEditor.browser.spec.ts @@ -0,0 +1,231 @@ +// @vitest-environment happy-dom + +import { cleanup, fireEvent, render, waitFor } from "@testing-library/svelte"; +import { afterEach, beforeEach, expect, test, vi } from "vitest"; +import ConfigSourceEditor from "./ConfigSourceEditor.svelte"; + +const toolchain = vi.hoisted(() => ({ + construct: vi.fn(), + setSnapshot: vi.fn(), + analyze: vi.fn(), + complete: vi.fn(), + close: vi.fn(), +})); +vi.mock("./toolchain.ts", () => ({ + ConfigSourceToolchain: class { + constructor() { + toolchain.construct(); + } + setSnapshot = toolchain.setSnapshot; + analyze = toolchain.analyze; + complete = toolchain.complete; + close = toolchain.close; + }, +})); + +const schemaSource = "schema body\n".repeat(6000); +const tree = { + snapshot: { + revision: 7, + digest: "sha256:tree", + entries: { + "main.dcdl": { + path: "main.dcdl", + content_type: "decodal", + content: "WorkspaceConfigSchema({})\n", + content_digest: "sha256:main", + }, + }, + }, + contract: { + contract_version: 1, + decodal_version: "0.4.0", + schema_version: 1, + entrypoints: ["main.dcdl"], + import_policy_version: 1, + schema_bundle: { + contributions: [{ + provider_id: "builtin", + namespace: "workspace", + version: "1", + source: schemaSource, + source_digest: "sha256:contribution", + }], + source: schemaSource, + fingerprint: "sha256:schema", + }, + fingerprint: "sha256:toolchain", + }, + projection_digest: "sha256:projection", +}; +const fetcher = vi.fn(); +const response = () => new Response(JSON.stringify(tree)); + +beforeEach(() => { + vi.resetAllMocks(); + vi.stubGlobal("fetch", fetcher); + fetcher.mockImplementation(async () => response()); + toolchain.setSnapshot.mockResolvedValue(undefined); + toolchain.analyze.mockResolvedValue([]); + toolchain.complete.mockResolvedValue(null); +}); +afterEach(() => { + cleanup(); + vi.unstubAllGlobals(); +}); + +function renderEditor(workspaceId = "w") { + return render(ConfigSourceEditor, { workspaceId }); +} + +function expectNoEditor(view: ReturnType) { + expect(view.queryByText("Select or create a source")).toBeNull(); + expect(view.queryByRole("button", { name: "Create local source" })) + .toBeNull(); + expect(view.queryByRole("button", { name: "Commit" })).toBeNull(); + expect(view.container.querySelector(".cm-editor")).toBeNull(); +} + +async function expectReady(view: ReturnType) { + await waitFor(() => + expect(view.getByRole("button", { name: "main.dcdl entrypoint" })).not + .toBeNull() + ); + expect(view.container.querySelector('.cm-content[contenteditable="true"]')) + .not.toBeNull(); + expect(view.container.textContent).toContain("WorkspaceConfigSchema({})"); + expect(view.queryByRole("alert")).toBeNull(); +} + +test("loading is not an empty source tree; editing waits for schema installation", async () => { + let finishFetch!: (response: Response) => void; + let finishSetup!: () => void; + fetcher.mockReturnValueOnce( + new Promise((resolve) => { + finishFetch = resolve; + }), + ); + toolchain.setSnapshot.mockReturnValueOnce( + new Promise((resolve) => { + finishSetup = resolve; + }), + ); + const view = render(ConfigSourceEditor, { workspaceId: "w/one" }); + expect(view.getByRole("status").textContent).toContain("Loading source tree"); + expectNoEditor(view); + finishFetch(response()); + await waitFor(() => + expect(toolchain.setSnapshot).toHaveBeenCalledWith( + tree.snapshot, + tree.contract.schema_bundle, + ) + ); + expectNoEditor(view); + finishSetup(); + await expectReady(view); + expect(fetcher).toHaveBeenCalledWith( + "/api/w/w%2Fone/config/source-tree", + expect.anything(), + ); +}); + +test.each(["http", "invalid"])( + "%s failure has a manual reload, not creation controls or automatic retries", + async (failure) => { + fetcher.mockResolvedValueOnce( + failure === "http" + ? new Response("Backend unavailable", { status: 503 }) + : new Response(JSON.stringify({ ...tree, unexpected: true })), + ); + const view = renderEditor(); + await waitFor(() => + expect(view.getByRole("alert").textContent).toContain( + "Unable to load workspace configuration", + ) + ); + expect(view.getByRole("alert").textContent).toContain( + failure === "http" ? "Backend unavailable" : "invalid response", + ); + expectNoEditor(view); + expect(toolchain.setSnapshot).not.toHaveBeenCalled(); + await new Promise((resolve) => setTimeout(resolve, 300)); + expect(fetcher).toHaveBeenCalledOnce(); + await fireEvent.click( + view.getByRole("button", { name: "Reload source tree" }), + ); + await expectReady(view); + expect(fetcher).toHaveBeenCalledTimes(2); + }, +); + +test.each(["construct", "setSnapshot"] as const)( + "toolchain %s failure is caught and manual reload initializes a fresh toolchain", + async (phase) => { + if (phase === "construct") { + toolchain.construct.mockImplementationOnce(() => { + throw new Error("Worker unavailable"); + }); + } else {toolchain.setSnapshot.mockRejectedValueOnce( + new Error("Schema setup failed"), + );} + const view = renderEditor(); + await waitFor(() => + expect(view.getByRole("alert").textContent).toContain( + phase === "construct" ? "Worker unavailable" : "Schema setup failed", + ) + ); + expectNoEditor(view); + await fireEvent.click( + view.getByRole("button", { name: "Reload source tree" }), + ); + await expectReady(view); + expect(toolchain.construct).toHaveBeenCalledTimes(2); + }, +); + +test("a successfully loaded empty tree still offers source creation", async () => { + fetcher.mockResolvedValueOnce( + new Response(JSON.stringify({ + ...tree, + snapshot: { ...tree.snapshot, entries: {} }, + })), + ); + const view = renderEditor(); + await waitFor(() => + expect(view.getByText("Select or create a source")).not.toBeNull() + ); + expect(view.getByRole("button", { name: "Create local source" })).not + .toBeNull(); + expect(view.queryByRole("alert")).toBeNull(); +}); + +test("unmount during schema setup closes the toolchain", async () => { + let failSetup!: (error: Error) => void; + toolchain.setSnapshot.mockReturnValueOnce( + new Promise((_, reject) => { + failSetup = reject; + }), + ); + toolchain.close.mockImplementationOnce(() => + failSetup(new Error("toolchain closed")) + ); + const view = renderEditor(); + await waitFor(() => expect(toolchain.setSnapshot).toHaveBeenCalledOnce()); + view.unmount(); + await new Promise((resolve) => setTimeout(resolve, 0)); + expect(toolchain.close).toHaveBeenCalledOnce(); +}); + +test("unmount during fetch does not start a toolchain", async () => { + let finishFetch!: (response: Response) => void; + fetcher.mockReturnValueOnce( + new Promise((resolve) => { + finishFetch = resolve; + }), + ); + const view = renderEditor(); + view.unmount(); + finishFetch(response()); + await new Promise((resolve) => setTimeout(resolve, 0)); + expect(toolchain.construct).not.toHaveBeenCalled(); +}); diff --git a/web/workspace/src/lib/workspace/config-source/ConfigSourceEditor.svelte b/web/workspace/src/lib/workspace/config-source/ConfigSourceEditor.svelte index 0cf4471d4..78f9edb44 100644 --- a/web/workspace/src/lib/workspace/config-source/ConfigSourceEditor.svelte +++ b/web/workspace/src/lib/workspace/config-source/ConfigSourceEditor.svelte @@ -21,6 +21,8 @@ let newPath = $state("module.dcdl"); let diagnostics = $state([]); let status = $state("Loading source tree…"); + let loadState = $state<"loading" | "error" | "ready">("loading"); + let mounted = false; let busy = $state(false); let workingChanges = $state([]); let baseRevision = $state(0); @@ -42,9 +44,12 @@ const dirty = $derived(workingChanges.length > 0 || (selected ? source !== selected.content : source.length > 0)); onMount(() => { - toolchain = new ConfigSourceToolchain(); + mounted = true; void reload(); - return () => toolchain?.close(); + return () => { + mounted = false; + toolchain?.close(); + }; }); $effect(() => { @@ -70,9 +75,20 @@ }); async function reload() { + loadState = "loading"; + status = "Loading source tree…"; analysisReady = false; + analysisGeneration += 1; try { - treeState = await fetchConfigTree(workspaceId); + const remote = await fetchConfigTree(workspaceId); + if (!mounted) return; + toolchain?.close(); + toolchain = null; + toolchain = new ConfigSourceToolchain(); + await toolchain.setSnapshot(remote.snapshot, remote.contract.schema_bundle); + if (!mounted) return; + // Publish the new baseline only after both fetch and toolchain setup succeed. + treeState = remote; if (!selectedPath || !treeState.snapshot.entries[selectedPath]) { selectedPath = Object.keys(treeState.snapshot.entries).toSorted()[0] ?? ""; } @@ -80,15 +96,19 @@ baseSnapshot = $state.snapshot(treeState.snapshot); baseRevision = treeState.snapshot.revision; baseDigest = treeState.snapshot.digest; - await toolchain?.setSnapshot(treeState.snapshot, treeState.contract.schema_bundle); analysisReady = true; workingChanges = []; renamePath = selectedPath; diagnostics = []; conflict = false; status = `Revision ${treeState.snapshot.revision} · ${treeState.snapshot.digest.slice(0, 20)}…`; + loadState = "ready"; } catch (error) { + if (!mounted) return; + toolchain?.close(); + toolchain = null; status = String(error); + loadState = "error"; } } @@ -213,8 +233,6 @@ } async function discardAndReload() { - workingChanges = []; - source = ""; await reload(); } @@ -287,7 +305,18 @@ } -
+
+ {#if loadState === "loading"} +
Loading source tree…
+ {:else if loadState === "error"} +
+
+ Unable to load workspace configuration +

{status}

+
+ +
+ {:else}
+ + diff --git a/web/workspace/src/lib/workspace/config-source/api.ts b/web/workspace/src/lib/workspace/config-source/api.ts index 479ba0b9b..c277ccc5d 100644 --- a/web/workspace/src/lib/workspace/config-source/api.ts +++ b/web/workspace/src/lib/workspace/config-source/api.ts @@ -11,6 +11,9 @@ import type { } from "./types.ts"; const MAX_RESPONSE_BYTES = 8 * 1024 * 1024; +// Schema bodies are documents, not short metadata. Keep them within the +// response budget; readJson also bounds the combined bundle and contributions. +const MAX_SCHEMA_SOURCE_BYTES = MAX_RESPONSE_BYTES; const MAX_ERROR_BYTES = 4 * 1024; const MAX_ENTRY_COUNT = 256; const MAX_ENTRY_BYTES = 256 * 1024; @@ -193,7 +196,7 @@ function parseContribution(value: unknown): ConfigSchemaContribution { provider_id: boundedString(item.provider_id), namespace: boundedString(item.namespace), version: boundedString(item.version), - source: boundedString(item.source), + source: boundedString(item.source, MAX_SCHEMA_SOURCE_BYTES), ...(projection === undefined ? {} : { projection_validator: projection }), source_digest: boundedString(item.source_digest, MAX_DIGEST_BYTES), }; @@ -203,7 +206,7 @@ function parseSchemaBundle(value: unknown): WorkspaceConfigSchemaBundle { const item = record(value, ["contributions", "source", "fingerprint"]); return { contributions: boundedArray(item.contributions).map(parseContribution), - source: boundedString(item.source), + source: boundedString(item.source, MAX_SCHEMA_SOURCE_BYTES), fingerprint: boundedString(item.fingerprint, MAX_DIGEST_BYTES), }; } diff --git a/web/workspace/src/lib/workspace/console/WorkerContextStatus.browser.spec.ts b/web/workspace/src/lib/workspace/console/WorkerContextStatus.browser.spec.ts index f45776f62..fd37c8bcf 100644 --- a/web/workspace/src/lib/workspace/console/WorkerContextStatus.browser.spec.ts +++ b/web/workspace/src/lib/workspace/console/WorkerContextStatus.browser.spec.ts @@ -6,6 +6,23 @@ import WorkerContextStatus from "./WorkerContextStatus.svelte"; afterEach(cleanup); +test("the three-dot Details control invokes the existing toggle and reflects its state", async () => { + let toggles = 0; + const view = render(WorkerContextStatus, { + metadata: null, + detailsOpen: false, + onToggleDetails: () => { toggles++; }, + }); + const button = view.getByRole("button", { name: "Details" }); + expect(button.querySelectorAll("circle")).toHaveLength(3); + expect(button.textContent?.trim()).toBe(""); + expect(button.getAttribute("aria-expanded")).toBe("false"); + button.click(); + expect(toggles).toBe(1); + await view.rerender({ detailsOpen: true }); + expect(button.getAttribute("aria-expanded")).toBe("true"); +}); + test("renders resolved model, reasoning, and measured context accessibly", () => { const view = render(WorkerContextStatus, { metadata: { @@ -19,13 +36,13 @@ test("renders resolved model, reasoning, and measured context accessibly", () => const status = view.getByLabelText("Worker model and context"); expect(status.textContent).toContain("gpt-6-astra · high"); - expect(status.textContent).toContain("Context 142.0k / 272.0k (52%)"); + expect(status.textContent).toContain("142.0k / 272.0k (52%)"); }); test("renders honest unavailable state instead of a fabricated zero percent", async () => { const view = render(WorkerContextStatus, { metadata: null }); expect(view.getByText("Model unavailable")).not.toBeNull(); - expect(view.getByText("Context unavailable")).not.toBeNull(); + expect(view.getByText("unavailable")).not.toBeNull(); expect(view.container.textContent).not.toContain("0%"); await view.rerender({ @@ -38,7 +55,7 @@ test("renders honest unavailable state instead of a fabricated zero percent", as }, }); expect(view.container.textContent).toContain("claude · 8.2k token budget"); - expect(view.container.textContent).toContain("Context ~12.0k / 64.0k (19%)"); + expect(view.container.textContent).toContain("~12.0k / 64.0k (19%)"); await view.rerender({ metadata: { diff --git a/web/workspace/src/lib/workspace/console/WorkerContextStatus.svelte b/web/workspace/src/lib/workspace/console/WorkerContextStatus.svelte index 248cf5025..6f6ad9db1 100644 --- a/web/workspace/src/lib/workspace/console/WorkerContextStatus.svelte +++ b/web/workspace/src/lib/workspace/console/WorkerContextStatus.svelte @@ -1,4 +1,5 @@
{modelSummary} - + {contextSummary} + {#if controls || onToggleDetails} +
+ {@render controls?.()} + {#if onToggleDetails} + + {/if} +
+ {/if}
diff --git a/web/workspace/src/lib/workspace/console/model.test.ts b/web/workspace/src/lib/workspace/console/model.test.ts index 4642618a5..06643d73e 100644 --- a/web/workspace/src/lib/workspace/console/model.test.ts +++ b/web/workspace/src/lib/workspace/console/model.test.ts @@ -3769,6 +3769,29 @@ Deno.test("committed tool identities reconcile one live call block", () => { ); }); +Deno.test("image rejection correction updates the existing tool result without duplicate rows", () => { + const result: SessionSnapshotEntry = { + kind: "tool_result", entry_id: "result-image", timestamp: 2, + provenance: "tool_output", call_id: "image", summary: "Attached image", is_error: false, + attachments: [{ attachment_id: "image-body", media_type: "image/png", byte_len: 100 }], + }; + const lines = projectConsole([ + { eventId: "call", event: { event: "session_entry_committed", data: { entry: { + kind: "tool_call", entry_id: "call-image", timestamp: 1, provenance: "model_output", + call_id: "image", name: "ViewImage", arguments: "{}", + } } } }, + { eventId: "original", event: { event: "session_entry_committed", data: { entry: result } } }, + { eventId: "correction", event: { event: "session_entry_committed", data: { entry: { + ...result, timestamp: 3, summary: "Resize or crop and retry ViewImage", is_error: true, attachments: [], + } } } }, + ]).lines; + assertEquals(lines.length, 1); + assertEquals(lines[0].entryId, "result-image"); + assertEquals(lines[0].toolCall?.state, "error"); + assertEquals(lines[0].attachments ?? [], []); + assert(lines[0].body.includes("Resize or crop"), "show the model recovery instruction"); +}); + function readHistory(ids: string[], summary: string): ConsoleLine[] { const entries = ids.flatMap((id): SessionSnapshotEntry[] => [{ kind: "tool_call", entry_id: `call-entry-${id}`, timestamp: 1, diff --git a/web/workspace/src/lib/workspace/console/run-status.test.ts b/web/workspace/src/lib/workspace/console/run-status.test.ts index 29de10947..bc8a9f989 100644 --- a/web/workspace/src/lib/workspace/console/run-status.test.ts +++ b/web/workspace/src/lib/workspace/console/run-status.test.ts @@ -45,7 +45,7 @@ Deno.test("worker metadata formats effort, budget, estimate, and unknown safely" contextTokens: 142_000, contextSource: "estimated", }), - "Context ~142.0k / 272.0k (52%)", + "~142.0k / 272.0k (52%)", ); assertEquals(formatModelSummary(null), "Model unavailable"); assertEquals( @@ -64,7 +64,7 @@ Deno.test("worker metadata formats effort, budget, estimate, and unknown safely" formatReasoning({ kind: "budget_tokens", budget_tokens: -2 }), "reasoning unavailable", ); - assertEquals(formatContextSummary(null), "Context unavailable"); + assertEquals(formatContextSummary(null), "unavailable"); assertEquals( formatContextSummary({ model: null, @@ -73,7 +73,7 @@ Deno.test("worker metadata formats effort, budget, estimate, and unknown safely" contextTokens: 0, contextSource: null, }), - "Context unavailable", + "unavailable", ); }); diff --git a/web/workspace/src/lib/workspace/console/worker-console.ui.test.ts b/web/workspace/src/lib/workspace/console/worker-console.ui.test.ts index 5ab4b36f1..abd18cdfd 100644 --- a/web/workspace/src/lib/workspace/console/worker-console.ui.test.ts +++ b/web/workspace/src/lib/workspace/console/worker-console.ui.test.ts @@ -502,7 +502,9 @@ Deno.test("workspace Memory surfaces use read-only scoped memory APIs", async () subjectIndexLoad.includes('"/subjektiv/subjects"') && subjectIndexLoad.includes("parseSubjektivSubjectListResponse") && subjectIndexPage.includes(">Subjects") && - subjectIndexPage.includes("subject.store_revision") && + subjectIndexPage.includes("Worker connection") && + subjectIndexPage.includes("subject.current_worker") && + !subjectIndexPage.includes("subject.store_revision") && subjectIndexPage.includes('data-memory-view="subjects"'), "Memory product entry should list explicit typed subjects", ); @@ -510,10 +512,13 @@ Deno.test("workspace Memory surfaces use read-only scoped memory APIs", async () subjectPageLoad.includes("`${subjectPath}/surface`") && subjectPageLoad.includes("`${subjectPath}/memories`") && subjectPage.includes("DocumentMarkdown from") && - subjectPage.includes("Resident surface") && + subjectPage.includes("Resident context") && + subjectPage.includes("Subject store revision") && subjectPage.includes("Current Memories") && subjectPage.includes("surface?.availability === 'ready'") && + subjectPage.includes("surface?.availability === 'ungenerated'") && subjectPage.includes("surface?.availability === 'stale'") && + subjectPage.includes("surface?.availability === 'failed'") && subjectPage.includes("data-surface-ready-empty") && !subjectPage.includes("overflow-y"), "Subject page should combine strict resident-surface and current-Memory reads without a nested vertical scroller", @@ -738,6 +743,32 @@ Deno.test("Worker Console renders Edit diffs without preformatted template gaps" ); }); +Deno.test("Model information stays below Composer in one compact monospace row", async () => { + const page = await Deno.readTextFile(new URL( + "./../../../routes/w/[workspaceId]/workers/[workerRef]/console/+page.svelte", + import.meta.url, + )); + const composer = page.indexOf('
", composer); + const status = page.indexOf("= 0 && composerEnd > composer && status > composerEnd, "Model information must follow Composer"); + assert(page.includes("metadata={selectedConsoleProjection.workerMetadata}"), "Keep selected Worker metadata"); + assert(page.indexOf(" status && modes < page.indexOf("", status), "Display toggle must be in the model information row"); + assert(!page.includes("console-header"), "Remove the empty header and its styles"); + assert(component.indexOf("{@render controls?.()}") < component.indexOf('class="details-button"'), "Display controls must precede Details"); + assert(/\.worker-context-actions\s*\{[^}]*flex: 0 0 auto/.test(component), "Keep display controls from shrinking on narrow screens"); + assert(component.includes("margin-left: auto"), "Align footer controls to the right"); +}); + Deno.test("Console spacing and text metrics use existing design tokens", async () => { const files = [ "ComposerInput.svelte", @@ -1551,14 +1582,10 @@ Deno.test("Composer owns TUI-style completion and replaces Compact/Rewind header const input = await Deno.readTextFile( new URL("./ComposerInput.svelte", import.meta.url), ); - const header = page.slice( - page.indexOf('
{"), "Only the mini summary should own Tasks toggling", ); diff --git a/web/workspace/src/lib/workspace/console/worker-metadata.ts b/web/workspace/src/lib/workspace/console/worker-metadata.ts index 03f9618e3..d9217cb61 100644 --- a/web/workspace/src/lib/workspace/console/worker-metadata.ts +++ b/web/workspace/src/lib/workspace/console/worker-metadata.ts @@ -36,11 +36,11 @@ export function formatContextSummary( used === null || used === undefined || limit === null || limit === undefined || !Number.isSafeInteger(used) || !Number.isSafeInteger(limit) || used < 0 || limit <= 0 - ) return "Context unavailable"; + ) return "unavailable"; const percent = Math.round((used / limit) * 100); - if (!Number.isFinite(percent)) return "Context unavailable"; + if (!Number.isFinite(percent)) return "unavailable"; const estimate = metadata?.contextSource === "estimated" ? "~" : ""; - return `Context ${estimate}${formatRunTokens(used)} / ${ + return `${estimate}${formatRunTokens(used)} / ${ formatRunTokens(limit) } (${percent}%)`; } diff --git a/web/workspace/src/lib/workspace/memory/api.ts b/web/workspace/src/lib/workspace/memory/api.ts index 65153d589..c97dea9d4 100644 --- a/web/workspace/src/lib/workspace/memory/api.ts +++ b/web/workspace/src/lib/workspace/memory/api.ts @@ -1,4 +1,7 @@ -import { MEMORY_API_LIMITS } from "#lib/generated/memory-api.ts"; +import { + MEMORY_API_LIMITS, + SUBJEKTIV_API_LIMITS, +} from "#lib/generated/memory-api.ts"; import { readBoundedJson, workspaceApiPath } from "#lib/workspace/api/http.ts"; import type { Diagnostic, @@ -26,6 +29,7 @@ import type { SubjektivResidentSurfaceAvailability, SubjektivResidentSurfaceResponse, SubjektivResidentSurfaceSnapshot, + SubjektivSubjectBehaviorUpdateRequest, SubjektivSubjectCreateRequest, SubjektivSubjectListResponse, SubjektivSubjectResponse, @@ -84,6 +88,7 @@ export const MEMORY_API_LOAD_POLICY = { } as const; const MAX_SUBJECT_ROLE_BYTES = 256; +export const MAX_SUBJECT_BEHAVIOR_BYTES = SUBJEKTIV_API_LIMITS.maxBehaviorBytes; const MAX_SUBJECT_CREATE_ERROR_BYTES = 1024 * 1024; export type SubjektivSubjectCreateErrorKind = @@ -126,7 +131,31 @@ export function validateSubjektivSubjectCreateRequest( "Subject role must be at most 256 bytes and contain no control characters.", ); } - return { role: request.role }; + const behaviorMd = request.behavior_md ?? ""; + validateSubjectBehavior(behaviorMd); + return { role: request.role, behavior_md: behaviorMd }; +} + +function validateSubjectBehavior(behaviorMd: string): void { + const bytes = new TextEncoder().encode(behaviorMd).byteLength; + if (bytes > MAX_SUBJECT_BEHAVIOR_BYTES) { + throw new SubjektivSubjectCreateError( + "validation", + `Subject behavior must be at most ${MAX_SUBJECT_BEHAVIOR_BYTES} bytes.`, + ); + } + if (behaviorMd.length > 0 && behaviorMd.trim().length === 0) { + throw new SubjektivSubjectCreateError( + "validation", + "Subject behavior must be empty or contain non-whitespace text.", + ); + } + if (/[\u0000-\u0008\u000B\u000C\u000E-\u001F\u007F]/u.test(behaviorMd)) { + throw new SubjektivSubjectCreateError( + "validation", + "Subject behavior contains an unsupported control character.", + ); + } } export async function createSubjektivSubject( @@ -170,8 +199,11 @@ export async function createSubjektivSubject( const subject = parseSubjektivSubjectResponse( await readBoundedJson(response, MEMORY_API_LIMITS.maxResponseBytes), ); - if (subject.role !== validated.role) { - throw new Error("created Subject role does not match the request"); + if ( + subject.role !== validated.role || + subject.behavior_md !== validated.behavior_md + ) { + throw new Error("created Subject does not match the request"); } return subject; } catch { @@ -182,6 +214,68 @@ export async function createSubjektivSubject( } } +export async function updateSubjektivSubjectBehavior( + fetchFn: typeof fetch, + workspaceId: string, + subjectId: string, + request: SubjektivSubjectBehaviorUpdateRequest, +): Promise { + validateSubjectBehavior(request.behavior_md); + if ( + !Number.isSafeInteger(request.expected_behavior_revision) || + request.expected_behavior_revision < 0 + ) { + throw new SubjektivSubjectCreateError( + "validation", + "Subject behavior revision is invalid.", + ); + } + let response: Response; + try { + response = await fetchFn( + workspaceApiPath( + workspaceId, + `/subjektiv/subjects/${encodeURIComponent(subjectId)}/behavior`, + ), + { + method: "PATCH", + headers: { "content-type": "application/json" }, + body: JSON.stringify(request), + }, + ); + } catch { + throw new SubjektivSubjectCreateError( + "unknown_outcome", + "The save outcome is unknown. Reload the Subject before retrying.", + ); + } + if (!response.ok) { + const message = await subjektivSubjectCreateErrorMessage(response); + throw new SubjektivSubjectCreateError( + response.status >= 500 ? "unknown_outcome" : "rejected", + response.status === 409 + ? "The behavior changed elsewhere. Reload the Subject and review the latest text." + : message, + response.status, + ); + } + try { + const subject = parseSubjektivSubjectResponse( + await readBoundedJson(response, MEMORY_API_LIMITS.maxResponseBytes), + subjectId, + ); + if (subject.behavior_md !== request.behavior_md) { + throw new Error("saved Subject behavior does not match the request"); + } + return subject; + } catch { + throw new SubjektivSubjectCreateError( + "unknown_outcome", + "The behavior may have been saved, but the response could not be confirmed. Reload the Subject.", + ); + } +} + async function subjektivSubjectCreateErrorMessage( response: Response, ): Promise { @@ -202,7 +296,7 @@ async function subjektivSubjectCreateErrorMessage( } catch { // A rejected request still has a known outcome even when its body is invalid. } - return `Subject creation failed with HTTP ${response.status}.`; + return `Subject request failed with HTTP ${response.status}.`; } export function parseMemoryDocumentResponse( @@ -454,6 +548,8 @@ export function parseSubjektivSubjectResponse( [ "id", "role", + "behavior_md", + "behavior_revision", "state", "store_revision", "created_at", @@ -472,6 +568,12 @@ export function parseSubjektivSubjectResponse( const result: SubjektivSubjectResponse = { id, role: requiredString(record, "role"), + behavior_md: requiredString( + record, + "behavior_md", + MAX_SUBJECT_BEHAVIOR_BYTES, + ), + behavior_revision: requiredNonNegativeInteger(record, "behavior_revision"), state, store_revision: requiredNonNegativeInteger(record, "store_revision"), created_at: requiredString(record, "created_at"), diff --git a/web/workspace/src/lib/workspace/settings/DecodalSourceEditor.browser.spec.ts b/web/workspace/src/lib/workspace/settings/DecodalSourceEditor.browser.spec.ts new file mode 100644 index 000000000..baf7c6a9f --- /dev/null +++ b/web/workspace/src/lib/workspace/settings/DecodalSourceEditor.browser.spec.ts @@ -0,0 +1,153 @@ +// @vitest-environment happy-dom + +import { + type CompletionResult, + completionStatus, + startCompletion, +} from "@codemirror/autocomplete"; +import { EditorView } from "@codemirror/view"; +import { cleanup, fireEvent, render, waitFor } from "@testing-library/svelte"; +import { afterEach, expect, test, vi } from "vitest"; +import DecodalSourceEditor from "./DecodalSourceEditor.svelte"; + +afterEach(cleanup); + +function setup( + value: string, + props: { + readonly?: boolean; + fixedSchemaWrapper?: boolean; + onComplete?: ( + source: string, + offset: number, + explicit: boolean, + ) => Promise; + } = {}, +) { + const onChange = vi.fn(); + const result = render(DecodalSourceEditor, { value, onChange, ...props }); + const content = result.container.querySelector(".cm-content")!; + const editor = EditorView.findFromDOM(content)!; + editor.focus(); + return { ...result, content, editor, onChange }; +} + +test.each([ + { name: "plain line", value: "name = 1", anchor: 8, expected: "name = 1\n" }, + { + name: "object body", + value: "{\n name = 1", + anchor: 12, + expected: "{\n name = 1\n ", + }, + { name: "empty object", value: "{}", anchor: 1, expected: "{\n \n}" }, + { + name: "nested object", + value: "{\n nested = {}\n}", + anchor: 14, + expected: "{\n nested = {\n \n }\n}", + }, +])( + "Enter inserts an indented newline in $name", + async ({ value, anchor, expected }) => { + const { content, editor, onChange } = setup(value); + editor.dispatch({ selection: { anchor } }); + expect(await fireEvent.keyDown(content, { key: "Enter", code: "Enter" })) + .toBe(false); + expect(editor.state.doc.toString()).toBe(expected); + expect(onChange).toHaveBeenLastCalledWith(expected); + }, +); + +test("Enter indents inside the fixed wrapper without altering its assertion", async () => { + const { content, editor } = setup("{} as WorkspaceConfigSchema\n", { + fixedSchemaWrapper: true, + }); + await fireEvent.keyDown(content, { key: "Enter", code: "Enter" }); + expect(editor.state.doc.toString()).toBe( + "{\n \n} as WorkspaceConfigSchema\n", + ); + expect(editor.state.selection.main.head).toBe(4); +}); + +test("Enter does not mutate readonly sources", async () => { + const value = "{\n name = 1"; + const { content, editor, onChange } = setup(value, { readonly: true }); + editor.dispatch({ selection: { anchor: value.length } }); + await fireEvent.keyDown(content, { key: "Enter", code: "Enter" }); + expect(editor.state.doc.toString()).toBe(value); + expect(onChange).not.toHaveBeenCalled(); +}); + +test("Enter adds an indented newline instead of accepting a visible completion", async () => { + const value = "{\n na"; + const { content, editor } = setup(value, { + onComplete: async () => ({ + from: 4, + options: [{ label: "name", type: "property" }], + }), + }); + editor.dispatch({ selection: { anchor: value.length } }); + startCompletion(editor); + await waitFor(() => expect(completionStatus(editor.state)).toBe("active")); + // Wait past CodeMirror's completion interaction delay, so accepting a + // suggestion would be observable rather than ignored as an early keypress. + await new Promise((resolve) => setTimeout(resolve, 100)); + await fireEvent.keyDown(content, { key: "Enter", code: "Enter" }); + expect(editor.state.doc.toString()).toBe("{\n na\n "); + await waitFor(() => expect(completionStatus(editor.state)).toBeNull()); +}); + +test("Tab indents the current line and Shift+Tab removes indentation", async () => { + const { content, editor, onChange } = setup("name = 1"); + editor.dispatch({ selection: { anchor: 4 } }); + expect(await fireEvent.keyDown(content, { key: "Tab", code: "Tab" })).toBe( + false, + ); + expect(editor.state.doc.toString()).toBe(" name = 1"); + expect(onChange).toHaveBeenLastCalledWith(" name = 1"); + expect(editor.hasFocus).toBe(true); + await fireEvent.keyDown(content, { key: "Tab", code: "Tab", shiftKey: true }); + expect(editor.state.doc.toString()).toBe("name = 1"); + expect(onChange).toHaveBeenLastCalledWith("name = 1"); +}); + +test("Tab and Shift+Tab indent every selected line", async () => { + const value = "first = 1\nsecond = 2"; + const { content, editor } = setup(value); + editor.dispatch({ selection: { anchor: 0, head: value.length } }); + await fireEvent.keyDown(content, { key: "Tab", code: "Tab" }); + expect(editor.state.doc.toString()).toBe(" first = 1\n second = 2"); + await fireEvent.keyDown(content, { key: "Tab", code: "Tab", shiftKey: true }); + expect(editor.state.doc.toString()).toBe(value); +}); + +test("Tab inserts indentation on a blank line", async () => { + const { content, editor } = setup(""); + await fireEvent.keyDown(content, { key: "Tab", code: "Tab" }); + expect(editor.state.doc.toString()).toBe(" "); +}); + +test("Tab and Shift+Tab do not mutate readonly sources", async () => { + const value = " name = 1"; + const { content, editor, onChange } = setup(value, { readonly: true }); + await fireEvent.keyDown(content, { key: "Tab", code: "Tab" }); + await fireEvent.keyDown(content, { key: "Tab", code: "Tab", shiftKey: true }); + expect(editor.state.doc.toString()).toBe(value); + expect(onChange).not.toHaveBeenCalled(); +}); + +test("Tab indents the schema body without modifying the fixed wrapper", async () => { + const value = "{\n name = 1\n} as WorkspaceConfigSchema\n"; + const { content, editor } = setup(value, { fixedSchemaWrapper: true }); + editor.dispatch({ selection: { anchor: value.indexOf("name") } }); + await fireEvent.keyDown(content, { key: "Tab", code: "Tab" }); + expect(editor.state.doc.toString()).toBe( + "{\n name = 1\n} as WorkspaceConfigSchema\n", + ); + await fireEvent.keyDown(content, { key: "Tab", code: "Tab", shiftKey: true }); + expect(editor.state.doc.toString()).toBe(value); + editor.dispatch({ selection: { anchor: 0 } }); + await fireEvent.keyDown(content, { key: "Tab", code: "Tab" }); + expect(editor.state.doc.toString()).toBe(value); +}); diff --git a/web/workspace/src/lib/workspace/settings/DecodalSourceEditor.svelte b/web/workspace/src/lib/workspace/settings/DecodalSourceEditor.svelte index 5fd974cec..7bb39e679 100644 --- a/web/workspace/src/lib/workspace/settings/DecodalSourceEditor.svelte +++ b/web/workspace/src/lib/workspace/settings/DecodalSourceEditor.svelte @@ -9,6 +9,7 @@ type CompletionContext, type CompletionResult, } from '@codemirror/autocomplete'; + import { indentWithTab, insertNewlineAndIndent } from '@codemirror/commands'; import { HighlightStyle, syntaxHighlighting } from '@codemirror/language'; import { Compartment, EditorState } from '@codemirror/state'; import { EditorView, keymap, lineNumbers, highlightActiveLine, drawSelection, type ViewUpdate } from '@codemirror/view'; @@ -129,6 +130,8 @@ decodal({ highlight: false }), syntaxHighlighting(syntaxTheme), autocompletion({ + // Use the explicit keymap below so Enter never accepts a suggestion. + defaultKeymap: false, activateOnTyping: false, override: [ async (context: CompletionContext) => { @@ -139,6 +142,10 @@ ], }), keymap.of(completionKeymapWithoutEnter), + keymap.of([ + { key: 'Enter', run: insertNewlineAndIndent }, + indentWithTab, + ]), fixedSchemaWrapperCompartment.of( initialFixedSchemaWrapper ? fixedSchemaWrapperExtension() : [], ), diff --git a/web/workspace/src/lib/workspace/sidebar/SidebarFrame.svelte b/web/workspace/src/lib/workspace/sidebar/SidebarFrame.svelte index 60dbd596e..760759d9e 100644 --- a/web/workspace/src/lib/workspace/sidebar/SidebarFrame.svelte +++ b/web/workspace/src/lib/workspace/sidebar/SidebarFrame.svelte @@ -1,5 +1,6 @@ diff --git a/web/workspace/src/lib/workspace/sidebar/sidebar-navigation.browser.spec.ts b/web/workspace/src/lib/workspace/sidebar/sidebar-navigation.browser.spec.ts new file mode 100644 index 000000000..db602e352 --- /dev/null +++ b/web/workspace/src/lib/workspace/sidebar/sidebar-navigation.browser.spec.ts @@ -0,0 +1,63 @@ +// @vitest-environment happy-dom + +import { cleanup, fireEvent, render } from "@testing-library/svelte"; +import { createRawSnippet } from "svelte"; +import { afterEach, beforeEach, expect, test, vi } from "vitest"; +import SidebarFrame from "./SidebarFrame.svelte"; + +// Stop happy-dom navigation after the component has handled the bubbling click. +const preventBrowserNavigation = (event: MouseEvent) => event.preventDefault(); +beforeEach(() => document.addEventListener("click", preventBrowserNavigation)); +afterEach(() => { + cleanup(); + document.removeEventListener("click", preventBrowserNavigation); +}); + +function mount(mobile = true, mode: "pinned" | "hover" = "pinned") { + const onOpenChange = vi.fn(); + const onModeChange = vi.fn(); + const children = createRawSnippet(() => ({ + render: () => ``, + })); + return { ...render(SidebarFrame, { + mobile, mode, open: true, children, onOpenChange, onModeChange, + }), onOpenChange, onModeChange }; +} + +test.each(["pinned", "hover"] as const)("mobile %s closes on nested, current-page and hash links", async (mode) => { + const view = mount(true, mode); + for (const label of ["Workspace home", "Current page", "Section"]) { + view.onOpenChange.mockClear(); + await fireEvent.click(view.getByText(label)); + expect(view.onOpenChange).toHaveBeenCalledExactlyOnceWith(false); + expect(view.onModeChange).not.toHaveBeenCalled(); + } +}); + +test("mobile ignores disclosure buttons, modified clicks, new tabs, downloads and cancelled navigation", async () => { + const view = mount(); + for (const label of ["Expand group", "New tab", "Download"]) { + await fireEvent.click(view.getByText(label)); + } + const link = view.getByText("Workspace home"); + for (const options of [{ ctrlKey: true }, { metaKey: true }, { shiftKey: true }, { altKey: true }, { button: 1 }]) { + await fireEvent.click(link, options); + } + link.addEventListener("click", (event) => event.preventDefault(), { once: true }); + await fireEvent.click(link); + expect(view.onOpenChange).not.toHaveBeenCalled(); +}); + +test.each(["pinned", "hover"] as const)("desktop %s navigation does not change sidebar mode or open state", async (mode) => { + const view = mount(false, mode); + await fireEvent.click(view.getByText("Workspace home")); + expect(view.onOpenChange).not.toHaveBeenCalled(); + expect(view.onModeChange).not.toHaveBeenCalled(); +}); diff --git a/web/workspace/src/routes/w/[workspaceId]/memory/+page.svelte b/web/workspace/src/routes/w/[workspaceId]/memory/+page.svelte index 9483ad3fc..2f10695ec 100644 --- a/web/workspace/src/routes/w/[workspaceId]/memory/+page.svelte +++ b/web/workspace/src/routes/w/[workspaceId]/memory/+page.svelte @@ -14,13 +14,16 @@ const subjects = $derived(data.subjects.data?.items ?? []); let createExpanded = $state(false); let role = $state(''); + let behaviorMd = $state(''); let submitting = $state(false); let createError = $state(null); let roleInvalid = $state(false); + let behaviorInvalid = $state(false); let createOutcomeUnknown = $state(false); let createdSubject = $state(null); let createButton = $state(); let roleInput = $state(); + let behaviorInput = $state(); function subjectHref(subjectId: string): string { return workspaceRoute(data.workspaceId, `/memory/${encodeURIComponent(subjectId)}`); @@ -31,10 +34,15 @@ return cursor ? `${path}?cursor=${encodeURIComponent(cursor)}` : path; } + function subjectStateLabel(state: SubjektivSubjectResponse['state']): string { + return state === 'active' ? 'Available' : 'Retired'; + } + async function openCreateForm(): Promise { createExpanded = true; createError = null; roleInvalid = false; + behaviorInvalid = false; createOutcomeUnknown = false; createdSubject = null; await tick(); @@ -45,8 +53,10 @@ if (submitting) return; createExpanded = false; role = ''; + behaviorMd = ''; createError = null; roleInvalid = false; + behaviorInvalid = false; createOutcomeUnknown = false; createdSubject = null; await tick(); @@ -58,9 +68,13 @@ submitting = true; createError = null; roleInvalid = false; + behaviorInvalid = false; createOutcomeUnknown = false; try { - const subject = await createSubjektivSubject(fetch, data.workspaceId, { role }); + const subject = await createSubjektivSubject(fetch, data.workspaceId, { + role, + behavior_md: behaviorMd, + }); createdSubject = subject; try { await goto(subjectHref(subject.id)); @@ -69,14 +83,16 @@ } } catch (error) { if (error instanceof SubjektivSubjectCreateError) { - roleInvalid = error.kind === 'validation'; + roleInvalid = error.kind === 'validation' && error.message.startsWith('Subject role'); + behaviorInvalid = error.kind === 'validation' && error.message.startsWith('Subject behavior'); createOutcomeUnknown = error.kind === 'unknown_outcome'; createError = error.status === 401 || error.status === 403 ? 'You do not have permission to create Subjects in this Workspace.' : error.message; - if (roleInvalid) { + if (roleInvalid || behaviorInvalid) { await tick(); - roleInput?.focus(); + if (behaviorInvalid) behaviorInput?.focus(); + else roleInput?.focus(); } } else { createOutcomeUnknown = true; @@ -109,7 +125,7 @@
{#if data.subjects.data} - {subjects.length}{data.subjects.data.has_more ? '+' : ''} subject{subjects.length === 1 ? '' : 's'} + {subjects.length} shown{data.subjects.data.has_more ? ' · more available' : ''} {/if} + {/if} + +

The Host injects this text verbatim for connected Workers, separately from generated Memory. Saving confirms storage only. Connected Workers check for the latest revision before later model requests; this page does not report that application.

+ {#if editingBehavior} + { event.preventDefault(); void saveBehavior(); }}> + + {#if behaviorError}{/if} +
+ + + +
+ + {:else if subject.behavior_md.length > 0} +
{subject.behavior_md}
+ {:else} +
No behavior is set.

Connected Workers receive an explicit empty behavior document.

+ {/if} + {#if behaviorSaveConfirmed}

Behavior storage confirmed at revision {subject.behavior_revision}. This page does not confirm application by a connected Worker.

{/if} +
+ {/if} +
-

Resident context

-

Resident surface

+

Generated from committed Memory

+

Resident context

{#if surface} - {surface.availability} + {surfaceLabel(surface.availability)} {/if}
{#if surface?.availability === 'ready' && snapshot}
- Built from store revision {snapshot.built_from_store_revision} + Generated - - - {snapshot.memory_refs.length} Memory ref{snapshot.memory_refs.length === 1 ? '' : 's'} + {snapshot.memory_refs.length} referenced Memory {snapshot.memory_refs.length === 1 ? 'record' : 'records'}
{#if snapshot.body_md.trim().length === 0}
- Resident surface is ready and empty. -

This subject currently has no resident context to display.

+ Resident context is current but empty. +

A generated surface exists, but it contains no context to show. Committed Memories, if any, remain listed below.

{:else} -
+
{/if}
- Surface provenance + Surface sources and diagnostics
-
Snapshot id
{snapshot.snapshot_id}
+
Snapshot ID
{snapshot.snapshot_id}
-
Memory refs
+
Generated from Subject revision
+
{snapshot.built_from_store_revision}The Subject change number used for this surface, distinct from each Memory’s revision.
+
+
+
Referenced Memory revisions
{#if snapshot.memory_refs.length === 0} None @@ -123,26 +294,26 @@
{:else if surface?.availability === 'ungenerated'}
- Resident surface has not been generated. -

Committed Memories remain available below.

+ Resident context has not been generated. +

Committed Memories, if any, remain available below.

{:else if surface?.availability === 'stale'}
- Resident surface is stale. -

The latest committed Memories are not represented by a publishable snapshot.

+ Resident context needs to be refreshed. +

The latest committed Memories are not represented by a current surface, so no generated context is shown.

{:else if surface?.availability === 'failed'} {:else if data.surface.error} {:else} -

Resident surface data is unavailable.

+

Resident context status is unavailable.

{/if}
@@ -152,14 +323,14 @@

Committed records

Current Memories

- {#if data.memories.data}{memories.length}{data.memories.data.has_more ? '+' : ''}{/if} + {#if data.memories.data}{memories.length} shown{data.memories.data.has_more ? ' · more available' : ''}{/if} {#if data.memories.data} {#if memories.length === 0}
- No committed Memories. -

This subject has no current Memory revisions.

+ {data.cursor ? 'No committed Memories on this page.' : 'No committed Memories yet.'} +

{data.cursor ? 'Return to the first page to review earlier records.' : 'This Subject has no current Memory records.'}

{#if data.cursor}

Return to the first page

{/if}
{:else} @@ -175,9 +346,9 @@

{memory.excerpt || 'No excerpt.'}

-
Memory id
{memory.id}
-
Revision
{memory.revision}
-
Updated
+
Memory ID
{memory.id}
+
Memory revision
{memory.revision}
+
Last changed
{/each} @@ -270,25 +441,79 @@ font-size: var(--font-size-compact); } - .subject-facts { - grid-template-columns: repeat(5, minmax(0, 1fr)); - gap: var(--space-3); + .subject-overview { + display: grid; + grid-template-columns: repeat(4, minmax(0, 1fr)); + gap: var(--space-4); + padding: var(--space-4); + background: var(--bg-raised); + border-radius: var(--radius-soft); } - .subject-facts > div { - display: block; + .subject-overview > div { + display: grid; + align-content: start; + gap: var(--space-1); + min-width: 0; } - .subject-facts dt { - white-space: normal; + .subject-overview span, + .subject-overview p, + .subject-technical-details summary, + .subject-technical-details small, + .surface-details small { + color: var(--text-muted); + font-size: var(--font-size-compact); } - .subject-facts dd { - margin-top: var(--space-1); - color: var(--text); + .subject-overview strong { + color: var(--text-strong); overflow-wrap: anywhere; } + .subject-overview p { + margin: 0; + overflow-wrap: anywhere; + } + + .subject-technical-details { + padding-bottom: var(--space-3); + border-bottom: 1px solid var(--line); + } + + .subject-technical-details summary { + width: fit-content; + cursor: pointer; + font-weight: 700; + } + + .subject-technical-details dl, + .surface-details dl { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: var(--space-4); + margin-top: var(--space-3); + } + + .subject-technical-details dl > div, + .surface-details dl > div { + display: block; + min-width: 0; + } + + .subject-technical-details dd, + .surface-details dd { + margin-top: var(--space-1); + overflow-wrap: anywhere; + } + + .subject-technical-details small, + .surface-details small { + display: block; + margin-top: var(--space-1); + line-height: 1.4; + } + .subject-state, .availability, .memory-pill, @@ -320,6 +545,7 @@ color: var(--danger); } + .behavior-section, .surface-section, .memories-section { display: grid; @@ -328,6 +554,78 @@ padding-top: var(--space-3); } + .behavior-help, + .behavior-saved, + .behavior-error { + margin: 0; + color: var(--text-muted); + font-size: var(--font-size-compact); + } + + .behavior-error { color: var(--danger); } + .behavior-saved { color: var(--success); } + + .behavior-form, + .behavior-form label { + display: grid; + gap: var(--space-3); + } + + .behavior-form textarea { + width: 100%; + min-height: 10rem; + padding: var(--space-3); + color: var(--text); + background: var(--surface-raised); + border: 1px solid var(--line-strong); + border-radius: var(--radius-sm); + font: inherit; + line-height: 1.5; + resize: vertical; + } + + .behavior-actions { + display: flex; + flex-wrap: wrap; + gap: var(--space-2); + } + + .behavior-edit, + .behavior-actions button { + padding: var(--space-2) var(--space-3); + color: var(--text); + background: var(--surface-raised); + border: 1px solid var(--line-strong); + border-radius: var(--radius-sm); + cursor: pointer; + } + + .behavior-actions button:disabled { cursor: not-allowed; opacity: 0.55; } + + .behavior-document pre { + margin: 0; + padding: var(--space-4); + overflow-wrap: anywhere; + white-space: pre-wrap; + background: var(--surface-raised); + border: 1px solid var(--line); + border-radius: var(--radius-sm); + font: inherit; + line-height: 1.6; + } + + .sr-only { + position: absolute; + width: 1px; + height: 1px; + padding: 0; + margin: -1px; + overflow: hidden; + clip: rect(0, 0, 0, 0); + white-space: nowrap; + border: 0; + } + .surface-meta, .pill-row { display: flex; @@ -455,7 +753,7 @@ } @media (max-width: 900px) { - .subject-facts { + .subject-overview { grid-template-columns: repeat(2, minmax(0, 1fr)); } @@ -472,10 +770,16 @@ gap: var(--space-2); } - .subject-facts { + .subject-overview, + .subject-technical-details dl, + .surface-details dl { grid-template-columns: minmax(0, 1fr); } + .subject-overview { + padding: var(--space-3); + } + .memory-row { padding-inline: 0; } diff --git a/web/workspace/src/routes/w/[workspaceId]/memory/[subjectId]/[memoryId]/+page.svelte b/web/workspace/src/routes/w/[workspaceId]/memory/[subjectId]/[memoryId]/+page.svelte index 07a1108fe..71c038627 100644 --- a/web/workspace/src/routes/w/[workspaceId]/memory/[subjectId]/[memoryId]/+page.svelte +++ b/web/workspace/src/routes/w/[workspaceId]/memory/[subjectId]/[memoryId]/+page.svelte @@ -63,6 +63,11 @@ return value.replaceAll('_', ' '); } + function titleLabel(value: string): string { + const normalized = label(value); + return normalized[0].toUpperCase() + normalized.slice(1); + } + function rangeLabel(range: [number, number] | null | undefined): string { return range ? `${range[0]}–${range[1]}` : 'None'; } @@ -98,8 +103,8 @@
- {memory.state} - {label(memory.kind)} + {titleLabel(memory.state)} + {titleLabel(memory.kind)} {#if memory.revision !== memory.current_revision}Historical revision{/if}

{memory.claim}

@@ -109,11 +114,11 @@
-
Revision
{memory.revision} of {memory.current_revision}
-
State
{memory.state}
-
Kind
{label(memory.kind)}
+
Memory revision
{memory.revision} of {memory.current_revision}
+
Status
{titleLabel(memory.state)}
+
Type
{titleLabel(memory.kind)}
Created
-
Updated
+
Last changed
@@ -140,7 +145,7 @@

Audit context

Candidate provenance

- {memory.source_candidates.length} candidate{memory.source_candidates.length === 1 ? '' : 's'} + {memory.source_candidates.length} shown
{#if memory.source_candidates.length === 0} @@ -226,7 +231,11 @@
-

Immutable record

Revision history

{#if data.revisions.data}{revisions.length}{data.revisions.data.has_more ? '+' : ''}{/if}
+
+

Immutable record

Revision history

+ {#if data.revisions.data}{revisions.length} shown{data.revisions.data.has_more ? ' · more available' : ''}{/if} +
+

These are versions of this Memory record, separate from the Subject’s internal update number.

{#if data.revisions.data} {#if revisions.length === 0}
@@ -238,7 +247,7 @@ {#each revisions as revision (revision.revision)}
  • -
    Revision {revision.revision}{revision.state}{label(revision.kind)}
    +
    Memory revision {revision.revision}{titleLabel(revision.state)}{titleLabel(revision.kind)}

    {revision.claim}

    {revision.change_reason} ·
    @@ -386,6 +395,7 @@ } .memory-facts { + display: grid; grid-template-columns: repeat(5, minmax(0, 1fr)); gap: var(--space-3); } @@ -414,6 +424,7 @@ .why-useful, .staleness, .change-reason, + .section-intro, .empty-copy, .bounded-note { margin: 0; diff --git a/web/workspace/src/routes/w/[workspaceId]/memory/memory-page.browser.spec.ts b/web/workspace/src/routes/w/[workspaceId]/memory/memory-page.browser.spec.ts index cc767fa43..5166dc3b0 100644 --- a/web/workspace/src/routes/w/[workspaceId]/memory/memory-page.browser.spec.ts +++ b/web/workspace/src/routes/w/[workspaceId]/memory/memory-page.browser.spec.ts @@ -34,6 +34,8 @@ function subject() { return { id: "subject-1", role: "Release coordinator", + behavior_md: "Prefer explicit evidence.", + behavior_revision: 2, state: "active" as const, store_revision: 9, created_at: "2026-01-01T00:00:00Z", @@ -73,6 +75,9 @@ test("renders the subject index as the Memory product entry", () => { expect(screen.getByRole("link", { name: /Release coordinator/ })).not .toBeNull(); expect(screen.getByText("subject-1")).not.toBeNull(); + expect(screen.getByText("Not connected")).not.toBeNull(); + expect(screen.queryByText("Store revision", { exact: true })).toBeNull(); + expect(screen.getByText("1 shown · more available")).not.toBeNull(); expect(screen.getByRole("link", { name: "First page" }).getAttribute("href")) .toBe("/w/workspace-1/memory"); expect(screen.getByRole("link", { name: /Next page/ }).getAttribute("href")) @@ -96,6 +101,12 @@ test("creates one Subject with the exact role and navigates to its generated det }) as HTMLInputElement; await waitFor(() => expect(document.activeElement).toBe(roleInput)); await fireEvent.input(roleInput, { target: { value: " Review lead " } }); + const behaviorInput = screen.getByRole("textbox", { + name: /Behavior/, + }) as HTMLTextAreaElement; + await fireEvent.input(behaviorInput, { + target: { value: "Challenge unsupported assumptions." }, + }); const submit = screen.getByRole("button", { name: "Create Subject" }); await fireEvent.click(submit); @@ -110,6 +121,8 @@ test("creates one Subject with the exact role and navigates to its generated det resolveRequest(Response.json({ id: "generated-subject-1", role: " Review lead ", + behavior_md: "Challenge unsupported assumptions.", + behavior_revision: 0, state: "active", store_revision: 0, created_at: "2026-01-03T00:00:00Z", @@ -127,7 +140,9 @@ test("creates one Subject with the exact role and navigates to its generated det ]; expect(path).toBe("/api/w/workspace-1/subjektiv/subjects"); expect(init.method).toBe("POST"); - expect(init.body).toBe('{"role":" Review lead "}'); + expect(init.body).toBe( + '{"role":" Review lead ","behavior_md":"Challenge unsupported assumptions."}', + ); }); test("keeps creation available for an empty list and restores focus on cancel", async () => { @@ -146,7 +161,7 @@ test("keeps creation available for an empty list and restores focus on cancel", expect(screen.queryByRole("textbox", { name: "Role" })).toBeNull(); }); -test("preserves invalid and rejected role drafts with accessible errors", async () => { +test("preserves invalid and rejected Subject drafts with accessible field errors", async () => { const fetchMock = vi.fn(() => Promise.resolve(Response.json( { @@ -172,6 +187,20 @@ test("preserves invalid and rejected role drafts with accessible errors", async expect(fetchMock).not.toHaveBeenCalled(); await fireEvent.input(input, { target: { value: "Release reviewer" } }); + const behaviorInput = screen.getByRole("textbox", { + name: /Behavior/, + }) as HTMLTextAreaElement; + await fireEvent.input(behaviorInput, { target: { value: " " } }); + await fireEvent.click(screen.getByRole("button", { name: "Create Subject" })); + expect(screen.getByRole("alert").textContent).toContain( + "empty or contain non-whitespace", + ); + expect(behaviorInput.getAttribute("aria-invalid")).toBe("true"); + expect(fetchMock).not.toHaveBeenCalled(); + + await fireEvent.input(behaviorInput, { + target: { value: "Challenge unsupported assumptions." }, + }); await fireEvent.click(screen.getByRole("button", { name: "Create Subject" })); await waitFor(() => { expect(screen.getByRole("alert").textContent).toContain( @@ -214,7 +243,10 @@ test("renders a ready resident surface and committed Memory lifecycle states", a workspaceId: "workspace-1", subjectId: "subject-1", cursor: "memory-current", - subject: result(subject()), + subject: result({ + ...subject(), + current_worker: { display_name: "Release Worker" }, + }), surface: result({ subject_id: "subject-1", availability: "ready" as const, @@ -267,9 +299,20 @@ test("renders a ready resident surface and committed Memory lifecycle states", a expect(screen.getByRole("heading", { name: "Resident context", level: 1 })) .not.toBeNull(); }); - expect(screen.getByRole("article", { name: "Resident surface" })).not + expect( + screen.getByRole("article", { name: "User-managed Subject behavior" }) + .textContent, + ) + .toContain("Prefer explicit evidence."); + expect(screen.getByRole("article", { name: "Resident context" })).not .toBeNull(); - expect(screen.getAllByText("active", { exact: true }).length).toBeGreaterThan( + expect(screen.getByText("Connected", { exact: true })).not.toBeNull(); + expect(screen.getByText("Release Worker", { exact: true })).not.toBeNull(); + expect(screen.getByText("3 on this page", { exact: true })).not.toBeNull(); + expect(screen.getByText("Subject store revision", { exact: true })).not + .toBeNull(); + expect(screen.getByText(/not a Memory count/)).not.toBeNull(); + expect(screen.getAllByText("Active", { exact: true }).length).toBeGreaterThan( 0, ); expect(screen.getByText("Resolved", { exact: true })).not.toBeNull(); @@ -280,6 +323,359 @@ test("renders a ready resident surface and committed Memory lifecycle states", a .toContain("cursor=memory-next"); }); +test("edits and clears user-managed behavior with CAS while distinguishing save from Worker application", async () => { + const fetchMock = vi.fn(async (_path: string, init?: RequestInit) => { + const request = JSON.parse(String(init?.body)); + return Response.json({ + ...subject(), + behavior_md: request.behavior_md, + behavior_revision: request.expected_behavior_revision + 1, + }); + }); + vi.stubGlobal("fetch", fetchMock); + render(SubjectPage, { + data: { + workspaceId: "workspace-1", + subjectId: "subject-1", + subject: result(subject()), + surface: result({ + subject_id: "subject-1", + availability: "ungenerated" as const, + }), + memories: result({ items: [], has_more: false }), + }, + } as never); + + await fireEvent.click(screen.getByRole("button", { name: "Edit" })); + const textarea = screen.getByRole("textbox", { name: "Subject behavior" }); + await fireEvent.input(textarea, { + target: { value: "Ask before irreversible actions." }, + }); + await fireEvent.click(screen.getByRole("button", { name: "Save behavior" })); + await waitFor(() => expect(fetchMock).toHaveBeenCalledTimes(1)); + const [path, init] = fetchMock.mock.calls[0] as unknown as [ + string, + RequestInit, + ]; + expect(path).toBe("/api/w/workspace-1/subjektiv/subjects/subject-1/behavior"); + expect(init.method).toBe("PATCH"); + expect(init.body).toBe( + '{"expected_behavior_revision":2,"behavior_md":"Ask before irreversible actions."}', + ); + await waitFor(() => { + expect( + screen.getByText(/Behavior storage confirmed at revision 3/).textContent, + ) + .toContain("does not confirm application"); + }); + expect( + screen.getByRole("article", { name: "User-managed Subject behavior" }) + .textContent, + ) + .toContain("Ask before irreversible actions."); + + await fireEvent.click(screen.getByRole("button", { name: "Edit" })); + await fireEvent.click(screen.getByRole("button", { name: "Clear" })); + await fireEvent.click(screen.getByRole("button", { name: "Save behavior" })); + await waitFor(() => expect(fetchMock).toHaveBeenCalledTimes(2)); + const secondBody = + (fetchMock.mock.calls[1] as unknown as [string, RequestInit])[1].body; + expect(secondBody).toBe('{"expected_behavior_revision":3,"behavior_md":""}'); + await waitFor(() => + expect(screen.getByText("No behavior is set.")).not.toBeNull() + ); +}); + +function subjectPageData(record = subject(), workspaceId = "workspace-1") { + return { + workspaceId, + subjectId: record.id, + subject: result(record), + surface: result({ + subject_id: record.id, + availability: "ungenerated" as const, + }), + memories: result({ items: [], has_more: false }), + }; +} + +test.each([ + ["subject-2", "workspace-1"], + ["subject-1", "workspace-2"], +])( + "resets reused Subject editor for %s in %s and saves only its own behavior", + async (id, workspaceId) => { + const nextSubject = { ...subject(), id, behavior_md: "Only B behavior." }; + const fetchMock = vi.fn(async (_path: string, init?: RequestInit) => { + const request = JSON.parse(String(init?.body)); + return Response.json({ + ...nextSubject, + behavior_md: request.behavior_md, + behavior_revision: request.expected_behavior_revision + 1, + }); + }); + vi.stubGlobal("fetch", fetchMock); + const view = render(SubjectPage, { data: subjectPageData() } as never); + await fireEvent.click(screen.getByRole("button", { name: "Edit" })); + await fireEvent.input( + screen.getByRole("textbox", { name: "Subject behavior" }), + { + target: { value: "Unsaved A draft" }, + }, + ); + + await view.rerender( + { data: subjectPageData(nextSubject, workspaceId) } as never, + ); + expect(screen.queryByRole("textbox", { name: "Subject behavior" })) + .toBeNull(); + expect( + screen.getByRole("article", { name: "User-managed Subject behavior" }) + .textContent, + ) + .toContain("Only B behavior."); + await fireEvent.click(screen.getByRole("button", { name: "Edit" })); + expect( + (screen.getByRole("textbox", { + name: "Subject behavior", + }) as HTMLTextAreaElement).value, + ) + .toBe("Only B behavior."); + await fireEvent.click( + screen.getByRole("button", { name: "Save behavior" }), + ); + await waitFor(() => expect(fetchMock).toHaveBeenCalledTimes(1)); + const [path, init] = fetchMock.mock.calls[0] as unknown as [ + string, + RequestInit, + ]; + expect(path).toBe( + `/api/w/${workspaceId}/subjektiv/subjects/${id}/behavior`, + ); + expect(JSON.parse(String(init.body))).toEqual({ + expected_behavior_revision: 2, + behavior_md: "Only B behavior.", + }); + await waitFor(() => + expect(screen.getByText(/storage confirmed at revision 3/)).not.toBeNull() + ); + }, +); + +test.each(["success", "failure"])( + "fences late %s from a previous Subject without clearing the new save state", + async (outcome) => { + const pending: Array<(response: Response) => void> = []; + const fetchMock = vi.fn(() => + new Promise((resolve) => pending.push(resolve)) + ); + vi.stubGlobal("fetch", fetchMock); + const view = render(SubjectPage, { data: subjectPageData() } as never); + await fireEvent.click(screen.getByRole("button", { name: "Edit" })); + await fireEvent.click( + screen.getByRole("button", { name: "Save behavior" }), + ); + await waitFor(() => expect(fetchMock).toHaveBeenCalledTimes(1)); + + const nextSubject = { + ...subject(), + id: "subject-2", + behavior_md: "Only B behavior.", + }; + await view.rerender({ data: subjectPageData(nextSubject) } as never); + await fireEvent.click(screen.getByRole("button", { name: "Edit" })); + await fireEvent.input( + screen.getByRole("textbox", { name: "Subject behavior" }), + { + target: { value: "Updated B behavior." }, + }, + ); + await fireEvent.click( + screen.getByRole("button", { name: "Save behavior" }), + ); + await waitFor(() => expect(fetchMock).toHaveBeenCalledTimes(2)); + pending[0]( + outcome === "success" + ? Response.json({ + ...subject(), + behavior_md: "Late A response", + behavior_revision: 3, + }) + : Response.json({ + error: "Forbidden", + message: "A denied", + diagnostics: [], + }, { status: 403 }), + ); + // Drain the previous response through its success/catch/finally branches. + await new Promise((resolve) => setTimeout(resolve, 0)); + expect( + screen.getByRole("button", { name: "Saving…" }).hasAttribute("disabled"), + ).toBe(true); + expect( + (screen.getByRole("textbox", { + name: "Subject behavior", + }) as HTMLTextAreaElement).value, + ) + .toBe("Updated B behavior."); + expect(screen.queryByRole("alert")).toBeNull(); + expect(screen.queryByText(/storage confirmed/)).toBeNull(); + pending[1]( + Response.json({ + ...nextSubject, + behavior_md: "Updated B behavior.", + behavior_revision: 3, + }), + ); + await waitFor(() => + expect(screen.getByText(/storage confirmed at revision 3/)).not.toBeNull() + ); + expect( + screen.getByRole("article", { name: "User-managed Subject behavior" }) + .textContent, + ) + .toContain("Updated B behavior."); + }, +); + +test("a refreshed loader snapshot fences old saves even after returning to the same Subject", async () => { + let resolveRequest!: (response: Response) => void; + vi.stubGlobal( + "fetch", + vi.fn(() => + new Promise((resolve) => { + resolveRequest = resolve; + }) + ), + ); + const view = render(SubjectPage, { data: subjectPageData() } as never); + await fireEvent.click(screen.getByRole("button", { name: "Edit" })); + await fireEvent.click(screen.getByRole("button", { name: "Save behavior" })); + await view.rerender( + { data: subjectPageData({ ...subject(), id: "subject-2" }) } as never, + ); + const fresh = { + ...subject(), + behavior_md: "Fresh A loader", + behavior_revision: 4, + }; + await view.rerender({ data: subjectPageData(fresh) } as never); + resolveRequest( + Response.json({ + ...subject(), + behavior_md: "Stale A response", + behavior_revision: 3, + }), + ); + await new Promise((resolve) => setTimeout(resolve, 0)); + expect( + screen.getByRole("article", { name: "User-managed Subject behavior" }) + .textContent, + ) + .toContain("Fresh A loader"); + expect(screen.queryByText(/storage confirmed/)).toBeNull(); + + await view.rerender( + { + data: { + ...subjectPageData(), + subject: result(null, "Subject unavailable"), + }, + } as never, + ); + expect(screen.queryByRole("button", { name: "Edit" })).toBeNull(); + expect( + screen.queryByRole("article", { name: "User-managed Subject behavior" }), + ).toBeNull(); +}); + +test("candidate-only Subjects show no committed Memories, not an inferred candidate count", () => { + // The store regression candidate_decisions_reject_conflicts_and_roll_back_partial_writes + // stages a candidate without committing a Memory. Its public read projection is + // the same empty committed list as a new Subject; candidates are not a page input. + render(SubjectPage, { + data: { + workspaceId: "workspace-1", + subjectId: "subject-1", + cursor: null, + subject: result({ ...subject(), store_revision: 0 }), + surface: result({ subject_id: "subject-1", availability: "ungenerated" }), + memories: result({ limit: 100, items: [], has_more: false }), + }, + } as never); + + expect(screen.getByText("None", { exact: true })).not.toBeNull(); + expect(screen.getByText("No committed Memories yet.")).not.toBeNull(); + expect(screen.getByText("Not connected", { exact: true })).not.toBeNull(); + expect(screen.getAllByText("Not generated", { exact: true })).toHaveLength(2); + expect(screen.queryByRole("article", { name: "Resident context" })) + .toBeNull(); + expect( + screen.queryByText( + /candidate count|all Memories lost|automatically updating/i, + ), + ).toBeNull(); +}); + +test.each(["ready", "ungenerated", "stale", "failed"] as const)( + "user behavior and diagnostic disclosures stay independent of %s generated Memory", + async (availability) => { + const record = { ...subject(), store_revision: 0 }; + const view = render(SubjectPage, { + data: { + ...subjectPageData(record), + surface: result({ + subject_id: record.id, + availability, + ...(availability === "ready" + ? { + snapshot: { + snapshot_id: "empty-snapshot", + body_md: "", + memory_refs: [], + built_from_store_revision: 0, + created_at: "2026-01-02T03:04:05Z", + }, + } + : {}), + }), + }, + } as never); + expect( + screen.getByRole("article", { name: "User-managed Subject behavior" }) + .textContent, + ) + .toContain(record.behavior_md); + expect(screen.getByText("Generated Memory context", { exact: true })).not + .toBeNull(); + expect(screen.getByText("No committed Memories yet.")).not.toBeNull(); + expect(screen.getByText("Not connected", { exact: true })).not.toBeNull(); + const technical = view.container.querySelector( + "details.subject-technical-details", + ) as HTMLDetailsElement; + expect(technical.open).toBe(false); + expect(technical.textContent).toContain("Subject store revision"); + if (availability === "ready") { + expect(screen.getByText("Resident context is current but empty.")).not + .toBeNull(); + expect(screen.getByText(/no generated Memory context to show/)).not + .toBeNull(); + expect( + view.container.querySelector("details.surface-details")?.textContent, + ) + .toContain("Generated from Subject revision"); + } + await fireEvent.click(screen.getByRole("button", { name: "Edit" })); + await fireEvent.click(screen.getByRole("button", { name: "Cancel" })); + expect( + screen.getByRole("article", { name: "User-managed Subject behavior" }) + .textContent, + ) + .toContain(record.behavior_md); + expect(technical.open).toBe(false); + }, +); + test("distinguishes ready-empty, stale, failed, unavailable, and request error surfaces", async () => { const base = { workspaceId: "workspace-1", @@ -303,8 +699,25 @@ test("distinguishes ready-empty, stale, failed, unavailable, and request error s }), }, } as never); - expect(screen.getByText("Resident surface is ready and empty.")).not + expect(screen.getByText("Resident context is current but empty.")).not .toBeNull(); + expect(screen.getByText(/current empty surface exists/)).not.toBeNull(); + expect(screen.getByText("No committed Memories yet.")).not.toBeNull(); + + await view.rerender( + { + data: { + ...base, + surface: result({ + subject_id: "subject-1", + availability: "ungenerated" as const, + }), + }, + } as never, + ); + expect(screen.getByText("Resident context has not been generated.")).not + .toBeNull(); + expect(screen.getAllByText("Not generated", { exact: true })).toHaveLength(2); await view.rerender( { @@ -317,7 +730,8 @@ test("distinguishes ready-empty, stale, failed, unavailable, and request error s }, } as never, ); - expect(screen.getByText("Resident surface is stale.")).not.toBeNull(); + expect(screen.getByText("Resident context needs to be refreshed.")).not + .toBeNull(); await view.rerender( { @@ -333,7 +747,7 @@ test("distinguishes ready-empty, stale, failed, unavailable, and request error s expect(screen.getByRole("alert").textContent).toContain("generation failed"); await view.rerender({ data: { ...base, surface: result(null) } } as never); - expect(screen.getByText("Resident surface data is unavailable.")).not + expect(screen.getByText("Resident context status is unavailable.")).not .toBeNull(); await view.rerender( @@ -342,7 +756,7 @@ test("distinguishes ready-empty, stale, failed, unavailable, and request error s } as never, ); expect(screen.getByRole("alert").textContent).toContain( - "Resident surface unavailable", + "Resident context status unavailable", ); }); diff --git a/web/workspace/src/routes/w/[workspaceId]/workers/[workerRef]/console/+page.svelte b/web/workspace/src/routes/w/[workspaceId]/workers/[workerRef]/console/+page.svelte index f46361f8b..423a10f6c 100644 --- a/web/workspace/src/routes/w/[workspaceId]/workers/[workerRef]/console/+page.svelte +++ b/web/workspace/src/routes/w/[workspaceId]/workers/[workerRef]/console/+page.svelte @@ -1859,45 +1859,6 @@
    -
    - -
    -
    - - -
    - -
    -
    - {#if rewindTargets.length > 0}

    Rewind targets

    @@ -2324,6 +2285,28 @@
  • + { + workerDetailsOpen = !workerDetailsOpen; + if (workerDetailsOpen) taskPaneOpen = false; + }} + > + {#snippet controls()} + + {/snippet} + diff --git a/web/workspace/src/routes/w/[workspaceId]/workers/[workerRef]/console/console-display.browser.spec.ts b/web/workspace/src/routes/w/[workspaceId]/workers/[workerRef]/console/console-display.browser.spec.ts index c3ceb7631..bcf4eec34 100644 --- a/web/workspace/src/routes/w/[workspaceId]/workers/[workerRef]/console/console-display.browser.spec.ts +++ b/web/workspace/src/routes/w/[workspaceId]/workers/[workerRef]/console/console-display.browser.spec.ts @@ -530,13 +530,28 @@ test.each(["live_protocol", "retained_snapshot"] as const)("overlapping history await screen.findByText("Start of conversation"); expect(view.container.querySelectorAll(".activity-summary")).toHaveLength(1); expect(screen.getAllByText("searched 1 time・ran 1 command")).toHaveLength(1); - await fireEvent.click(screen.getByRole("button", { name: "Normal" })); + const footer = screen.getByLabelText("Worker model and context"); + const overview = screen.getByRole("switch", { name: "Overview" }); + expect(screen.queryByRole("button", { name: "Normal" })).toBeNull(); + const details = screen.getByRole("button", { name: "Details" }); + expect(view.container.querySelector(".console-header")).toBeNull(); + expect(footer.contains(overview)).toBe(true); + expect(overview.nextElementSibling).toBe(details); + expect(overview.closest("form")).toBeNull(); + expect(view.container.querySelector(".console-composer")?.nextElementSibling).toBe(footer); + expect(overview.getAttribute("aria-checked")).toBe("true"); + await fireEvent.click(overview); + expect(overview.getAttribute("aria-checked")).toBe("false"); + expect(overview.textContent?.trim()).toBe("Overview"); const transcript = screen.getByRole("article", { name: "main transcript" }); const ids = [...transcript.querySelectorAll("[data-console-line-id]")].map((line) => line.getAttribute("data-console-line-id")); expect(ids).toHaveLength(3); expect(new Set(ids).size).toBe(ids.length); - await fireEvent.click(screen.getByRole("button", { name: "Overview" })); + await fireEvent.click(overview); + expect(overview.getAttribute("aria-checked")).toBe("true"); + expect(overview.textContent?.trim()).toBe("Overview"); expect(view.container.querySelectorAll(".activity-summary")).toHaveLength(1); + expect(multiplexer.sendWorkerMethod).not.toHaveBeenCalled(); }); test.each([ @@ -744,7 +759,7 @@ test("mini task summary opens and closes details without a header Tasks button", latestListener().onFrame(subscribedFrame(session)); const summary = await screen.findByRole("button", { name: /1 task — pending/ }); expect(screen.queryByRole("complementary", { name: "Worker tasks" })).toBeNull(); - expect(view.container.querySelector(".console-header")?.textContent).not.toContain("Tasks"); + expect(view.container.querySelector(".console-header")).toBeNull(); await fireEvent.click(screen.getByRole("button", { name: "Details" })); const details = await screen.findByRole("complementary", { name: "Worker detail" }); expect(details.textContent).not.toContain("Capabilities"); diff --git a/web/workspace/test/config-source/api.test.ts b/web/workspace/test/config-source/api.test.ts index 6c5771099..d0269c215 100644 --- a/web/workspace/test/config-source/api.test.ts +++ b/web/workspace/test/config-source/api.test.ts @@ -71,18 +71,105 @@ Deno.test("config source API commits directly through the workspace scope", asyn entrypoints: [], }, fetcher); - assertEquals(calls.map((call) => call.url), [ + assertEquals(calls.map((call) => call.url), [ "/api/w/w%2Fone/config/source-tree", "/api/w/w%2Fone/config/source-tree/revisions/7", "/api/w/w%2Fone/config/source-tree/entries/profiles%2Fmain.dcdl", "/api/w/w%2Fone/config/source-tree/commit", ]); - assertEquals(calls[3].init?.method, "POST"); + assertEquals(calls[3].init?.method, "POST"); assert( String(calls[3].init?.body).includes('"base_digest":"sha256:base"'), ); }); +function withSchema(source: unknown, contributionSource: unknown = "builtin") { + return { + ...tree, + contract: { + ...tree.contract, + schema_bundle: { + ...tree.contract.schema_bundle, + source, + contributions: [{ + provider_id: "builtin", + namespace: "workspace", + version: "1", + source: contributionSource, + source_digest: "sha256:contribution", + }], + }, + }, + }; +} + +for (const field of ["bundle", "contribution"] as const) { + Deno.test(`config source API accepts ${field} schema bodies larger than metadata`, async () => { + const source = "schema body\n".repeat(6000); + const body = field === "bundle" + ? withSchema(source) + : withSchema("builtin", source); + const fetcher = (() => Promise.resolve(response(body))) as typeof fetch; + assertEquals(await fetchConfigTree("w", fetcher), body); + assertEquals( + await commitConfigTree("w", { + base_revision: 7, + base_digest: snapshot.digest, + changes: [], + entrypoints: ["main.dcdl"], + }, fetcher), + body, + ); + }); + + Deno.test(`config source API bounds ${field} schema bodies in UTF-8 bytes and checks types`, () => { + const body = (source: unknown) => + field === "bundle" ? withSchema(source) : withSchema("builtin", source); + const limit = 8 * 1024 * 1024; + const atLimit = "é".repeat(limit / 2); + assertEquals( + parseWorkspaceConfigTreeResponse(body(atLimit)), + body(atLimit), + ); + for (const invalid of [atLimit + "x", null, 42, {}]) { + assertThrows( + () => parseWorkspaceConfigTreeResponse(body(invalid)), + ConfigSourceApiError, + ); + } + }); +} + +Deno.test("config source API retains metadata and total response limits", async () => { + const invalid = withSchema("builtin"); + invalid.contract.schema_bundle.contributions[0].provider_id = "x".repeat( + 4097, + ); + assertThrows( + () => parseWorkspaceConfigTreeResponse(invalid), + ConfigSourceApiError, + ); + for ( + const headers of [ + new Headers(), + new Headers({ "content-length": String(8 * 1024 * 1024 + 1) }), + ] + ) { + const fetcher = (() => + Promise.resolve( + new Response( + JSON.stringify(withSchema("x".repeat(8 * 1024 * 1024))), + { headers }, + ), + )) as typeof fetch; + await assertRejects( + () => fetchConfigTree("w", fetcher), + ConfigSourceApiError, + "too large", + ); + } +}); + Deno.test("config source API rejects unknown response fields", async () => { const fetcher = (() => diff --git a/web/workspace/tests/memory-api.test.ts b/web/workspace/tests/memory-api.test.ts index 97f036e1e..9d5b460d8 100644 --- a/web/workspace/tests/memory-api.test.ts +++ b/web/workspace/tests/memory-api.test.ts @@ -12,6 +12,7 @@ import { parseSubjektivSubjectListResponse, parseSubjektivSubjectResponse, SubjektivSubjectCreateError, + updateSubjektivSubjectBehavior, validateSubjektivSubjectCreateRequest, } from "../src/lib/workspace/memory/api.ts"; @@ -61,9 +62,7 @@ async function assertSubjectCreateRejects( assertEquals(error.status, expectedStatus); if (!error.message.includes(expectedMessage)) { throw new Error( - `expected ${JSON.stringify(error.message)} to include ${ - JSON.stringify(expectedMessage) - }`, + `expected ${JSON.stringify(error.message)} to include ${JSON.stringify(expectedMessage)}`, ); } return; @@ -75,6 +74,8 @@ function subject(id = "subject-1") { return { id, role: "Release coordinator", + behavior_md: "Prefer explicit evidence.", + behavior_revision: 2, state: "active", store_revision: 12, created_at: "2026-09-01T00:00:00Z", @@ -82,6 +83,30 @@ function subject(id = "subject-1") { }; } +function currentWorker() { + return { + runtime_id: "runtime-1", + worker_id: "worker-1", + host_id: "host-1", + display_name: "Release Worker", + label: "Release Worker", + profile: "builtin:companion", + singleton_key: "subjektiv:subject-1", + tags: [], + workspace: { + visibility: "workspace", + identity: "workspace-1", + workspace_id: "workspace-1", + }, + state: "idle", + last_seen_at: "2026-09-02T00:00:00Z", + pinned: false, + retention_state: "retained", + implementation: { kind: "embedded", display_hint: "Embedded" }, + diagnostics: [], + }; +} + function queryItem(id = "memory-1") { return { id, @@ -187,14 +212,35 @@ Deno.test("Subject parsers enforce identity, exact enums, safe revisions, and bo () => parseSubjektivSubjectListResponse({ limit: 100, - items: Array.from({ length: 101 }, (_, index) => - subject(`subject-${index}`)), + items: Array.from({ length: 101 }, (_, index) => subject(`subject-${index}`)), has_more: false, }), "bounded array", ); }); +Deno.test("Subject parser preserves live Worker connection state", () => { + assertEquals( + parseSubjektivSubjectResponse({ + ...subject(), + current_worker: currentWorker(), + }), + { ...subject(), current_worker: currentWorker() }, + ); + assertEquals( + parseSubjektivSubjectResponse({ ...subject(), current_worker: null }), + { ...subject(), current_worker: null }, + ); + assertThrows( + () => + parseSubjektivSubjectResponse({ + ...subject(), + current_worker: { display_name: "Incomplete Worker" }, + }), + "current worker.runtime_id", + ); +}); + Deno.test("Subject list parser preserves the exact bounded response", () => { const response = { limit: 100, items: [subject()], has_more: false }; assertEquals(parseSubjektivSubjectListResponse(response), response); @@ -228,7 +274,7 @@ Deno.test("Subject list parser preserves the exact bounded response", () => { Deno.test("Subject create request mirrors Backend role validation without rewriting input", () => { assertEquals( validateSubjektivSubjectCreateRequest({ role: " Release coordinator " }), - { role: " Release coordinator " }, + { role: " Release coordinator ", behavior_md: "" }, ); assertThrows( () => validateSubjektivSubjectCreateRequest({ role: " " }), @@ -242,6 +288,70 @@ Deno.test("Subject create request mirrors Backend role validation without rewrit () => validateSubjektivSubjectCreateRequest({ role: "界".repeat(86) }), "at most 256 bytes", ); + assertThrows( + () => + validateSubjektivSubjectCreateRequest({ + role: "Reviewer", + behavior_md: " ".repeat(3), + }), + "empty or contain non-whitespace", + ); + assertThrows( + () => + validateSubjektivSubjectCreateRequest({ + role: "Reviewer", + behavior_md: "x".repeat(16 * 1024 + 1), + }), + "at most 16384 bytes", + ); +}); + +Deno.test("Subject behavior update uses CAS and preserves exact text", async () => { + const requests: Array<{ path: string; init?: RequestInit }> = []; + const fetchFn = (async (path: string | URL | Request, init?: RequestInit) => { + requests.push({ path: String(path), init }); + return Response.json({ + ...subject("subject/one"), + behavior_md: "Line one.\nLine two.", + behavior_revision: 3, + }); + }) as typeof fetch; + + const updated = await updateSubjektivSubjectBehavior( + fetchFn, + "workspace one", + "subject/one", + { expected_behavior_revision: 2, behavior_md: "Line one.\nLine two." }, + ); + assertEquals(updated.behavior_revision, 3); + assertEquals(updated.behavior_md, "Line one.\nLine two."); + assertEquals( + requests[0].path, + "/api/w/workspace%20one/subjektiv/subjects/subject%2Fone/behavior", + ); + assertEquals(requests[0].init?.method, "PATCH"); + assertEquals( + requests[0].init?.body, + '{"expected_behavior_revision":2,"behavior_md":"Line one.\\nLine two."}', + ); +}); + +Deno.test("Subject behavior update reports conflicts without claiming Worker application", async () => { + const conflictFetch = + (async () => + Response.json({ message: "revision conflict" }, { + status: 409, + })) as typeof fetch; + await assertSubjectCreateRejects( + () => + updateSubjektivSubjectBehavior(conflictFetch, "workspace", "subject", { + expected_behavior_revision: 1, + behavior_md: "new", + }), + "rejected", + "changed elsewhere", + 409, + ); }); Deno.test("Subject create client sends the exact typed body once and parses the created Subject", async () => { @@ -251,20 +361,26 @@ Deno.test("Subject create client sends the exact typed body once and parses the return Response.json({ ...subject("subject-created"), role: " Release coordinator ", + behavior_md: "Stay concise.", }, { status: 201 }); }) as typeof fetch; const created = await createSubjektivSubject(fetchFn, "workspace one", { role: " Release coordinator ", + behavior_md: "Stay concise.", }); assertEquals(created, { ...subject("subject-created"), role: " Release coordinator ", + behavior_md: "Stay concise.", }); assertEquals(requests.length, 1); assertEquals(requests[0].path, "/api/w/workspace%20one/subjektiv/subjects"); assertEquals(requests[0].init?.method, "POST"); - assertEquals(requests[0].init?.body, '{"role":" Release coordinator "}'); + assertEquals( + requests[0].init?.body, + '{"role":" Release coordinator ","behavior_md":"Stay concise."}', + ); }); Deno.test("Subject create client distinguishes rejected and unknown outcomes without retrying", async () => { @@ -328,8 +444,7 @@ Deno.test("Subject create client distinguishes rejected and unknown outcomes wit throw new TypeError("connection reset"); }) as typeof fetch; await assertSubjectCreateRejects( - () => - createSubjektivSubject(unknownFetch, "workspace-1", { role: "Reviewer" }), + () => createSubjektivSubject(unknownFetch, "workspace-1", { role: "Reviewer" }), "unknown_outcome", "may have been created", ); @@ -433,8 +548,7 @@ Deno.test("Current Memory list parser rejects unknown variants, unsafe integers, assertThrows( () => parseSubjektivMemoryQueryResponse({ - items: Array.from({ length: 101 }, (_, index) => - queryItem(`memory-${index}`)), + items: Array.from({ length: 101 }, (_, index) => queryItem(`memory-${index}`)), has_more: false, }), "bounded array",