fix: expose typed Workspace config batch contracts
This commit is contained in:
@@ -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 } = ¶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::<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
|
||||
|
||||
Reference in New Issue
Block a user