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