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
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user