fix: expose typed Workspace config batch contracts

This commit is contained in:
2026-10-29 05:08:40 +09:00
6 changed files with 880 additions and 18 deletions
@@ -24,6 +24,9 @@
pub mod backend;
mod content;
mod contract;
#[cfg(test)]
mod contract_tests;
pub mod wip;
use std::sync::Arc;
@@ -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::<Vec<Change>>(
crate::wip::wip_to_json(value).map_err(|_| invalid())?,
)
.map_err(|_| invalid())?
let mut command = super::contract::convert(arguments)?;
serde_json::from_value::<Vec<Change>>(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(
@@ -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<String>) -> Option<Documentation> {
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<String, Value>) -> Result<Json, WipOperationError> {
input().convert(arguments, &ROOTS)
}
@@ -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<String, Value> {
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 } = &parameter.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::<Vec<_>>(),
["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);
}
@@ -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
@@ -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<ConfigRouterClient>,
wip: worker::wip::WipRuntime,
}
fn config_batch_runtime(client: Arc<ConfigRouterClient>) -> 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::<Vec<_>>()
.join(" ");
let declarations = declarations
.split_whitespace()
.collect::<Vec<_>>()
.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::<Vec<_>>();
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