From 9b5fad39702148ea157d533045d875397a38b665 Mon Sep 17 00:00:00 2001 From: Hare Date: Wed, 28 Oct 2026 17:35:14 +0900 Subject: [PATCH] fix: publish typed workdir result entries and current content entrances --- .../src/feature/builtin/manage_workdir/wip.rs | 227 ++++-- .../builtin/manage_workdir/wip/model_tests.rs | 699 ++++++++++++++++++ .../builtin/manage_workdir/wip/result.rs | 144 ++++ crates/worker/src/wip.rs | 9 + 4 files changed, 1034 insertions(+), 45 deletions(-) create mode 100644 crates/worker/src/feature/builtin/manage_workdir/wip/model_tests.rs create mode 100644 crates/worker/src/feature/builtin/manage_workdir/wip/result.rs diff --git a/crates/worker/src/feature/builtin/manage_workdir/wip.rs b/crates/worker/src/feature/builtin/manage_workdir/wip.rs index 004cb8093..86c572934 100644 --- a/crates/worker/src/feature/builtin/manage_workdir/wip.rs +++ b/crates/worker/src/feature/builtin/manage_workdir/wip.rs @@ -1,5 +1,16 @@ //! Host-owned Repository, Workdir and Worker attachment Objects. //! Inventory is resolved from Workspace authority on demand, not a second catalog. +//! +//! Result Records distinguish Entry coordinates from identity/selector strings. An Entry's wire +//! value is a string; only its declared type makes it navigable. Provider metadata (capabilities, +//! nullable selectors, occupancy and removal outcomes) stays JSON, not a hidden reference catalog. +//! A Workdir source's config mapping is not a live entrance: only a current Attachment publishes one. +//! +//! Coordinates never acquire destinations or grant content access. Inspect/Invoke recheck current +//! provider authority and observation validators, including connection generations. Post-attach +//! lookup is separate from mutation success; rediscover unavailable references, never repeat attach. + +mod result; use std::collections::BTreeMap; use std::sync::Arc; @@ -78,6 +89,7 @@ struct Provider { catalog: bool, manage: bool, config_content: bool, + checkout_content: bool, } /// Register Object ownership once, then contribute independent management operations. @@ -94,9 +106,9 @@ pub fn mount_workspace_workdir_wip( permissions, catalog, manage, + checkout_content: registry.has_subtree("/checkouts"), config_content: registry - .routes() - .any(|route| route == crate::feature::builtin::workspace_config::CONTENT_ROOT), + .has_subtree(crate::feature::builtin::workspace_config::CONTENT_ROOT), }; for domain in [Domain::Repository, Domain::Workdir, Domain::Attachment] { let namespace = @@ -421,46 +433,100 @@ impl Provider { 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 references are resolved from current Backend authority, - // never inferred from the alias or an OS session. An old lifetime - // cannot refer to a replacement connection at the same root. - if self.config_content - && raw.get("alias").and_then(Json::as_str) - == Some(crate::feature::builtin::workspace_config::ATTACHMENT_ALIAS) - && let Some(current) = - crate::feature::builtin::workspace_config::backend::WorkspaceConfigBackend::new( - self.backend.client.clone(), - ) - .current()? - && current.connection_id == id - && raw.get("working_directory_id").and_then(Json::as_str) - == Some(current.working_directory_id.as_str()) - && raw.get("alias").and_then(Json::as_str) == Some(current.alias.as_str()) + self.content_reference(raw, &mut result)?; + } + result.insert("path".into(), json!(item_path(domain, id))); + Ok(Json::Object(result)) + } + fn content_reference( + &self, + raw: &Json, + result: &mut serde_json::Map, + ) -> Result<(), WipOperationError> { + let alias = raw.get("alias").and_then(Json::as_str); + let id = raw.get("connection_id").and_then(Json::as_str); + let workdir = raw.get("working_directory_id").and_then(Json::as_str); + let state = if alias == Some(crate::feature::builtin::workspace_config::ATTACHMENT_ALIAS) { + if !self.config_content { + "unpublished" + } else if let Some(current) = + crate::feature::builtin::workspace_config::backend::WorkspaceConfigBackend::new( + self.backend.client.clone(), + ) + .current()? + && Some(current.connection_id.as_str()) == id + && Some(current.working_directory_id.as_str()) == workdir + && Some(current.alias.as_str()) == alias { result.insert("content_path".into(), json!(current.content_path)); result.insert("name".into(), json!(current.name)); result.insert("purpose".into(), json!(current.purpose)); result.insert("access".into(), json!(current.access)); + "available" + } else { + "disconnected" } - if !result.contains_key("content_path") - && let Some(alias) = raw.get("alias").and_then(Json::as_str) - && let Ok(selected) = self.backend.session_router.resolve(Some(alias)) - && raw.get("working_directory_id").and_then(Json::as_str) - == Some(selected.session.workdir().id().as_str()) - && selected - .session - .capabilities() - .supports(workdir::WorkdirSessionCapability::Read) + } else if !self.checkout_content { + "unpublished" + } else if let Some(alias) = alias + && let Ok(selected) = self.backend.session_router.resolve(Some(alias)) + && workdir == Some(selected.session.workdir().id().as_str()) + { + if selected + .session + .capabilities() + .supports(workdir::WorkdirSessionCapability::Read) { + // Alias and Workdir identity alone do not identify a connection lifetime. + // Refuse an entrance when the Backend no longer recognizes this exact link. + if !self + .backend + .client + .authorize_workdir_attachment_operation("workdir.attachment.read", alias, id) + .is_ok_and(|current| Some(current.as_str()) == id) + { + result.insert("content_state".into(), json!("unavailable")); + return Ok(()); + } result.insert( "checkout_path".into(), json!(crate::checkout::checkout_root(alias)), ); - result.insert("checkout_slug".into(), json!(encode_identity(alias))); + "available" + } else { + "unsupported" } + } else { + "disconnected" + }; + result.insert("content_state".into(), json!(state)); + Ok(()) + } + + fn attached_reference(&self, alias: &str, workdir: &str) -> Option { + if !self.catalog { + return None; } - result.insert("path".into(), json!(item_path(domain, id))); - Ok(Json::Object(result)) + // Resolve only the selected alias through current authority, never scan all pages. + // Attach has no response connection ID: this is a separate current observation, + // not proof that the connection cannot change before later Inspect. + let connection = self + .backend + .client + .authorize_workdir_attachment_operation("workdir.attachment.read", alias, None) + .ok()?; + self.authorize( + "WorkdirAttachmentRead", + &self.read_input(Domain::Attachment, &connection), + ) + .ok()?; + let raw = self.raw_item(Domain::Attachment, &connection).ok()?; + if raw.get("alias").and_then(Json::as_str) != Some(alias) + || raw.get("working_directory_id").and_then(Json::as_str) != Some(workdir) + { + return None; + } + self.projected(Domain::Attachment, &raw).ok() } } @@ -848,18 +914,12 @@ impl WipOperationHandler for ItemHandler { let mut result = raw; result["workdir_path"] = json!(item_path(Domain::Workdir, &self.id)); result["attachments_path"] = json!(ATTACHMENTS); - if let Some(alias) = input.get("alias").and_then(Json::as_str) - && self - .provider - .backend - .session_router - .resolve(Some(alias)) - .is_ok() - { - result["checkout_path"] = json!(crate::checkout::checkout_root(alias)); - result["checkout_slug"] = json!(encode_identity(alias)); - } - // Backend attachment list supplies the durable lifetime identity. + result["attachment"] = input + .get("alias") + .and_then(Json::as_str) + .and_then(|alias| self.provider.attached_reference(alias, &self.id)) + .map(|value| json!({"$case":"available", "value":value})) + .unwrap_or_else(|| json!({"$case":"unavailable"})); result } else { raw @@ -1043,6 +1103,7 @@ fn operation( name: &str, summary: &str, parameters: Vec, + returns: TypeExpr, ) -> OperationDeclaration { OperationDeclaration { name: name.into(), @@ -1053,7 +1114,7 @@ fn operation( parameters, returns: ReturnDeclaration { documentation: None, - r#type: TypeExpr::Json, + r#type: returns, }, } } @@ -1081,15 +1142,17 @@ fn collection_descriptor(domain: Domain, management: bool) -> InterfaceDescripto parameter("runtime_id", false, TypeExpr::String), parameter("display_name", false, TypeExpr::String), ], + result::created(domain), )] } else { vec![operation( "list", - "List a bounded page of visible Objects with item paths", + "List a bounded page of visible Objects with typed Entries", vec![ parameter("limit", false, TypeExpr::Integer), parameter("cursor", false, TypeExpr::String), ], + result::page(domain), )] }, ) @@ -1105,14 +1168,21 @@ fn item_descriptor(domain: Domain, management: bool) -> InterfaceDescriptor { "attach", ATTACH_DESCRIPTION, vec![parameter("alias", true, TypeExpr::String)], + result::attached(), ), operation( "delete", DELETE_DESCRIPTION, vec![parameter("reason", true, TypeExpr::String)], + TypeExpr::Json, ), ], - Domain::Attachment => vec![operation("detach", DETACH_DESCRIPTION, vec![])], + Domain::Attachment => vec![operation( + "detach", + DETACH_DESCRIPTION, + vec![], + TypeExpr::Json, + )], Domain::Repository => vec![], } } else { @@ -1120,6 +1190,7 @@ fn item_descriptor(domain: Domain, management: bool) -> InterfaceDescriptor { "read", "Read this Object through current Workspace authority", vec![], + result::item(domain), )] }, ) @@ -1146,7 +1217,7 @@ pub(crate) mod tests { Vec, pub(crate) authorization_denied: bool, pub(crate) denied: bool, - config_current: Option, + pub(crate) config_current: Option, generation: usize, } impl Default for CatalogClient { @@ -1447,6 +1518,69 @@ pub(crate) mod tests { assert!(!projected.to_string().contains("SECRET")); } + #[tokio::test] + async fn physical_entrance_requires_publication_session_capability_and_exact_connection() { + let client = Arc::new(CatalogClient::default()); + let raw = json!({"alias":"checkout", "connection_id":"current", "working_directory_id":"wd", "capabilities":{"bits":63}}); + client.state.lock().unwrap().attachments.push(raw.clone()); + let mut p = provider(client.clone(), true, true, None); + let project = |p: &Provider, raw: &Json| p.projected(Domain::Attachment, raw).ok().unwrap(); + assert_eq!(project(&p, &raw)["content_state"], "unpublished"); + p.checkout_content = true; + assert_eq!(project(&p, &raw)["content_state"], "disconnected"); + let alias = workdir::WorkdirAttachmentAlias::new("checkout").unwrap(); + p.backend + .session_router + .attach( + alias.clone(), + WorkspaceAttachedWorkdirSession::handle_for_workdir_with_capabilities( + client.clone(), + "checkout", + "wd", + WorkdirSessionCapabilities::EMPTY, + ), + ) + .unwrap(); + assert_eq!(project(&p, &raw)["content_state"], "unsupported"); + p.backend.session_router.detach(&alias).await.unwrap(); + p.backend + .session_router + .attach( + alias, + WorkspaceAttachedWorkdirSession::handle_for_workdir(client, "checkout", "wd"), + ) + .unwrap(); + assert_eq!(project(&p, &raw)["content_state"], "available"); + let mut old = raw.clone(); + old["connection_id"] = json!("old"); + let old = project(&p, &old); + assert_eq!(old["content_state"], "unavailable"); + assert!(old.get("checkout_path").is_none()); + } + + #[test] + fn result_contract_validates_entry_coordinates_without_guessing_plain_strings() { + let p = provider(Arc::new(CatalogClient::default()), true, true, None); + let descriptor = item_descriptor(Domain::Repository, false); + let mut raw = json!({"repository_key":"key", "display_name":"not an Entry /?", "default_selector":"refs/heads/feature"}); + let projected = p.projected(Domain::Repository, &raw).ok().unwrap(); + descriptor + .validate_result("read", &json_to_wip(&projected).unwrap()) + .unwrap(); + let mut bad = projected; + bad["path"] = json!("not-an-absolute-entry"); + assert!( + descriptor + .validate_result("read", &json_to_wip(&bad).unwrap()) + .is_err() + ); + raw["repository_key"] = json!("repo/name"); + let projected = p.projected(Domain::Repository, &raw).ok().unwrap(); + descriptor + .validate_result("read", &json_to_wip(&projected).unwrap()) + .unwrap(); + } + fn provider( client: Arc, catalog: bool, @@ -1458,6 +1592,7 @@ pub(crate) mod tests { catalog, manage, config_content: false, + checkout_content: false, permissions, } } @@ -1832,6 +1967,7 @@ mod lifetime_tests { catalog: true, manage: true, config_content: false, + checkout_content: false, }; let handler = CollectionHandler { provider: p.clone(), @@ -1928,6 +2064,7 @@ mod logical_capability_tests { catalog: true, manage: true, config_content: false, + checkout_content: false, permissions: None, }; let item = ItemHandler { diff --git a/crates/worker/src/feature/builtin/manage_workdir/wip/model_tests.rs b/crates/worker/src/feature/builtin/manage_workdir/wip/model_tests.rs new file mode 100644 index 000000000..669cbfe3d --- /dev/null +++ b/crates/worker/src/feature/builtin/manage_workdir/wip/model_tests.rs @@ -0,0 +1,699 @@ +//! Model-facing Inspect/Invoke integration through real WIP Client/Host, management Feature, +//! WorkspaceAttachedWorkdirSession, checkout/config projections and an isolated LocalWorkdirSession. +//! Only WorkspaceClient transport/Backend ledger is substituted; Backend persistence/authorization +//! is not reimplemented or certified here. Selected Entries come from published return declarations, +//! not result field names or path concatenation. This module belongs to the WIP binding test scope. +use super::*; +use crate::feature::builtin::manage_workdir::{ManageWorkdirFeature, wip::tests::CatalogClient}; +use crate::feature::builtin::workspace_config::WorkspaceConfigFeature; +use crate::worker::{ + WorkspaceClient, WorkspaceClientError, WorkspaceRequest, WorkspaceRequestMethod, + WorkspaceResponse, +}; +use std::sync::atomic::{AtomicBool, AtomicUsize}; +use tempfile::TempDir; +use workdir::{LocalWorkdirSession, WorkdirSessionCapabilities, WorkdirSessionHandle}; + +#[derive(Debug)] +struct ContentClient { + catalog: Arc, + session: WorkdirSessionHandle, + reference_unavailable: AtomicBool, + content_denied: AtomicBool, + content_reads: AtomicUsize, +} +impl WorkspaceClient for ContentClient { + fn workspace_id(&self) -> Option<&str> { + Some("test-workspace") + } + fn kind(&self) -> &str { + "workdir-model-transport" + } + fn is_available(&self) -> bool { + true + } + fn execute( + &self, + request: WorkspaceRequest, + ) -> Result { + if request.path.ends_with("/authorization/bound-check") + && self.reference_unavailable.load(Ordering::SeqCst) + && request + .body + .as_deref() + .unwrap() + .contains("workdir.attachment.read") + { + return Ok(WorkspaceResponse { + status: 403, + body: "denied".into(), + }); + } + if request.path.ends_with("/workdir-session/operations") { + self.catalog + .state + .lock() + .unwrap() + .requests + .push(request.clone()); + if self.content_denied.load(Ordering::SeqCst) { + return Ok(WorkspaceResponse { + status: 403, + body: serde_json::to_string( + &workdir::http::WorkdirTransportError::from_workdir_error( + &workdir::WorkdirError::Denied("fixture".into()), + ), + ) + .unwrap(), + }); + } + #[derive(serde::Deserialize)] + struct SessionRequest { + target_workdir: String, + operation: workdir::http::WorkdirSessionOperation, + } + let operation: SessionRequest = + serde_json::from_str(request.body.as_deref().unwrap()).unwrap(); + assert_eq!(operation.target_workdir, "checkout"); + // The synchronous WorkspaceClient boundary runs the actual async local provider on an + // isolated thread/runtime, without nesting block_on in the model's runtime thread. + if matches!( + &operation.operation, + workdir::http::WorkdirSessionOperation::CheckoutExecute(workdir::CheckoutRequest { + operation: workdir::CheckoutOperation::Read { .. } + | workdir::CheckoutOperation::ReadBytes { .. }, + .. + }) + ) { + self.content_reads.fetch_add(1, Ordering::SeqCst); + } + let session = self.session.clone(); + let result = std::thread::spawn(move || { + tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .unwrap() + .block_on(workdir::dispatch_workdir_session_operation( + session.as_ref(), + operation.operation, + )) + }) + .join() + .unwrap() + .unwrap(); + return Ok(reply(&workdir::http::WorkdirSessionOperationResponse { + result, + audit_failures: vec![], + })); + } + if request.path.contains("/workspace-config/") { + self.catalog + .state + .lock() + .unwrap() + .requests + .push(request.clone()); + let input: Json = serde_json::from_str(request.body.as_deref().unwrap()).unwrap(); + let current = self + .catalog + .state + .lock() + .unwrap() + .config_current + .clone() + .unwrap(); + if input["connection_id"] != current["connection_id"] + || self.content_denied.load(Ordering::SeqCst) + { + return Ok(WorkspaceResponse { + status: 403, + body: "denied".into(), + }); + } + let file = "main.dcdl"; + let text = "model = fixture\n"; + let digest = config_source::digest_bytes(text.as_bytes()); + let validator = + |path: &str| format!("{}:{path}", current["connection_id"].as_str().unwrap()); + if request.path.ends_with("/observe") { + let input: server_api::WorkspaceConfigObserveRequest = + serde_json::from_value(input).unwrap(); + let nodes = ["", file] + .into_iter() + .filter(|path| { + input.paths.iter().any(|selected| { + selected == path || selected.is_empty() && input.depth > 0 + }) + }) + .map(|path| server_api::WorkspaceConfigNode { + path: path.into(), + kind: if path.is_empty() { + server_api::WorkspaceConfigNodeKind::Directory + } else { + server_api::WorkspaceConfigNodeKind::File + }, + validator: validator(path), + digest: (!path.is_empty()).then(|| digest.clone()), + content_type: (!path.is_empty()) + .then_some(server_api::ConfigContentType::Decodal), + operations: if path.is_empty() { + vec![] + } else { + vec!["read".into()] + }, + }) + .collect(); + return Ok(reply(&server_api::WorkspaceConfigObserveResponse { + connection_id: current["connection_id"].as_str().unwrap().into(), + validator: validator(""), + digest, + entrypoints: vec![file.into()], + nodes, + })); + } + assert!(request.path.ends_with("/read")); + let input: server_api::WorkspaceConfigReadRequest = + serde_json::from_value(input).unwrap(); + assert_eq!(input.path, file); + assert_eq!(input.validator, validator(file)); + return Ok(reply(&server_api::WorkspaceConfigReadResponse { + path: input.path, + validator: validator(file), + content: text.into(), + digest, + content_type: server_api::ConfigContentType::Decodal, + })); + } + self.catalog.execute(request) + } +} +fn reply(value: &impl serde::Serialize) -> WorkspaceResponse { + WorkspaceResponse { + status: 200, + body: serde_json::to_string(value).unwrap(), + } +} + +struct Fixture { + _directory: TempDir, + client: Arc, + runtime: Arc, + tools: BTreeMap>, +} +impl Fixture { + fn new(checkout: bool, config: bool, permissions: Option) -> Self { + Self::with_config_content(checkout, config, true, permissions) + } + fn with_config_content( + checkout: bool, + config: bool, + content: bool, + permissions: Option, + ) -> Self { + let directory = TempDir::new().unwrap(); + std::fs::write(directory.path().join("sample.txt"), "isolated content\n").unwrap(); + let client = Arc::new(ContentClient { + catalog: Arc::new(CatalogClient::default()), + session: Arc::new(LocalWorkdirSession::materialized( + directory.path().into(), + directory.path().into(), + manifest::SharedScope::new(manifest::Scope::writable(directory.path()).unwrap()), + WorkdirSessionCapabilities::READ_ONLY, + )), + reference_unavailable: AtomicBool::new(false), + content_denied: AtomicBool::new(false), + content_reads: AtomicUsize::new(0), + }); + let router = Arc::new(workdir::WorkdirSessionRouter::new()); + let feature = ManageWorkdirFeature::with_child_lifecycle( + client.clone(), + router.clone(), + Arc::new(|| Box::pin(async { Ok(()) })), + Arc::new(|| {}), + ); + let mut registry = WipMountRegistry::new(); + if checkout { + crate::checkout::mount_checkouts( + &mut registry, + router, + tools::Tracker::new(), + permissions.clone(), + ) + .unwrap(); + } + if config { + let feature = WorkspaceConfigFeature::for_workspace(client.clone(), true); + if content { + crate::feature::builtin::workspace_config::wip::mount_workspace_config_wip( + &mut registry, + &feature, + ) + .unwrap(); + } else { + crate::feature::builtin::workspace_config::wip::mount_workspace_config_attach_wip( + &mut registry, + &feature, + ) + .unwrap(); + } + } + crate::feature::builtin::manage_workdir::wip::mount_workspace_workdir_wip( + &mut registry, + &feature, + true, + true, + permissions, + ) + .unwrap(); + let runtime = + Arc::new(WipRuntime::from_mounts(registry, "workdir-model-test".into()).unwrap()); + let tools = wip_tool_definitions(runtime.clone()) + .into_iter() + .map(|definition| { + let (meta, tool) = definition(); + (meta.name, tool) + }) + .collect(); + Self { + _directory: directory, + client, + runtime, + tools, + } + } + async fn execute(&self, tool: &str, input: Json) -> Result { + self.tools[tool] + .execute(&input.to_string(), ToolExecutionContext::direct()) + .await + .map(|output| serde_json::from_str(output.content.as_deref().unwrap()).unwrap()) + } + async fn inspect(&self, path: &str) -> Json { + self.execute("Inspect", json!({"path":path})).await.unwrap() + } + async fn request(&self, path: &str, operation: &str, arguments: Json) -> (Json, TypeExpr) { + let object = self.inspect(path).await; + for interface in object["interfaces"].as_array().unwrap() { + let reference = InterfaceReference { + scope: interface["reference"]["scope"].as_str().unwrap().into(), + name: interface["reference"]["name"].as_str().unwrap().into(), + }; + let descriptor = self + .runtime + .host + .fetch_interface_live(&reference) + .await + .unwrap() + .descriptor; + if let Some(op) = descriptor.operations.iter().find(|op| op.name == operation) { + let display = self + .execute("Inspect", json!({"path":interface["path"]})) + .await + .unwrap(); + assert_eq!( + display["interface_signature"], + wip_text_view::render_interface(&wip_text_view::Interface { + reference: &reference, + descriptor: &descriptor, + }) + .unwrap() + ); + return ( + json!({"path":path,"interface":interface["reference"],"operation":operation,"arguments":arguments}), + op.returns.r#type.clone(), + ); + } + } + panic!("no published {operation} for {path}") + } + async fn invoke(&self, path: &str, operation: &str, arguments: Json) -> (Json, Vec) { + let (request, ty) = self.request(path, operation, arguments).await; + let value = self.execute("Invoke", request).await.unwrap(); + let mut entries = Vec::new(); + collect_entries(&ty, &value, &mut entries); + (value, entries) + } + fn writes(&self, suffix: &str) -> usize { + self.client + .catalog + .state + .lock() + .unwrap() + .requests + .iter() + .filter(|request| { + request.method != WorkspaceRequestMethod::Get && request.path.ends_with(suffix) + }) + .count() + } +} +fn collect_entries(ty: &TypeExpr, value: &Json, entries: &mut Vec) { + match ty { + TypeExpr::Entry => entries.push(value.as_str().expect("Entry wire string").into()), + TypeExpr::Record { fields } => { + for field in fields { + if let Some(value) = value.get(&field.name) { + collect_entries(&field.r#type, value, entries); + } + } + } + TypeExpr::List { items } => { + for value in value.as_array().unwrap() { + collect_entries(items, value, entries) + } + } + TypeExpr::Union { cases } => { + let case = cases + .iter() + .find(|case| value["$case"] == case.name) + .unwrap(); + if let Some(payload) = &case.payload { + collect_entries(payload, &value["value"], entries); + } + } + // Neither ordinary Strings nor JSON objects containing path-shaped strings are Entries. + _ => (), + } +} +fn selected(entries: &[String], root: &str) -> String { + entries + .iter() + .find(|entry| entry.starts_with(root) && entry.as_str() != root) + .unwrap() + .clone() +} + +#[tokio::test] +async fn model_repository_create_attach_selected_entries_read_and_detach_preserve_workdir() { + let f = Fixture::new(true, false, None); + let (listed, entries) = f.invoke("/repositories", "list", json!({})).await; + assert_eq!(entries.len(), 1); + let repository = entries[0].clone(); + let (read, _) = f.invoke(&repository, "read", json!({})).await; + assert_eq!(read["repository_key"], listed["items"][0]["repository_key"]); + let (created, entries) = f + .invoke( + "/workdirs", + "create", + json!({"repository_key":read["repository_key"]}), + ) + .await; + assert_eq!(f.writes("working-directories"), 1); + assert!( + f.client + .catalog + .state + .lock() + .unwrap() + .attachments + .is_empty() + ); + let workdir = selected(&entries, "/workdirs"); + assert!(entries.contains(&repository)); + let (attached, entries) = f + .invoke(&workdir, "attach", json!({"alias":"checkout"})) + .await; + assert_eq!(attached["attached"], true); + assert_eq!(attached["attachment"]["$case"], "available"); + assert_eq!( + f.writes("workdir-session/operations"), + 0, + "returning Entries must not observe content eagerly" + ); + let attachment = selected(&entries, "/workdir-attachments"); + let checkout = selected(&entries, "/checkouts"); + let (_, read_entries) = f.invoke(&attachment, "read", json!({})).await; + assert!(read_entries.contains(&checkout)); + let (_, files) = f.invoke(&checkout, "list", json!({})).await; + let file = files.first().unwrap(); + let (read, _) = f.invoke(file, "read", json!({})).await; + assert!( + read["content"] + .as_str() + .unwrap() + .contains("isolated content") + ); + assert_eq!(f.writes("workdir-attachments"), 1); + f.invoke(&attachment, "detach", json!({})).await; + assert!( + f.execute("Inspect", json!({"path":attachment,"refresh":true})) + .await + .is_err() + ); + assert!( + f.execute("Inspect", json!({"path":checkout,"refresh":true})) + .await + .is_err() + ); + assert_eq!(f.client.catalog.state.lock().unwrap().workdirs.len(), 1); + assert_eq!(created["item"]["working_directory_id"], "wd-created"); +} + +#[tokio::test] +async fn model_config_attachment_entry_reaches_logical_content_without_physical_session() { + let f = Fixture::new(false, true, None); + let current = json!({"workspace_id":"test-workspace", "alias":"workspace-config", "connection_id":"logical-connection", "working_directory_id":"logical-workdir", "access":"read_only", "name":"Config", "purpose":"Configuration", "content_path":"/workspace-config", "already_attached":true}); + { + let mut state = f.client.catalog.state.lock().unwrap(); + state.config_current = Some(current.clone()); + state.attachments.push(json!({"alias":current["alias"], "connection_id":current["connection_id"], "working_directory_id":current["working_directory_id"], "capabilities":{"bits":25}})); + } + let (_, entries) = f.invoke("/workdir-attachments", "list", json!({})).await; + let attachment = selected(&entries, "/workdir-attachments"); + let (_, entries) = f.invoke(&attachment, "read", json!({})).await; + let entrance = entries + .iter() + .find(|entry| entry.as_str() == "/workspace-config") + .unwrap(); + assert!(!entries.iter().any(|entry| entry.starts_with("/checkouts"))); + f.inspect(entrance).await; + let tree = f + .execute("Tree", json!({"path":entrance, "depth":1})) + .await + .unwrap(); + let file = tree["tree"]["children"][0]["path"].as_str().unwrap(); + let (read, _) = f.invoke(file, "read", json!({})).await; + assert_eq!(read["content"], "model = fixture\n"); + assert_eq!(f.writes("workdir-session/operations"), 0); + assert_eq!(f.writes("workdir-attachments"), 0); + let (old_read, _) = f.request(file, "read", json!({})).await; + { + let mut state = f.client.catalog.state.lock().unwrap(); + state.config_current.as_mut().unwrap()["connection_id"] = json!("logical-reconnected"); + state.attachments[0]["connection_id"] = json!("logical-reconnected"); + } + let reads = f.writes("workspace-config/read"); + assert!(f.execute("Invoke", old_read).await.is_err()); + assert_eq!( + f.writes("workspace-config/read"), + reads, + "stale logical observation must not read the replacement connection" + ); + assert!( + f.execute("Inspect", json!({"path":attachment,"refresh":true})) + .await + .is_err() + ); + f.execute("Inspect", json!({"path":file,"refresh":true})) + .await + .unwrap(); + f.invoke(file, "read", json!({})).await; + f.client.catalog.state.lock().unwrap().config_current = None; + assert!( + f.execute("Inspect", json!({"path":file,"refresh":true})) + .await + .is_err() + ); +} + +async fn create_and_attach(f: &Fixture) -> (String, Json, Vec) { + let (_, entries) = f + .invoke( + "/workdirs", + "create", + json!({"repository_key":"registered-key"}), + ) + .await; + let workdir = selected(&entries, "/workdirs"); + let (attached, entries) = f + .invoke(&workdir, "attach", json!({"alias":"checkout"})) + .await; + (workdir, attached, entries) +} + +#[tokio::test] +async fn model_successful_mutations_survive_unavailable_references_without_reexecution() { + let f = Fixture::new(true, false, None); + f.client.reference_unavailable.store(true, Ordering::SeqCst); + let (workdir, attached, entries) = create_and_attach(&f).await; + assert_eq!(attached["attached"], true); + assert_eq!(attached["attachment"]["$case"], "unavailable"); + assert!(!entries.iter().any(|entry| entry.starts_with("/checkouts"))); + assert_eq!(f.writes("working-directories"), 1); + assert_eq!(f.writes("workdir-attachments"), 1); + f.client + .reference_unavailable + .store(false, Ordering::SeqCst); + let (_, entries) = f.invoke("/workdir-attachments", "list", json!({})).await; + let attachment = selected(&entries, "/workdir-attachments"); + let checkout = selected(&entries, "/checkouts"); + f.invoke(&attachment, "detach", json!({})).await; + // Both successful mutation results remain successful when selected coordinates later disappear. + assert!( + f.execute("Inspect", json!({"path":checkout, "refresh":true})) + .await + .is_err() + ); + f.client.catalog.state.lock().unwrap().workdirs.clear(); + assert!( + f.execute("Inspect", json!({"path":workdir, "refresh":true})) + .await + .is_err() + ); + assert_eq!(f.writes("working-directories"), 1); + assert_eq!(f.writes("workdir-attachments"), 1); + assert!( + f.runtime + .audit() + .iter() + .all(|audit| audit.outcome == WipAuditOutcome::Success) + ); +} + +#[tokio::test] +async fn model_alias_reuse_rejects_old_attachment_and_content_observations() { + let f = Fixture::new(true, false, None); + let (workdir, _, entries) = create_and_attach(&f).await; + let old_attachment = selected(&entries, "/workdir-attachments"); + let checkout = selected(&entries, "/checkouts"); + let (_, files) = f.invoke(&checkout, "list", json!({})).await; + let (old_read, _) = f.request(&files[0], "read", json!({})).await; + let (old_detach, _) = f.request(&old_attachment, "detach", json!({})).await; + f.execute("Invoke", old_detach.clone()).await.unwrap(); + let (_, new_entries) = f + .invoke(&workdir, "attach", json!({"alias":"checkout"})) + .await; + let new_attachment = selected(&new_entries, "/workdir-attachments"); + assert_ne!(old_attachment, new_attachment); + assert!( + new_entries.contains(&checkout), + "coordinate reuse is not observation reuse" + ); + let before = f.client.content_reads.load(Ordering::SeqCst); + assert!(f.execute("Invoke", old_read).await.is_err()); + assert_eq!(f.client.content_reads.load(Ordering::SeqCst), before); + assert!(f.execute("Invoke", old_detach).await.is_err()); + assert_eq!(f.client.catalog.state.lock().unwrap().attachments.len(), 1); + assert!( + f.execute("Inspect", json!({"path":old_attachment, "refresh":true})) + .await + .is_err() + ); + f.execute("Inspect", json!({"path":checkout, "refresh":true})) + .await + .unwrap(); + let (_, files) = f.invoke(&checkout, "list", json!({})).await; + // Explicit refresh observes the new generation rather than retrying the old call. + f.execute("Inspect", json!({"path":files[0], "refresh":true})) + .await + .unwrap(); + let (read, _) = f.invoke(&files[0], "read", json!({})).await; + assert!( + read["content"] + .as_str() + .unwrap() + .contains("isolated content") + ); +} + +#[tokio::test] +async fn model_content_entry_never_grants_read_or_write_authority() { + let f = Fixture::new(true, false, None); + let (_, _, entries) = create_and_attach(&f).await; + let checkout = selected(&entries, "/checkouts"); + f.client.content_denied.store(true, Ordering::SeqCst); + assert!( + f.execute("Inspect", json!({"path":checkout})) + .await + .is_err() + ); + f.client.content_denied.store(false, Ordering::SeqCst); + f.execute("Inspect", json!({"path":checkout, "refresh":true})) + .await + .unwrap(); + let (_, files) = f.invoke(&checkout, "list", json!({})).await; + let object = f.inspect(&files[0]).await; + for interface in object["interfaces"].as_array().unwrap() { + let reference = InterfaceReference { + scope: interface["reference"]["scope"].as_str().unwrap().into(), + name: interface["reference"]["name"].as_str().unwrap().into(), + }; + let descriptor = f + .runtime + .host + .fetch_interface_live(&reference) + .await + .unwrap() + .descriptor; + assert!( + !descriptor + .operations + .iter() + .any(|operation| matches!(operation.name.as_str(), "write" | "edit" | "command")) + ); + } + let (read, _) = f.request(&files[0], "read", json!({})).await; + f.client.content_denied.store(true, Ordering::SeqCst); + assert!( + f.execute("Invoke", read).await.is_err(), + "current authority is rechecked" + ); +} + +#[tokio::test] +async fn model_plain_path_strings_stay_data_and_pages_expose_only_selected_entries() { + let f = Fixture::new(false, false, None); + { + let mut state = f.client.catalog.state.lock().unwrap(); + state.repositories[0]["display_name"] = json!("/workdirs/not-an-entry"); + state.repositories[0]["default_selector"] = json!("/checkouts/not-an-entry"); + state.repositories.push(json!({"repository_key":"second"})); + } + let (first, entries) = f.invoke("/repositories", "list", json!({"limit":1})).await; + assert_eq!(entries.len(), 1); + assert_eq!(first["has_more"], true); + assert!(!entries.iter().any(|entry| entry.contains("not-an-entry"))); + let (second, entries) = f + .invoke( + "/repositories", + "list", + json!({"limit":1, "cursor":first["next_cursor"]}), + ) + .await; + assert_eq!(entries.len(), 1); + assert_eq!(second["items"][0]["repository_key"], "second"); + let (empty, entries) = f.invoke("/workdirs", "list", json!({})).await; + assert_eq!(empty["empty"], true); + assert!(entries.is_empty()); + let (_, attached, entries) = create_and_attach(&f).await; + assert_eq!( + attached["attachment"]["value"]["content_state"], + "unpublished" + ); + assert!(!entries.iter().any(|entry| entry.starts_with("/checkouts"))); +} + +#[tokio::test] +async fn model_self_attach_only_root_does_not_publish_a_config_content_entry() { + let f = Fixture::with_config_content(false, true, false, None); + let current = json!({"workspace_id":"test-workspace", "alias":"workspace-config", "connection_id":"logical-connection", "working_directory_id":"logical-workdir", "access":"read_only", "name":"Config", "purpose":"Configuration", "content_path":"/workspace-config", "already_attached":true}); + { + let mut state = f.client.catalog.state.lock().unwrap(); + state.config_current = Some(current.clone()); + state.attachments.push(json!({"alias":current["alias"], "connection_id":current["connection_id"], "working_directory_id":current["working_directory_id"], "capabilities":{"bits":25}})); + } + let object = f.inspect("/workspace-config").await; + assert_eq!(object["path"], "/workspace-config"); + let (listed, entries) = f.invoke("/workdir-attachments", "list", json!({})).await; + assert_eq!(listed["items"][0]["content_state"], "unpublished"); + assert!(!entries.contains(&"/workspace-config".into())); +} diff --git a/crates/worker/src/feature/builtin/manage_workdir/wip/result.rs b/crates/worker/src/feature/builtin/manage_workdir/wip/result.rs new file mode 100644 index 000000000..904c44ce3 --- /dev/null +++ b/crates/worker/src/feature/builtin/manage_workdir/wip/result.rs @@ -0,0 +1,144 @@ +//! Native result declarations; identity data never acquires Entry semantics by its spelling. +use super::Domain; +use wip_protocol::{Documentation, EnumCase, FieldDeclaration, TypeExpr, UnionCase}; + +fn field(name: &str, required: bool, ty: TypeExpr) -> FieldDeclaration { + FieldDeclaration { + name: name.into(), + required, + documentation: None, + r#type: ty, + } +} +fn record(fields: Vec) -> TypeExpr { + TypeExpr::Record { fields } +} + +pub(super) fn item(domain: Domain) -> TypeExpr { + let mut fields = vec![field("path", true, TypeExpr::Entry)]; + let data: &[&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", + "name", + "purpose", + "access", + ], + }; + let identities: &[&str] = match domain { + Domain::Repository => &["repository_key"], + Domain::Workdir => &["working_directory_id"], + Domain::Attachment => &["connection_id", "alias", "working_directory_id"], + }; + for name in data { + // Nullable/structured Backend metadata remains ordinary JSON, never navigable references. + fields.push(if identities.contains(name) { + field(name, true, TypeExpr::String) + } else { + field(name, false, TypeExpr::Json) + }); + } + if matches!(domain, Domain::Workdir) { + fields.push(field( + "source", + false, + record(vec![ + field("kind", true, TypeExpr::String), + field("repository_key", false, TypeExpr::String), + field("repository_path", false, TypeExpr::Entry), + field("access", false, TypeExpr::String), + // Source mapping only: no attached connection, therefore not an Entry. + field("content_path", false, TypeExpr::String), + field("purpose", false, TypeExpr::String), + ]), + )); + } + if matches!(domain, Domain::Attachment) { + for name in ["workdir_path", "checkout_path", "content_path"] { + fields.push(field(name, false, TypeExpr::Entry)); + } + fields.push(field( + "content_state", + true, + TypeExpr::Enum { + cases: [ + ("available", "Current entrance coordinate only; content and access are not acquired"), + ("unpublished", "Native content subtree is not registered; a self-attach root alone is insufficient"), + ("disconnected", "No matching current content connection/session"), + ("unsupported", "Attached session does not support content Read"), + ("unavailable", "Exact connection could not be confirmed; rediscover, do not repeat mutation"), + ] + .into_iter() + .map(|(name, summary)| EnumCase { + name: name.into(), + documentation: Some(Documentation { summary:summary.into(), details:None }), + }) + .collect(), + }, + )); + } + record(fields) +} + +pub(super) fn page(domain: Domain) -> TypeExpr { + record(vec![ + field( + "items", + true, + TypeExpr::List { + items: Box::new(item(domain)), + }, + ), + field("empty", true, TypeExpr::Boolean), + field("has_more", true, TypeExpr::Boolean), + field("next_cursor", true, TypeExpr::Json), + field("limit", true, TypeExpr::Integer), + ]) +} +pub(super) fn created(domain: Domain) -> TypeExpr { + record(vec![ + field("item", true, item(domain)), + field("runtime_id", true, TypeExpr::String), + ]) +} +pub(super) fn attached() -> TypeExpr { + record(vec![ + field("workspace_id", true, TypeExpr::String), + field("alias", true, TypeExpr::String), + field("working_directory_id", true, TypeExpr::String), + field("capabilities", true, TypeExpr::Json), + field("environment", true, TypeExpr::Json), + field("attached", true, TypeExpr::Boolean), + field("workdir_path", true, TypeExpr::Entry), + field("attachments_path", true, TypeExpr::Entry), + field("attachment", true, TypeExpr::Union { cases: vec![ + UnionCase { name:"available".into(), documentation:None, payload:Some(item(Domain::Attachment)) }, + UnionCase { name:"unavailable".into(), documentation:Some(Documentation { + summary:"Attach succeeded; reference lookup unavailable. Rediscover attachments; do not repeat attach.".into(), details:None + }), payload:None }, + ] }), + ]) +} diff --git a/crates/worker/src/wip.rs b/crates/worker/src/wip.rs index f1e989446..11f9c7eee 100644 --- a/crates/worker/src/wip.rs +++ b/crates/worker/src/wip.rs @@ -1149,6 +1149,11 @@ impl WipMountRegistry { Ok(()) } + /// Exact registration only; this does not observe content or establish current authority. + pub(crate) fn has_subtree(&self, root: &str) -> bool { + self.subtree_mounts.iter().any(|mount| mount.root == root) + } + pub fn routes(&self) -> impl Iterator { self.mounts.keys().map(String::as_str) } @@ -7269,3 +7274,7 @@ mod tests { #[cfg(test)] mod bash_output_tests; + +#[cfg(test)] +#[path = "feature/builtin/manage_workdir/wip/model_tests.rs"] +mod workdir_model_tests;