fix: preserve string and table Profile scope authoring (T-710)
Resolve MR sequence 5 profile_authoring_rejects_valid_string_scope_intents. Decodal 0.4 has no union constraint: retain an opaque scope range with non-materialized table completion hints so string intents remain valid. Keep shared Backend semantic validation authoritative for scope forms/types/unknown keys without changing persisted values or weakening worker/feature editor diagnostics.
Prove actual-provider native/WASM scalar/table, inline/import/patch, nested intent/deny_write/symlink_policy completion and evaluation invariance. Add real save/projection/Runtime scope equivalence and invalid-scope save non-loss coverage. Regenerated WASM artifact is byte-identical; the changed shape is transported via the fingerprint-bound Backend schema metadata.
Validation: root cargo check; config-source39; value_profiles4; unfiltered server643+11; worker-runtime340+17+17; server-api --features typescript77; wasm32 release/wasm-bindgen0.2.117/Binaryen132 -Oz; Deno39; deno task check0/0; fmt/diff checks. Logs target/t710-scope-{check,server,server-api,runtime}.log. No target/ref integration changes.
This commit is contained in:
@@ -2555,9 +2555,8 @@ mod tests {
|
||||
assert_eq!(environment.evaluate_contract(&contract).unwrap(), evaluated);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn actual_profile_schema_diagnoses_nested_values_without_materializing_omissions() {
|
||||
let schema = WorkspaceConfigSchemaBundle::compose([ConfigSchemaContribution::new(
|
||||
fn actual_profile_schema() -> WorkspaceConfigSchemaBundle {
|
||||
WorkspaceConfigSchemaBundle::compose([ConfigSchemaContribution::new(
|
||||
"builtin:profile",
|
||||
"profile",
|
||||
"2",
|
||||
@@ -2567,7 +2566,12 @@ mod tests {
|
||||
.with_authoring_source(include_str!(
|
||||
"../../../resources/config-schema/profile-authoring.dcdl"
|
||||
))])
|
||||
.unwrap();
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn actual_profile_schema_diagnoses_nested_values_without_materializing_omissions() {
|
||||
let schema = actual_profile_schema();
|
||||
let source = "{ profile = { entries = [{ selector = \"project:alpha\"; profile = {}; }]; }; } as WorkspaceConfigSchema";
|
||||
let snapshot = ConfigTreeSnapshot::from_entries(
|
||||
8,
|
||||
@@ -2659,6 +2663,81 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn actual_profile_schema_preserves_scalar_and_table_scope_forms_and_completion() {
|
||||
for (recipe, expected) in [
|
||||
(
|
||||
r#"{ scope = "workspace_read"; delegation_scope = "workspace_write"; }"#,
|
||||
serde_json::json!({"scope": "workspace_read", "delegation_scope": "workspace_write"}),
|
||||
),
|
||||
(
|
||||
r#"{ scope = "workspace_write"; delegation_scope = "workspace_read"; }"#,
|
||||
serde_json::json!({"scope": "workspace_write", "delegation_scope": "workspace_read"}),
|
||||
),
|
||||
(
|
||||
r#"{ scope = { intent = "workspace_read"; }; delegation_scope = { intent = "workspace_write"; deny_write = ["private"]; symlink_policy = "resolved"; }; }"#,
|
||||
serde_json::json!({"scope": {"intent": "workspace_read"}, "delegation_scope": {"intent": "workspace_write", "deny_write": ["private"], "symlink_policy": "resolved"}}),
|
||||
),
|
||||
] {
|
||||
for form in [
|
||||
recipe.to_owned(),
|
||||
r#"import "./scoped.dcdl""#.into(),
|
||||
format!(r#"(import "./scoped.dcdl") // {{ description = "patched"; }}"#),
|
||||
] {
|
||||
let source = format!(
|
||||
r#"{{ profile.entries = [{{ selector = "project:scoped"; profile = {form}; }}]; }} as WorkspaceConfigSchema"#
|
||||
);
|
||||
let snapshot = ConfigTreeSnapshot::from_entries(
|
||||
9,
|
||||
[entry("main.dcdl", &source), entry("scoped.dcdl", recipe)],
|
||||
)
|
||||
.unwrap();
|
||||
let schema = actual_profile_schema();
|
||||
let environment =
|
||||
SnapshotEnvironment::new(snapshot).with_schema_bundle(schema.clone());
|
||||
let contract =
|
||||
ToolchainContract::with_schema_bundle(1, vec![path("main.dcdl")], 1, schema);
|
||||
let before = environment.evaluate_contract(&contract).unwrap();
|
||||
assert!(
|
||||
environment.analyze(&path("main.dcdl"), None).is_empty(),
|
||||
"{source}"
|
||||
);
|
||||
let mut expected = expected.clone();
|
||||
if form.contains("patched") {
|
||||
expected["description"] = serde_json::json!("patched");
|
||||
}
|
||||
assert_eq!(
|
||||
before.projections[0]
|
||||
.data_json
|
||||
.pointer("/profile/entries/0/profile"),
|
||||
Some(&expected)
|
||||
);
|
||||
for field in ["scope", "delegation_scope"] {
|
||||
for (prefix, label) in [
|
||||
("int", "intent"),
|
||||
("deny", "deny_write"),
|
||||
("sym", "symlink_policy"),
|
||||
] {
|
||||
let marked = format!(
|
||||
"{{ profile.entries = [{{ profile.{field} = {{ {prefix}| }}; }}]; }} as WorkspaceConfigSchema"
|
||||
);
|
||||
let cursor = marked.find('|').unwrap();
|
||||
let source = marked.replace('|', "");
|
||||
let result = environment
|
||||
.complete_config(&path("main.dcdl"), &source, cursor, true)
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
assert!(
|
||||
result.items.iter().any(|item| item.label == label),
|
||||
"{source}: {result:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
assert_eq!(environment.evaluate_contract(&contract).unwrap(), before);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn completion_authoring_sources_compose_with_validation_source_fallbacks() {
|
||||
let schema = WorkspaceConfigSchemaBundle::compose([
|
||||
|
||||
@@ -228,6 +228,81 @@ async fn value_profiles_save_project_and_runtime_consume_the_same_revision() {
|
||||
assert!(restored.feature.task.enabled);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn value_profiles_scalar_and_table_scopes_analyze_save_and_resolve_equally() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let api = test_api(dir.path()).await;
|
||||
let scalar = format!(
|
||||
r#"{PROFILE} // {{ scope = "workspace_read"; delegation_scope = "workspace_write"; }}"#
|
||||
);
|
||||
let table = format!(
|
||||
r#"{PROFILE} // {{ scope = {{ intent = "workspace_read"; }}; delegation_scope = {{ intent = "workspace_write"; }}; }}"#
|
||||
);
|
||||
let mut scopes = Vec::new();
|
||||
for (index, form) in [scalar.clone(), table, r#"import "./scoped.dcdl""#.into()]
|
||||
.iter()
|
||||
.enumerate()
|
||||
{
|
||||
let extra = if index == 0 {
|
||||
vec![config_source::ConfigTreeChange::Create {
|
||||
path: config_source::VirtualPath::parse("scoped.dcdl").unwrap(),
|
||||
content_type: config_source::ConfigContentType::Decodal,
|
||||
content: scalar.clone(),
|
||||
}]
|
||||
} else {
|
||||
vec![]
|
||||
};
|
||||
let authored = format!("{} as WorkspaceConfigSchema", source(form));
|
||||
let state =
|
||||
commit_workspace_config_tree(&api, TEST_WORKSPACE_ID, &request(&api, &authored, extra))
|
||||
.unwrap();
|
||||
let environment = config_source::SnapshotEnvironment::new(state.snapshot.clone())
|
||||
.with_schema_bundle(state.contract.schema_bundle.clone());
|
||||
assert!(
|
||||
environment
|
||||
.analyze(
|
||||
&config_source::VirtualPath::parse("main.dcdl").unwrap(),
|
||||
None
|
||||
)
|
||||
.is_empty(),
|
||||
"{authored}"
|
||||
);
|
||||
let projection = project_profiles_from_workspace_config(TEST_WORKSPACE_ID, &state).unwrap();
|
||||
let bundle = build_virtual_profile_config_bundle(
|
||||
&projection,
|
||||
&state,
|
||||
TEST_WORKSPACE_ID,
|
||||
"created",
|
||||
"project:alpha",
|
||||
)
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
let resolved = bundle
|
||||
.profile_source_archive
|
||||
.as_ref()
|
||||
.unwrap()
|
||||
.verify()
|
||||
.unwrap()
|
||||
.resolve_profile(
|
||||
"project:alpha",
|
||||
Path::new("/runtime-worker"),
|
||||
"scoped-worker",
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(resolved.scope.allow.len(), 1);
|
||||
assert_eq!(resolved.delegation_scope.allow.len(), 1);
|
||||
let scope = serde_json::to_value(&resolved.scope).unwrap();
|
||||
let delegation = serde_json::to_value(&resolved.delegation_scope).unwrap();
|
||||
assert_eq!(scope["allow"][0]["target"], "/runtime-worker");
|
||||
assert_eq!(scope["allow"][0]["permission"], "read");
|
||||
assert_eq!(delegation["allow"][0]["target"], "/runtime-worker");
|
||||
assert_eq!(delegation["allow"][0]["permission"], "write");
|
||||
scopes.push((scope, delegation));
|
||||
}
|
||||
assert_eq!(scopes[0], scopes[1]);
|
||||
assert_eq!(scopes[1], scopes[2]);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn value_profiles_invalid_saves_preserve_the_active_config() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
@@ -242,6 +317,12 @@ async fn value_profiles_invalid_saves_preserve_the_active_config() {
|
||||
(source("42"), ""),
|
||||
(source(r#"{ worker = { mode = "not-a-mode"; }; }"#), "profile_value_invalid"),
|
||||
(source(r#"{ worker = { name = "runtime-only"; }; }"#), "profile_value_invalid"),
|
||||
(source(r#"{ scope = 42; }"#), "profile_value_invalid"),
|
||||
(source(r#"{ scope = "not-an-intent"; }"#), "profile_value_invalid"),
|
||||
(source(r#"{ delegation_scope = { intent = 42; }; }"#), "profile_value_invalid"),
|
||||
(source(r#"{ scope = { intent = "workspace_read"; unknown = true; }; }"#), "profile_value_invalid"),
|
||||
(source(r#"{ scope = { intent = "workspace_read"; deny_write = 42; }; }"#), "profile_value_invalid"),
|
||||
(source(r#"{ delegation_scope = { intent = "workspace_write"; symlink_policy = 42; }; }"#), "profile_value_invalid"),
|
||||
(source(r#"import "./missing.dcdl""#), ""),
|
||||
(source(r#"import "../outside.dcdl""#), ""),
|
||||
(r#"{ profile = { entries = [{ selector = "project:alpha"; profile = {}; }, { selector = "project:alpha"; profile = {}; }]; }; }"#.into(), "profile_selector_duplicate"),
|
||||
|
||||
@@ -45,6 +45,8 @@ The former `entries = [{ selector = "project:name"; source = "profiles/name.dcdl
|
||||
|
||||
The schema contribution separates its materialized registration schema from an optional, fingerprint-bound `authoring_source`. The latter supplies editor field suggestions inside array elements and partial Profile records, plus a separate constraint-only analysis pass for supplied nested values (including imported recipes); that pass never materializes the shape, makes omitted fields required, or supplies defaults to the saved projection. Editor diagnostics point to the supplied value's virtual file/span. The authoring shape is never substituted for the persisted evaluation schema. Decodal structural diagnostics cover registration and nested Profile field types; the shared Backend save boundary additionally checks full Profile/Manifest semantics (including mode values, forbidden runtime fields, duplicate selectors and default references). UI and WIP saves use that same boundary; invalid candidates leave the active config revision untouched.
|
||||
|
||||
`scope` and `delegation_scope` retain both supported forms: a string such as `"workspace_read"` / `"workspace_write"`, or a table such as `{ intent = "workspace_read"; deny_write = ["private"]; symlink_policy = "resolved"; }`. Decodal 0.4 has no union range, so these fields are opaque to the editor constraint pass; a non-materialized table hint supplies nested completion without rejecting the string form or rewriting it. The shared Backend save validator checks scope forms, intent values and table fields (including their types and unknown keys). Other structurally typed fields such as `worker.mode` and `feature.task.enabled` retain editor type diagnostics. Completion hints never become Profile defaults or saved values.
|
||||
|
||||
## Manifests
|
||||
|
||||
A resolved Manifest is the concrete contract used to create or restore a Worker. It carries defaults, resolved paths, permissions, scope, prompt references, provider/model decisions, and runtime identity.
|
||||
|
||||
@@ -12,7 +12,11 @@ let
|
||||
enabled = Bool; model = Model; reasoning = String;
|
||||
threshold = Int; worker_max_turns = Int;
|
||||
};
|
||||
Scope = { intent = String; deny_write = [...String]; symlink_policy = String; };
|
||||
# Profile scope accepts either a string intent or a table. Decodal 0.4 has
|
||||
# no union range; keep this field opaque to constraint analysis and expose
|
||||
# the table only as a completion hint. This shape is never materialized;
|
||||
# actual scope forms, intent values and table fields are checked on save.
|
||||
Scope = Unknown default { intent = String; deny_write = [...String]; symlink_policy = String; };
|
||||
Profile = {
|
||||
slug = String;
|
||||
description = String;
|
||||
|
||||
@@ -569,6 +569,93 @@ Deno.test("generated WASM authors value-based Profiles using the Backend schema
|
||||
);
|
||||
}
|
||||
}
|
||||
for (const field of ["scope", "delegation_scope"]) {
|
||||
for (
|
||||
const [prefix, label] of [["int", "intent"], ["deny", "deny_write"], [
|
||||
"sym",
|
||||
"symlink_policy",
|
||||
]]
|
||||
) {
|
||||
const body =
|
||||
`{ profile.entries = [{ profile.${field} = { ${prefix} } }]; } as WorkspaceConfigSchema`;
|
||||
const cursor = body.lastIndexOf(prefix) + prefix.length;
|
||||
const result = complete_current("main.dcdl", body, cursor, true) as {
|
||||
items: Array<{ label: string }>;
|
||||
};
|
||||
assertEquals(
|
||||
result.items.some((item) => item.label === label),
|
||||
true,
|
||||
`${field}.${label}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
for (
|
||||
const [recipe, expected] of [
|
||||
['{ scope = "workspace_read"; delegation_scope = "workspace_write"; }', {
|
||||
scope: "workspace_read",
|
||||
delegation_scope: "workspace_write",
|
||||
}],
|
||||
['{ scope = "workspace_write"; delegation_scope = "workspace_read"; }', {
|
||||
scope: "workspace_write",
|
||||
delegation_scope: "workspace_read",
|
||||
}],
|
||||
[
|
||||
'{ scope = { intent = "workspace_read"; }; delegation_scope = { intent = "workspace_write"; deny_write = ["private"]; symlink_policy = "resolved"; }; }',
|
||||
{
|
||||
scope: { intent: "workspace_read" },
|
||||
delegation_scope: {
|
||||
intent: "workspace_write",
|
||||
deny_write: ["private"],
|
||||
symlink_policy: "resolved",
|
||||
},
|
||||
},
|
||||
],
|
||||
] as const
|
||||
) {
|
||||
for (
|
||||
const form of [
|
||||
recipe,
|
||||
'import "./scoped.dcdl"',
|
||||
'(import "./scoped.dcdl") // { description = "patched"; }',
|
||||
]
|
||||
) {
|
||||
const scopedContent = content.replace(
|
||||
"profile = {};",
|
||||
`profile = ${form};`,
|
||||
);
|
||||
const scopedTree = {
|
||||
...tree,
|
||||
entries: {
|
||||
"main.dcdl": {
|
||||
...tree.entries["main.dcdl"],
|
||||
content: scopedContent,
|
||||
content_digest: await digestText(scopedContent),
|
||||
},
|
||||
"scoped.dcdl": {
|
||||
path: "scoped.dcdl",
|
||||
content_type: "decodal",
|
||||
content: recipe,
|
||||
content_digest: await digestText(recipe),
|
||||
},
|
||||
},
|
||||
};
|
||||
assertEquals(
|
||||
analyze_snapshot(scopedTree, "main.dcdl", undefined),
|
||||
[],
|
||||
form,
|
||||
);
|
||||
const result = evaluate_snapshot(
|
||||
scopedTree,
|
||||
valueContract,
|
||||
) as typeof evaluated;
|
||||
assertEquals(
|
||||
result.projections[0].data_json.profile.entries[0].profile,
|
||||
form.includes("patched")
|
||||
? { ...expected, description: "patched" }
|
||||
: expected,
|
||||
);
|
||||
}
|
||||
}
|
||||
// Neither completion nor diagnostic shape checking changes the saved value.
|
||||
assertEquals(evaluate_snapshot(tree, valueContract), evaluated);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user