From 4e2f6fcb9c1768a20a81b49458235c48ee16b548 Mon Sep 17 00:00:00 2001 From: Hare Date: Wed, 28 Oct 2026 17:49:22 +0900 Subject: [PATCH] fix: expose typed Workspace config batch contracts --- .../src/feature/builtin/workspace_config.rs | 3 + .../builtin/workspace_config/content.rs | 28 +- .../builtin/workspace_config/contract.rs | 136 ++++ .../workspace_config/contract_tests.rs | 127 ++++ .../workspace_config/production_tests.rs | 4 +- .../src/server_workspace_config_wip_tests.rs | 600 +++++++++++++++++- 6 files changed, 880 insertions(+), 18 deletions(-) create mode 100644 crates/worker/src/feature/builtin/workspace_config/contract.rs create mode 100644 crates/worker/src/feature/builtin/workspace_config/contract_tests.rs diff --git a/crates/worker/src/feature/builtin/workspace_config.rs b/crates/worker/src/feature/builtin/workspace_config.rs index 6bfe1e8e1..791bb1fca 100644 --- a/crates/worker/src/feature/builtin/workspace_config.rs +++ b/crates/worker/src/feature/builtin/workspace_config.rs @@ -24,6 +24,9 @@ pub mod backend; mod content; +mod contract; +#[cfg(test)] +mod contract_tests; pub mod wip; use std::sync::Arc; diff --git a/crates/worker/src/feature/builtin/workspace_config/content.rs b/crates/worker/src/feature/builtin/workspace_config/content.rs index ae6c64b41..7213ba6d0 100644 --- a/crates/worker/src/feature/builtin/workspace_config/content.rs +++ b/crates/worker/src/feature/builtin/workspace_config/content.rs @@ -419,18 +419,21 @@ fn descriptor(names: &[String]) -> InterfaceDescriptor { summary: "Logical Workspace configuration file operations".into(), details: Some("Backend grants, semantic validation, atomic whole-tree CAS and activation apply. Validators and change digests are managed automatically. apply_changes atomically accepts create/update/delete/rename changes without validators or expected_digest fields. Saving updates configuration for future operation boundaries, not already-running Worker manifests. No OS or command access.".into()), }), - operations: names.iter().filter(|name| !matches!(name.as_str(), "read_lines" | "search_text" | "edit" | "write")).map(|name| OperationDeclaration { + operations: names.iter().filter(|name| !matches!(name.as_str(), "read_lines" | "search_text" | "edit" | "write")).map(|name| { + if name == "apply_changes" { + return super::contract::declaration(); + } + OperationDeclaration { name: name.clone(), documentation: None, parameters: match name.as_str() { "attach" => vec![parameter("access", false, TypeExpr::String)], "read" => crate::file_operation::parameters(name, false).unwrap(), "delete" => vec![], "create" => vec![parameter("path", false, TypeExpr::String), parameter("content", true, TypeExpr::String), parameter("content_type", false, TypeExpr::String)], - "apply_changes" => vec![parameter("changes", true, TypeExpr::Json)], _ => vec![], }, returns: ReturnDeclaration { documentation: None, r#type: TypeExpr::Json }, - }).collect(), + }}).collect(), } } @@ -830,13 +833,9 @@ impl ConfigHandler { }] } "apply_changes" => { - let Some(value) = arguments.get("changes") else { - return Err(invalid()); - }; - serde_json::from_value::>( - crate::wip::wip_to_json(value).map_err(|_| invalid())?, - ) - .map_err(|_| invalid())? + let mut command = super::contract::convert(arguments)?; + serde_json::from_value::>(command["changes"].take()) + .map_err(|_| invalid())? } _ => return Err(invalid()), }; @@ -884,10 +883,11 @@ impl ConfigHandler { unknown() })?; let value = json_to_wip(&json!({"digest": result.digest, - "activation": "Saved to canonical Workspace configuration; existing Worker manifests are not rewritten"})).map_err(|_| { - diagnostic.record(Stage::Completion, Reason::Decode, None); - unknown() - })?; + "activation": super::contract::ACTIVATION})) + .map_err(|_| { + diagnostic.record(Stage::Completion, Reason::Decode, None); + unknown() + })?; Ok(WipOperationOutput::native_with_validator( value, object_validator( diff --git a/crates/worker/src/feature/builtin/workspace_config/contract.rs b/crates/worker/src/feature/builtin/workspace_config/contract.rs new file mode 100644 index 000000000..1fb910016 --- /dev/null +++ b/crates/worker/src/feature/builtin/workspace_config/contract.rs @@ -0,0 +1,136 @@ +//! The native batch shape owns both its declaration and conversion to the Backend JSON command. +//! +//! WIP unions use `$case`/`value`, not the Backend's `kind`-tagged JSON. Reusing the native +//! contract converter keeps these representations explicit without accepting a legacy JSON fallback. +//! Semantic validation and atomic persistence remain exclusively at the canonical Backend boundary. +use std::collections::BTreeMap; + +use serde_json::Value as Json; +use wip_protocol::{Documentation, FieldDeclaration, OperationDeclaration, TypeExpr, Value}; + +use super::backend::{MAX_NODES, MAX_PATH_BYTES, MAX_TEXT_BYTES}; +use crate::feature::builtin::native_contract::{ + Case, Contract, Destination, Input, ReferenceRoots, Shape, +}; +use crate::wip::WipOperationError; + +pub(super) const ACTIVATION: &str = + "Saved to canonical Workspace configuration; existing Worker manifests are not rewritten"; + +// This command has no Entry fields and therefore uses no domain reference coordinates. +const ROOTS: ReferenceRoots<'static> = ReferenceRoots { + ticket: "", + merge_request: "", + objective: "", +}; + +fn field(name: &'static str, required: bool, ty: Shape) -> Input { + Input { + name, + required, + ty, + destination: Destination::Same, + } +} + +fn input() -> Contract { + Contract { + name: "apply_changes", + summary: "Atomically save a configuration batch; validators and digests are managed by the Client/Host, never supplied by the model", + parameters: vec![field( + "changes", + true, + Shape::List(Box::new(Shape::Union { + tag: "kind", + cases: vec![ + Case { + name: "create", + payload: Some(Shape::Record(vec![ + field("path", true, Shape::String), + field("content", true, Shape::String), + field("content_type", false, Shape::Enum(vec!["decodal", "text"])), + ])), + }, + Case { + name: "update", + payload: Some(Shape::Record(vec![ + field("path", true, Shape::String), + field("content", true, Shape::String), + ])), + }, + Case { + name: "delete", + payload: Some(Shape::Record(vec![field("path", true, Shape::String)])), + }, + Case { + name: "rename", + payload: Some(Shape::Record(vec![ + field("from", true, Shape::String), + field("to", true, Shape::String), + ])), + }, + ], + })), + )], + } +} + +fn documentation(summary: impl Into) -> Option { + Some(Documentation { + summary: summary.into(), + details: None, + }) +} + +pub(super) fn declaration() -> OperationDeclaration { + let mut operation = input().declaration(&ROOTS); + operation.documentation = documentation(format!( + "Atomically validate and save 1..={MAX_NODES} base-relative changes; aggregate content <= {MAX_TEXT_BYTES} UTF-8 bytes. \ + path/from/to are nonempty canonical paths relative to the configuration root (not WIP or OS paths), <= {MAX_PATH_BYTES} UTF-8 bytes: \ + no leading slash, backslash, control characters, URL, empty, . or .. components; $builtin namespace is read-only. \ + Every touched path, including both rename endpoints, may occur only once per batch. Create requires absence; update/delete/rename.from require an existing file; rename.to must be absent. \ + Update retains content_type; rename retains bytes/type and does not rewrite imports. Create content_type defaults to decodal when omitted (not null). \ + The complete candidate must pass Backend import/evaluation/schema validation at the existing entrypoints; update importers in the same batch when renaming/deleting referenced sources. \ + The tree is bounded to {entry_count} entries and {tree_bytes} total UTF-8 bytes, including Backend source/import limits. \ + Validators, expected_digest and entrypoints are automatic, not arguments. Whole-tree CAS rejects stale observations; Inspect again before deciding a new operation. \ + Success returns digest and activation, not per-change results. Rejection is a protocol error with no commit; after dispatched failure OutcomeUnknown is distinct and must not be automatically retried. \ + Backend formats Decodal sources before saving; the returned digest describes the canonical saved tree. \ + Saving is not hot application to running Workers. \ + Invoke JSON encodes each WIP union as {{\"$case\":\"create|update|delete|rename\",\"value\":{{...fields}}}}; \ + the adapter maps it to Backend JSON {{\"kind\":\"case\",...fields}}, adding observed preconditions. Raw kind-tagged input is not accepted. \ + Examples (paths must satisfy the above existence constraints): \ + create {{\"$case\":\"create\",\"value\":{{\"path\":\"notes/new.txt\",\"content\":\"new\",\"content_type\":\"text\"}}}}; \ + update {{\"$case\":\"update\",\"value\":{{\"path\":\"notes/old.txt\",\"content\":\"updated\"}}}}; \ + delete {{\"$case\":\"delete\",\"value\":{{\"path\":\"notes/obsolete.txt\"}}}}; \ + rename {{\"$case\":\"rename\",\"value\":{{\"from\":\"notes/source.txt\",\"to\":\"notes/moved.txt\"}}}}. \ + A composite Invoke arguments record is {{\"changes\":[{{\"$case\":\"create\",\"value\":{{\"path\":\"notes/new.txt\",\"content\":\"new\",\"content_type\":\"text\"}}}},{{\"$case\":\"update\",\"value\":{{\"path\":\"notes/old.txt\",\"content\":\"updated\"}}}},{{\"$case\":\"delete\",\"value\":{{\"path\":\"notes/obsolete.txt\"}}}},{{\"$case\":\"rename\",\"value\":{{\"from\":\"notes/source.txt\",\"to\":\"notes/moved.txt\"}}}}]}}; all changes commit together or none do.", + entry_count = config_source::MAX_ENTRY_COUNT, + tree_bytes = config_source::MAX_TOTAL_BYTES, + )); + operation.returns.r#type = TypeExpr::Record { + fields: vec![ + FieldDeclaration { + name: "digest".into(), + required: true, + r#type: TypeExpr::String, + documentation: documentation( + "Digest of the saved whole configuration tree, not a per-file digest or proof of hot activation", + ), + }, + FieldDeclaration { + name: "activation".into(), + required: true, + r#type: TypeExpr::String, + documentation: documentation(ACTIVATION), + }, + ], + }; + operation.returns.documentation = documentation( + "Successful canonical save only. Failures are WIP protocol errors or OutcomeUnknown, never a success record with partial changes; Client validators are returned separately as protocol metadata", + ); + operation +} + +pub(super) fn convert(arguments: &BTreeMap) -> Result { + input().convert(arguments, &ROOTS) +} diff --git a/crates/worker/src/feature/builtin/workspace_config/contract_tests.rs b/crates/worker/src/feature/builtin/workspace_config/contract_tests.rs new file mode 100644 index 000000000..9b71d12a9 --- /dev/null +++ b/crates/worker/src/feature/builtin/workspace_config/contract_tests.rs @@ -0,0 +1,127 @@ +//! Native JSON boundary tests. Signed Inspect/Invoke plus persistence is exercised by the +//! Workspace server's configuration integration suite; these cases isolate representation drift. +use super::contract; +use crate::wip::{json_to_wip, wip_to_json}; +use serde_json::{Value as Json, json}; +use wip_protocol::{INTERFACE_FORMAT_V1, InterfaceDescriptor, TypeExpr, Value}; + +fn arguments(json: &Json) -> std::collections::BTreeMap { + let Value::Record(arguments) = json_to_wip(json).unwrap() else { + panic!("record expected") + }; + arguments +} + +#[test] +fn config_batch_native_union_maps_exact_backend_tagged_json() { + for (case, payload) in [ + ("create", json!({"path":"new.dcdl", "content":"{}"})), + ( + "create", + json!({"path":"new.txt", "content":"note", "content_type":"text"}), + ), + ( + "create", + json!({"path":"new.dcdl", "content":"{}", "content_type":"decodal"}), + ), + ("update", json!({"path":"existing.dcdl", "content":"{}"})), + ("delete", json!({"path":"obsolete.txt"})), + ("rename", json!({"from":"old.txt", "to":"new.txt"})), + ] { + let command = contract::convert(&arguments( + &json!({"changes":[{"$case":case,"value":payload}]}), + )) + .unwrap_or_else(|_| panic!("published {case} shape must convert")); + let mut expected = payload; + expected["kind"] = json!(case); + assert_eq!(command, json!({"changes":[expected]})); + assert!(!command.to_string().contains("expected_digest")); + } +} + +#[test] +fn config_batch_native_shape_rejects_unpublished_fields_and_invalid_union_wire() { + for change in [ + json!({"kind":"delete","path":"old.txt"}), + json!({"$case":"unknown","value":{}}), + json!({"$case":"create","value":{"path":"new.txt"}}), + json!({"$case":"create","value":{"content":"note"}}), + json!({"$case":"update","value":{"path":"old.txt"}}), + json!({"$case":"delete","value":{}}), + json!({"$case":"rename","value":{"from":"old.txt"}}), + json!({"$case":"rename","value":{"to":"new.txt"}}), + json!({"$case":"create","value":{"path":"new.txt","content":"note","content_type":"json"}}), + json!({"$case":"create","value":{"path":"new.txt","content":"note","content_type":null}}), + json!({"$case":"update","value":{"path":"old.txt","content":"note","content_type":"text"}}), + json!({"$case":"delete","value":{"path":"old.txt","expected_digest":"manual"}}), + json!({"$case":"delete","value":{"path":42}}), + json!({"$case":"delete","value":{"path":"old.txt"},"kind":"delete"}), + ] { + assert!( + contract::convert(&arguments(&json!({"changes":[change]}))).is_err(), + "{change}" + ); + } + for input in [ + json!({}), + json!({"changes":{}}), + json!({"changes":[],"validator":"manual"}), + ] { + assert!(contract::convert(&arguments(&input)).is_err(), "{input}"); + } +} + +#[test] +fn config_batch_declaration_exposes_structure_and_result_not_json() { + let declaration = contract::declaration(); + let descriptor = InterfaceDescriptor { + format: INTERFACE_FORMAT_V1.into(), + documentation: None, + types: vec![], + operations: vec![declaration.clone()], + }; + descriptor.validate().unwrap(); + let [parameter] = declaration.parameters.as_slice() else { + panic!("one argument") + }; + assert_eq!(parameter.name, "changes"); + assert!(parameter.required); + let TypeExpr::List { items } = ¶meter.r#type else { + panic!("list") + }; + let TypeExpr::Union { cases } = items.as_ref() else { + panic!("union") + }; + assert_eq!( + cases + .iter() + .map(|case| case.name.as_str()) + .collect::>(), + ["create", "update", "delete", "rename"] + ); + let signature = wip_text_view::render_interface(&wip_text_view::Interface { + reference: &crate::wip::contextual_reference( + "yoi.workspace-config/node/v1", + "/workspace-config", + ), + descriptor: &descriptor, + }) + .unwrap(); + for text in [ + "content_type?", + "decodal", + "text", + "from:", + "to:", + "digest:", + "activation:", + "Whole-tree CAS", + "OutcomeUnknown", + "not hot application", + ] { + assert!(signature.contains(text), "missing {text}: {signature}"); + } + // Native values remain ordinary JSON strings/records at the model boundary; no Entry inference. + let result = json!({"digest":"tree-digest", "activation":contract::ACTIVATION}); + assert_eq!(wip_to_json(&json_to_wip(&result).unwrap()).unwrap(), result); +} diff --git a/crates/worker/src/feature/builtin/workspace_config/production_tests.rs b/crates/worker/src/feature/builtin/workspace_config/production_tests.rs index 3b1e77b6a..acc1d3d52 100644 --- a/crates/worker/src/feature/builtin/workspace_config/production_tests.rs +++ b/crates/worker/src/feature/builtin/workspace_config/production_tests.rs @@ -639,8 +639,8 @@ async fn workspace_config_production_slash_deep_discover_read_edit_and_atomic_im CONTENT_ROOT, "apply_changes", json!({"changes":[ - {"kind":"create","path":"profiles/new.dcdl","content":"model = imported"}, - {"kind":"update","path":"main.dcdl","content":"import profiles/new.dcdl"} + {"$case":"create","value":{"path":"profiles/new.dcdl","content":"model = imported"}}, + {"$case":"update","value":{"path":"main.dcdl","content":"import profiles/new.dcdl"}} ]}), ) .await diff --git a/crates/workspace-server/src/server_workspace_config_wip_tests.rs b/crates/workspace-server/src/server_workspace_config_wip_tests.rs index 7c719a648..4650edcf9 100644 --- a/crates/workspace-server/src/server_workspace_config_wip_tests.rs +++ b/crates/workspace-server/src/server_workspace_config_wip_tests.rs @@ -268,6 +268,602 @@ async fn assert_config_catalog(runtime: &worker::wip::WipRuntime, attached: &Att assert!(workdir["source"].get("grant_id").is_none()); } +// Ordinary workspace-server lib integration tests: model-facing Inspect/Invoke -> production +// WIP adapter -> signed Backend router -> temporary canonical store and its existing evaluator. +// Only HTTP transport is in-process; response loss is injected after real commit dispatch. +struct ConfigBatchWipFixture { + backend: ManualCoderAssignmentFixture, + client: Arc, + wip: worker::wip::WipRuntime, +} +fn config_batch_runtime(client: Arc) -> worker::wip::WipRuntime { + let feature = worker::feature::builtin::workspace_config::WorkspaceConfigFeature::for_workspace( + client.clone(), + true, + ); + let mut mounts = worker::wip::WipMountRegistry::new(); + worker::feature::builtin::workspace_config::wip::mount_workspace_config_wip( + &mut mounts, + &feature, + ) + .unwrap(); + worker::wip::WipRuntime::from_mounts( + mounts, + format!( + "{}:{}@{TEST_WORKSPACE_ID}", + client.worker.runtime_id, client.worker.worker_id + ), + ) + .unwrap() +} +impl ConfigBatchWipFixture { + async fn new() -> Self { + let mut backend = manual_worker_assignment_fixture().await; + let identity = RuntimeIdentityMaterial::generate(&backend.worker.runtime_id).unwrap(); + configure_runtime_request_auth(&mut backend.api, &identity, &backend.worker.runtime_id); + grant(&backend.api, &backend.worker, Access::ReadWrite).await; + attach(&backend.api, &backend.worker).await; + let client = Arc::new(ConfigRouterClient { + api: backend.api.clone(), + worker: backend.worker.clone(), + identity: Arc::new(identity), + runtime: tokio::runtime::Handle::current(), + paths: Default::default(), + drop_next_commit_response: Default::default(), + }); + let wip = config_batch_runtime(client.clone()); + Self { + backend, + client, + wip, + } + } + fn state(&self) -> crate::config_source::WorkspaceConfigState { + self.backend + .api + .config_store + .load_workspace_config(TEST_WORKSPACE_ID) + .unwrap() + .unwrap() + } + fn commits(&self) -> usize { + self.client + .paths + .lock() + .unwrap() + .iter() + .filter(|path| path.ends_with("/commit")) + .count() + } + async fn inspect(&self) -> Value { + let object = config_inspect(&self.wip, "/workspace-config").await; + let interface = object["interfaces"] + .as_array() + .unwrap() + .iter() + .find(|interface| { + interface["reference"] + == json!({ + "scope":"/workspace-config", "name":"yoi.workspace-config/node/v1" + }) + }) + .unwrap(); + let output = self + .wip + .inspect(interface["path"].as_str().unwrap().into(), true) + .await + .unwrap(); + serde_json::from_str(output.content.as_deref().unwrap()).unwrap() + } + async fn apply(&self, changes: Value) -> Value { + self.inspect().await; + self.apply_observed(changes).await + } + async fn apply_observed(&self, changes: Value) -> Value { + let before = self.commits(); + let output = config_call( + &self.wip, + "/workspace-config", + "apply_changes", + json!({"changes":changes}), + ) + .await + .unwrap(); + let result: Value = serde_json::from_str(output.content.as_deref().unwrap()).unwrap(); + assert_eq!(result.as_object().unwrap().len(), 2, "{result}"); + assert_eq!(result["digest"], self.state().snapshot.digest); + assert!( + result["activation"] + .as_str() + .unwrap() + .contains("not rewritten"), + "{result}" + ); + assert_eq!( + self.commits(), + before + 1, + "one atomic batch, one commit dispatch" + ); + result + } + async fn reject(&self, label: &str, arguments: Value) -> agen::tool::ToolError { + self.inspect().await; + let before = self.state(); + let commits = self.commits(); + let error = config_call(&self.wip, "/workspace-config", "apply_changes", arguments) + .await + .unwrap_err(); + assert!( + !error.to_string().contains("outcome unknown"), + "{label}: {error}" + ); + assert_eq!( + self.state(), + before, + "{label}: no partial canonical write or projection change" + ); + assert!( + self.commits() <= commits + 1, + "{label}: never automatically retry rejection" + ); + error + } +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 4)] +async fn workspace_config_apply_inspect_publishes_typed_cases_constraints_and_results() { + let fixture = ConfigBatchWipFixture::new().await; + let definition = fixture.inspect().await; + let signature = definition["interface_signature"].as_str().unwrap(); + // Assert the actual Text View, not a descriptor constructed by the test. This is display + // verification only: none of this text is parsed into production request arguments. + let declarations = signature + .lines() + .filter(|line| !line.trim_start().starts_with("///")) + .collect::>() + .join(" "); + let declarations = declarations + .split_whitespace() + .collect::>() + .join(" "); + for expected in [ + "operation apply_changes(", + "changes: [union {", + "create({ path: string, content: string, content_type?: enum { decodal, text, }, }),", + "update({ path: string, content: string, }),", + "delete({ path: string, }),", + "rename({ from: string, to: string, }),", + "-> { digest: string, activation: string, }", + ] { + assert!( + declarations.contains(expected), + "missing {expected}: {signature}" + ); + } + assert!(!declarations.contains("changes: json"), "{signature}"); + assert!(!declarations.contains("changes?:"), "{signature}"); + assert!( + !declarations.contains("expected_digest:"), + "model cannot manage CAS: {signature}" + ); + // Summaries (not invisible documentation.details) must explain non-type constraints. + for expected in [ + "1..=256", + "262144", + "512", + "UTF-8", + "canonical", + "relative", + "both rename endpoints", + "once per batch", + "defaults to decodal", + "existing entrypoints", + "schema", + "Whole-tree CAS", + "stale", + "not hot application", + "OutcomeUnknown", + "Raw kind-tagged input is not accepted", + "composite", + "all changes commit together or none", + ] { + assert!( + signature.contains(expected), + "missing public constraint {expected}: {signature}" + ); + } + for case in ["create", "update", "delete", "rename"] { + // The renderer quotes documentation strings, including the JSON examples. + assert!( + signature.contains(&format!(r#"\"$case\":\"{case}\""#)), + "missing public JSON example for {case}: {signature}" + ); + } +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 4)] +async fn workspace_config_apply_model_union_cases_and_composite_persist_evaluated_imports() { + let fixture = ConfigBatchWipFixture::new().await; + // Public Invoke union encoding and fields shown by Inspect; no internal Change fixtures. + fixture.apply(json!([ + {"$case":"create","value":{"path":"notes/edit.txt","content":"old","content_type":"text"}}, + {"$case":"create","value":{"path":"notes/source.txt","content":"move me","content_type":"text"}}, + {"$case":"create","value":{"path":"notes/obsolete.txt","content":"remove me","content_type":"text"}} + ])).await; + // Subsequent calls use the Client's returned root validator, without reinspection or + // model-supplied preconditions. All four variants share the same public contract. + fixture + .apply_observed(json!([{"$case":"update","value":{"path":"notes/edit.txt","content":"updated"}}])) + .await; + fixture + .apply_observed( + json!([{"$case":"rename","value":{"from":"notes/source.txt","to":"notes/moved.txt"}}]), + ) + .await; + fixture + .apply_observed(json!([{"$case":"delete","value":{"path":"notes/obsolete.txt"}}])) + .await; + let state = fixture.state(); + let entry = |path: &str| { + state + .snapshot + .get(&config_source::VirtualPath::parse(path).unwrap()) + }; + assert_eq!(entry("notes/edit.txt").unwrap().content, "updated"); + assert_eq!( + entry("notes/edit.txt").unwrap().content_type, + config_source::ConfigContentType::Text + ); + assert_eq!(entry("notes/moved.txt").unwrap().content, "move me"); + assert_eq!( + entry("notes/moved.txt").unwrap().content_type, + config_source::ConfigContentType::Text + ); + assert!(entry("notes/source.txt").is_none()); + assert!(entry("notes/obsolete.txt").is_none()); + + fixture.apply(json!([ + {"$case":"create","value":{"path":"fragments/batch.dcdl","content":"{ common = { language = \"TYPED_BATCH\"; }; }"}}, + {"$case":"update","value":{"path":"main.dcdl","content":"{ prompts = import \"./fragments/batch.dcdl\"; } as WorkspaceConfigSchema"}}, + {"$case":"delete","value":{"path":"notes/edit.txt"}}, + {"$case":"rename","value":{"from":"notes/moved.txt","to":"notes/final.txt"}} + ])).await; + let state = fixture.state(); + let fragment = state + .snapshot + .get(&config_source::VirtualPath::parse("fragments/batch.dcdl").unwrap()) + .unwrap(); + assert_eq!( + fragment.content_type, + config_source::ConfigContentType::Decodal, + "omitted content_type defaults to decodal" + ); + assert_eq!( + fragment.content, + "{\n common = {\n language = \"TYPED_BATCH\";\n };\n}\n" ); + assert_eq!( + state + .snapshot + .get(&config_source::VirtualPath::parse("notes/final.txt").unwrap()) + .unwrap() + .content, + "move me" + ); + for absent in ["notes/edit.txt", "notes/moved.txt"] { + assert!( + state + .snapshot + .get(&config_source::VirtualPath::parse(absent).unwrap()) + .is_none() + ); + } + let prompts = crate::prompt_settings::project_prompts_from_workspace_config(&state).unwrap(); + assert_eq!(prompts.templates["common.language"], "TYPED_BATCH"); + assert_eq!( + state.contract.entrypoints, + vec![config_source::VirtualPath::parse("main.dcdl").unwrap()] + ); +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 4)] +async fn workspace_config_apply_model_shape_errors_reject_without_commit() { + let fixture = ConfigBatchWipFixture::new().await; + for (label, arguments) in [ + ("missing changes", json!({})), + ("changes not list", json!({"changes":{}})), + ( + "unknown case", + json!({"changes":[{"$case":"replace","value":{"path":"main.dcdl","content":"{}"}}]}), + ), + ( + "raw Backend kind is not WIP union", + json!({"changes":[{"kind":"create","path":"notes/raw.txt","content":"raw","content_type":"text"}]}), + ), + ( + "create missing content", + json!({"changes":[{"$case":"create","value":{"path":"notes/missing.txt"}}]}), + ), + ( + "update missing path", + json!({"changes":[{"$case":"update","value":{"content":"{}"}}]}), + ), + ( + "delete missing path", + json!({"changes":[{"$case":"delete","value":{}}]}), + ), + ( + "rename missing to", + json!({"changes":[{"$case":"rename","value":{"from":"main.dcdl"}}]}), + ), + ( + "null is not omitted content_type", + json!({"changes":[{"$case":"create","value":{"path":"notes/null.dcdl","content":"{}","content_type":null}}]}), + ), + ( + "invalid content_type", + json!({"changes":[{"$case":"create","value":{"path":"notes/type.txt","content":"x","content_type":"json"}}]}), + ), + ( + "wrong field type", + json!({"changes":[{"$case":"delete","value":{"path":123}}]}), + ), + ( + "extra payload field", + json!({"changes":[{"$case":"update","value":{"path":"main.dcdl","content":"{}","expected_digest":"model supplied"}}]}), + ), + ( + "extra union field", + json!({"changes":[{"$case":"delete","value":{"path":"main.dcdl"},"kind":"delete"}]}), + ), + ( + "extra argument", + json!({"changes":[],"entrypoints":["notes/new.dcdl"]}), + ), + ] { + let commits = fixture.commits(); + fixture.reject(label, arguments).await; + assert_eq!( + fixture.commits(), + commits, + "{label}: invalid model shape cannot reach commit" + ); + } +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 4)] +async fn workspace_config_apply_invalid_candidates_and_collisions_leave_canonical_tree_unchanged() { + let fixture = ConfigBatchWipFixture::new().await; + fixture.apply(json!([ + {"$case":"create","value":{"path":"notes/source.txt","content":"source","content_type":"text"}}, + {"$case":"create","value":{"path":"notes/other.txt","content":"other","content_type":"text"}} + ])).await; + for (label, changes) in [ + ("empty batch", json!([])), + ( + "traversal", + json!([{"$case":"create","value":{"path":"../escape","content":"{}"}}]), + ), + ( + "absolute path", + json!([{"$case":"create","value":{"path":"/workspace-config/escape","content":"{}"}}]), + ), + ( + "noncanonical path", + json!([{"$case":"create","value":{"path":"notes//escape","content":"{}"}}]), + ), + ( + "overlong UTF-8 path", + json!([{"$case":"create","value":{"path":"é".repeat(257),"content":"{}"}}]), + ), + ( + "parse failure", + json!([{"$case":"update","value":{"path":"main.dcdl","content":"{"}}]), + ), + ( + "schema failure", + json!([{"$case":"update","value":{"path":"main.dcdl","content":"{ prompts = 42; } as WorkspaceConfigSchema"}}]), + ), + ( + "missing import", + json!([{"$case":"update","value":{"path":"main.dcdl","content":"import \"./missing.dcdl\""}}]), + ), + ( + "duplicate target", + json!([ + {"$case":"update","value":{"path":"notes/source.txt","content":"changed"}}, + {"$case":"delete","value":{"path":"notes/source.txt"}} + ]), + ), + ( + "rename source touched twice", + json!([ + {"$case":"rename","value":{"from":"notes/source.txt","to":"notes/moved.txt"}}, + {"$case":"delete","value":{"path":"notes/source.txt"}} + ]), + ), + ( + "rename destination touched twice", + json!([ + {"$case":"rename","value":{"from":"notes/source.txt","to":"notes/moved.txt"}}, + {"$case":"create","value":{"path":"notes/moved.txt","content":"collision","content_type":"text"}} + ]), + ), + ( + "two rename destinations collide", + json!([ + {"$case":"rename","value":{"from":"notes/source.txt","to":"notes/moved.txt"}}, + {"$case":"rename","value":{"from":"notes/other.txt","to":"notes/moved.txt"}} + ]), + ), + ( + "occupied rename destination", + json!([{"$case":"rename","value":{"from":"notes/source.txt","to":"notes/other.txt"}}]), + ), + ( + "required entrypoint deletion", + json!([{"$case":"delete","value":{"path":"main.dcdl"}}]), + ), + ] { + // A valid first change must not leak from a later rejected change/candidate. + let mut batch = vec![ + json!({"$case":"create","value":{"path":"notes/atomic-marker.txt","content":"must not persist","content_type":"text"}}), + ]; + if label == "empty batch" { + batch.clear(); + } else { + batch.extend(changes.as_array().unwrap().iter().cloned()); + } + fixture.reject(label, json!({"changes":batch})).await; + } + let oversized = (0..257) + .map(|index| { + json!({"$case":"create","value":{ + "path":format!("notes/large-{index}.txt"),"content":"","content_type":"text" + }}) + }) + .collect::>(); + fixture + .reject("batch limit", json!({"changes":oversized})) + .await; + fixture.reject("aggregate text limit", json!({"changes":[ + {"$case":"create","value":{"path":"notes/large-a.txt","content":"x".repeat(131072),"content_type":"text"}}, + {"$case":"create","value":{"path":"notes/large-b.txt","content":"x".repeat(131073),"content_type":"text"}} + ]})).await; +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 4)] +async fn workspace_config_apply_stale_root_observation_rejects_unrelated_tree_change() { + let fixture = ConfigBatchWipFixture::new().await; + fixture.inspect().await; + let before = fixture.state(); + // Independent model-facing cache over the same signed client and canonical store. + let other = config_batch_runtime(fixture.client.clone()); + config_inspect(&other, "/workspace-config").await; + config_call(&other, "/workspace-config", "apply_changes", json!({"changes":[ + {"$case":"create","value":{"path":"notes/other-editor.txt","content":"concurrent edit","content_type":"text"}} + ]})).await.unwrap(); + let concurrent = fixture.state(); + assert_ne!(before.snapshot.digest, concurrent.snapshot.digest); + assert_eq!( + before + .snapshot + .get(&config_source::VirtualPath::parse("main.dcdl").unwrap()), + concurrent + .snapshot + .get(&config_source::VirtualPath::parse("main.dcdl").unwrap()) + ); + let commits = fixture.commits(); + let error = config_call(&fixture.wip, "/workspace-config", "apply_changes", json!({"changes":[ + {"$case":"create","value":{"path":"notes/stale-editor.txt","content":"must not save","content_type":"text"}} + ]})).await.unwrap_err(); + assert!(error.to_string().contains("ValidatorMismatch"), "{error}"); + assert_eq!(fixture.state(), concurrent); + assert_eq!( + fixture.commits(), + commits, + "stale root cannot dispatch a fresh commit" + ); + fixture.apply(json!([{"$case":"create","value":{"path":"notes/fresh-editor.txt","content":"new decision","content_type":"text"}}])).await; +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 4)] +async fn workspace_config_apply_cached_contract_cannot_bypass_readonly_or_revocation() { + for revoke in [false, true] { + let fixture = ConfigBatchWipFixture::new().await; + let definition = fixture.inspect().await; + assert!( + definition["interface_signature"] + .as_str() + .unwrap() + .contains("apply_changes(") + ); + let before = fixture.state(); + if revoke { + workspace_config::revoke_grant( + &fixture.backend.api, + &test_owner_actor(), + TEST_WORKSPACE_ID, + &attached_grant_id(&fixture.backend.api, &fixture.backend.worker), + ) + .await + .unwrap(); + } else { + workspace_config::attach( + &fixture.backend.api, + &fixture.backend.worker, + Attach { + alias: None, + access: Some(Access::ReadOnly), + }, + ) + .await + .unwrap(); + } + let commits = fixture.commits(); + let error = config_call(&fixture.wip, "/workspace-config", "apply_changes", json!({"changes":[ + {"$case":"create","value":{"path":"notes/denied.txt","content":"denied","content_type":"text"}} + ]})).await.unwrap_err(); + assert!( + !error.to_string().contains("outcome unknown"), + "{error}" + ); + assert_eq!(fixture.state(), before); + assert_eq!(fixture.commits(), commits); + if !revoke { + let readonly = fixture.inspect().await; + assert!( + !readonly["interface_signature"] + .as_str() + .unwrap() + .contains("apply_changes("), + "{readonly}" + ); + } + } +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 4)] +async fn workspace_config_apply_known_rejection_differs_from_lost_response_without_retry() { + let fixture = ConfigBatchWipFixture::new().await; + let commits = fixture.commits(); + fixture.reject("known Backend evaluation rejection", json!({"changes":[ + {"$case":"create","value":{"path":"notes/no-partial.txt","content":"not committed","content_type":"text"}}, + {"$case":"update","value":{"path":"main.dcdl","content":"{ prompts = 42; } as WorkspaceConfigSchema"}} + ]})).await; + assert_eq!( + fixture.commits(), + commits + 1, + "known rejection came from real Backend evaluation" + ); + fixture.inspect().await; + let commits = fixture.commits(); + fixture + .client + .drop_next_commit_response + .store(true, Ordering::SeqCst); + let error = config_call(&fixture.wip, "/workspace-config", "apply_changes", json!({"changes":[ + {"$case":"create","value":{"path":"notes/unknown.txt","content":"committed once","content_type":"text"}} + ]})).await.unwrap_err(); + assert!( + error.to_string().contains("outcome unknown"), + "{error}" + ); + let state = fixture.state(); + assert_eq!( + state + .snapshot + .get(&config_source::VirtualPath::parse("notes/unknown.txt").unwrap()) + .unwrap() + .content, + "committed once" + ); + assert_eq!( + fixture.commits(), + commits + 1, + "lost response never triggers automatic retry" + ); +} + #[tokio::test(flavor = "multi_thread", worker_threads = 4)] async fn workspace_config_real_selected_invoke_backend_wip_edit_create_delete_import_and_unknown() { use worker::feature::builtin::workspace_config::wip::mount_workspace_config_wip; @@ -432,8 +1028,8 @@ async fn workspace_config_real_selected_invoke_backend_wip_edit_create_delete_im .unwrap(); config_inspect(&wip, CONTENT_ROOT).await; config_call(&wip,CONTENT_ROOT,"apply_changes",json!({"changes":[ - {"kind":"create","path":"fragments/prompts.dcdl","content_type":"decodal","content":"{ common = { language = \"CONFIG_WIP_E2E\"; }; }"}, - {"kind":"update","path":"main.dcdl","content":"{ prompts = import \"./fragments/prompts.dcdl\"; } as WorkspaceConfigSchema"} + {"$case":"create","value":{"path":"fragments/prompts.dcdl","content_type":"decodal","content":"{ common = { language = \"CONFIG_WIP_E2E\"; }; }"}}, + {"$case":"update","value":{"path":"main.dcdl","content":"{ prompts = import \"./fragments/prompts.dcdl\"; } as WorkspaceConfigSchema"}} ]})).await.unwrap(); // Confirm through ORIGINAL config reader/projection, not a copied WIP store. let state = fixture