feat: register Workspace Profiles as Decodal values (T-710)

Project the evaluated Workspace Profile value into a revision-bound JSON archive without source-path registration or Decodal re-evaluation. Preserve partial-value omissions and explicit overrides, validate before save, and reject legacy registrations with a documented explicit migration.

Fix array-element schema completion and provide fingerprint-bound authoring shapes independently of materialization defaults. Carry metadata through REST, regenerate TypeScript/OpenAPI/WASM artifacts, and exercise save/projection/Runtime consumption and persisted Manifest restore boundaries.

Validation: root cargo check; cargo test -p config-source; cargo test -p worker-runtime; cargo test -p server-api --features typescript; focused profile_settings/value_profiles/archive tests; 39 web config-source/profile API tests; deno task check; cargo fmt --all -- --check; git diff --check HEAD.

Full workspace-server test: 641 passed, one pre-existing ticket_checker_suppresses_stale_and_clean_results_and_diagnoses_invalid_output assertion failure. The same filter fails at base 3c424fd17d in a separately archived source tree and isolated target directory. Remaining server crate tests pass with only that filter skipped (641 lib + 11 bin); unrelated checker code is unchanged.
This commit is contained in:
2026-10-07 00:14:51 +09:00
parent 3c424fd17d
commit 58462d5844
24 changed files with 1708 additions and 319 deletions
+651 -37
View File
@@ -487,6 +487,9 @@ pub struct ConfigSchemaContribution {
pub namespace: String,
pub version: String,
pub source: String,
// Optional editor-only schema; never used to validate or materialize config.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub authoring_source: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub projection_validator: Option<ConfigProjectionValidator>,
pub source_digest: String,
@@ -529,16 +532,31 @@ impl ConfigSchemaContribution {
version,
source_digest: digest_bytes(source.as_bytes()),
source,
authoring_source: None,
projection_validator: None,
})
}
pub fn with_authoring_source(mut self, source: impl Into<String>) -> Self {
self.authoring_source = Some(source.into());
self
}
pub fn with_projection_validator(mut self, validator: ConfigProjectionValidator) -> Self {
self.projection_validator = Some(validator);
self
}
fn validate(&self) -> Result<(), ConfigTreeError> {
if self
.authoring_source
.as_ref()
.is_some_and(|source| source.trim().is_empty())
{
return Err(ConfigTreeError::InvalidSchemaContribution(
"authoring schema source must not be empty".to_string(),
));
}
let expected = digest_bytes(self.source.as_bytes());
if self.source_digest != expected {
return Err(ConfigTreeError::InvalidSchemaContribution(format!(
@@ -591,27 +609,41 @@ impl WorkspaceConfigSchemaBundle {
.collect::<Vec<_>>()
.join(" & ")
};
let fingerprint = digest_bytes(
serde_json::to_vec(&(
CONFIG_SOURCE_CONTRACT_VERSION,
DECODAL_VERSION,
contributions
.iter()
.map(|contribution| {
(
contribution.provider_id.as_str(),
contribution.namespace.as_str(),
contribution.version.as_str(),
contribution.source_digest.as_str(),
contribution.projection_validator.as_ref(),
)
})
.collect::<Vec<_>>(),
digest_bytes(source.as_bytes()),
))
.expect("schema bundle fingerprint input serializes")
.as_slice(),
);
let mut fingerprint_input = serde_json::to_vec(&(
CONFIG_SOURCE_CONTRACT_VERSION,
DECODAL_VERSION,
contributions
.iter()
.map(|contribution| {
(
contribution.provider_id.as_str(),
contribution.namespace.as_str(),
contribution.version.as_str(),
contribution.source_digest.as_str(),
contribution.projection_validator.as_ref(),
)
})
.collect::<Vec<_>>(),
digest_bytes(source.as_bytes()),
))
.expect("schema bundle fingerprint input serializes");
let authoring_sources = contributions
.iter()
.filter_map(|contribution| {
contribution
.authoring_source
.as_deref()
.map(|source| (contribution.provider_id.as_str(), source))
})
.collect::<Vec<_>>();
// Preserve legacy fingerprints when no editor-only metadata is present.
if !authoring_sources.is_empty() {
fingerprint_input.extend(
serde_json::to_vec(&("authoring_sources", authoring_sources))
.expect("authoring schema fingerprint input serializes"),
);
}
let fingerprint = digest_bytes(&fingerprint_input);
Ok(Self {
contributions,
source,
@@ -619,6 +651,25 @@ impl WorkspaceConfigSchemaBundle {
})
}
fn authoring_source(&self) -> String {
if self.contributions.is_empty() {
return self.source.clone();
}
self.contributions
.iter()
.map(|contribution| {
format!(
"({})",
contribution
.authoring_source
.as_deref()
.unwrap_or(&contribution.source)
)
})
.collect::<Vec<_>>()
.join(" & ")
}
pub fn empty() -> Self {
Self::compose(Vec::new()).expect("empty schema bundle is valid")
}
@@ -751,26 +802,32 @@ pub struct EvaluationResult {
pub projection_digest: String,
}
#[derive(Debug, Clone, PartialEq, Eq)]
enum ConfigCompletionPathSegment {
Field(String),
Index(usize),
}
#[derive(Debug, Clone, PartialEq, Eq)]
struct ConfigFieldCompletionContext {
schema_path: Vec<String>,
schema_path: Vec<ConfigCompletionPathSegment>,
from: usize,
}
#[derive(Debug)]
enum ConfigCompletionContainer {
Object {
schema_path: Vec<String>,
schema_path: Vec<ConfigCompletionPathSegment>,
pending_path: Vec<String>,
last_identifier_from: Option<usize>,
trailing_dot: bool,
reading_value: bool,
},
Array {
schema_path: Vec<String>,
schema_path: Vec<ConfigCompletionPathSegment>,
},
Other {
schema_path: Vec<String>,
schema_path: Vec<ConfigCompletionPathSegment>,
},
}
@@ -855,9 +912,18 @@ fn config_field_completion_context(
});
}
SyntaxTokenKind::LBracket => {
let schema_path = pending_container_path(&containers);
let mut schema_path = pending_container_path(&containers);
schema_path.push(ConfigCompletionPathSegment::Index(0));
containers.push(ConfigCompletionContainer::Array { schema_path });
}
SyntaxTokenKind::Comma => {
if let Some(ConfigCompletionContainer::Array { schema_path }) =
containers.last_mut()
&& let Some(ConfigCompletionPathSegment::Index(index)) = schema_path.last_mut()
{
*index += 1;
}
}
SyntaxTokenKind::RBracket => {
pop_container(&mut containers, |container| {
matches!(container, ConfigCompletionContainer::Array { .. })
@@ -943,7 +1009,12 @@ fn config_field_completion_context(
return None;
};
let mut schema_path = schema_path.clone();
schema_path.extend(pending_path.iter().cloned());
schema_path.extend(
pending_path
.iter()
.cloned()
.map(ConfigCompletionPathSegment::Field),
);
Some(ConfigFieldCompletionContext {
schema_path,
from: if *trailing_dot {
@@ -954,14 +1025,25 @@ fn config_field_completion_context(
})
}
fn pending_container_path(containers: &[ConfigCompletionContainer]) -> Vec<String> {
fn pending_container_path(
containers: &[ConfigCompletionContainer],
) -> Vec<ConfigCompletionPathSegment> {
match containers.last() {
Some(ConfigCompletionContainer::Object {
schema_path,
pending_path,
reading_value: true,
..
}) => schema_path.iter().chain(pending_path).cloned().collect(),
}) => schema_path
.iter()
.cloned()
.chain(
pending_path
.iter()
.cloned()
.map(ConfigCompletionPathSegment::Field),
)
.collect(),
Some(ConfigCompletionContainer::Array { schema_path })
| Some(ConfigCompletionContainer::Other { schema_path }) => schema_path.clone(),
_ => Vec::new(),
@@ -1201,12 +1283,37 @@ impl SnapshotEnvironment {
if self.schema_bundle.is_some()
&& let Some(context) = config_field_completion_context(source, utf8_byte_offset)
{
let mut member_source = format!("{WORKSPACE_CONFIG_SCHEMA_GLOBAL}.");
member_source.push_str(&context.schema_path.join("."));
if !context.schema_path.is_empty() && context.from == utf8_byte_offset {
member_source.push('.');
let (schema_path, prefix) = if context.from == utf8_byte_offset {
(context.schema_path.as_slice(), "")
} else {
let Some((ConfigCompletionPathSegment::Field(prefix), schema_path)) =
context.schema_path.split_last()
else {
return self.complete(entrypoint, source, utf8_byte_offset, explicit);
};
(schema_path, prefix.as_str())
};
let has_array = schema_path
.iter()
.any(|segment| matches!(segment, ConfigCompletionPathSegment::Index(_)));
let mut member_source = WORKSPACE_CONFIG_SCHEMA_GLOBAL.to_owned();
// Ordinary object paths retain the language service's member traversal,
// but both branches install the composed authoring schema.
if !has_array {
for segment in schema_path {
if let ConfigCompletionPathSegment::Field(field) = segment {
member_source.push('.');
member_source.push_str(field);
}
}
}
let mut completion = LanguageService::new(self).complete(
member_source.push('.');
member_source.push_str(prefix);
let completion_environment = ConfigFieldCompletionEnvironment {
environment: self,
schema_path: if has_array { schema_path } else { &[] },
};
let mut completion = LanguageService::new(completion_environment).complete(
entrypoint.as_str(),
&member_source,
member_source.len(),
@@ -1270,6 +1377,91 @@ impl HostEnvironment for &SnapshotEnvironment {
}
}
// Decodal has no indexed member syntax. Resolve array ranges only in this
// completion environment, then reuse the language service's member behavior.
struct ConfigFieldCompletionEnvironment<'a> {
environment: &'a SnapshotEnvironment,
schema_path: &'a [ConfigCompletionPathSegment],
}
impl HostEnvironment for ConfigFieldCompletionEnvironment<'_> {
type Loader = SnapshotImportLoader;
fn create_loader(&self) -> Self::Loader {
self.environment.create_loader()
}
fn configure_engine(&self, engine: &mut Engine<Self::Loader>) -> decodal::Result<()> {
use decodal::runtime::{ConcreteValue, ObjectField, ObjectValue, RuntimeValue};
let Some(bundle) = &self.environment.schema_bundle else {
return Ok(());
};
let module = engine.add_root_source(
WORKSPACE_CONFIG_SCHEMA_SOURCE,
WORKSPACE_CONFIG_SCHEMA_SOURCE,
&bundle.authoring_source(),
)?;
let mut schema = Some(engine.eval_module(module)?);
for segment in self.schema_path {
let Some(value) = schema else {
break;
};
schema = match segment {
ConfigCompletionPathSegment::Field(field) => engine
.value_fields(&value)?
.unwrap_or_default()
.into_iter()
.find(|(name, _)| name == field)
.map(|(_, value)| value),
ConfigCompletionPathSegment::Index(index) => {
let element = match value {
RuntimeValue::Concrete(ConcreteValue::Array(elements)) => {
elements.get(*index).copied()
}
RuntimeValue::Abstract(range) => {
range.constraints.iter().find_map(|entry| {
if let decodal::Constraint::ArrayItems(element) = entry.constraint {
Some(element)
} else {
None
}
})
}
_ => None,
};
if let Some(element) = element {
// value_fields is the public API for forcing lazy fields.
// Wrap the element thunk without materializing its schema.
let object = RuntimeValue::Concrete(ConcreteValue::Object(ObjectValue {
fields: vec![ObjectField {
name: "element".into(),
value: element,
span: Span::default(),
}],
rest: None,
}));
engine
.value_fields(&object)?
.and_then(|fields| fields.into_iter().next().map(|(_, value)| value))
} else {
None
}
}
};
}
if let Some(schema) = schema {
engine.bind_global_runtime(WORKSPACE_CONFIG_SCHEMA_GLOBAL, schema);
} else {
engine.bind_global(
WORKSPACE_CONFIG_SCHEMA_GLOBAL,
Value::object([] as [(&str, Value); 0]),
)?;
}
Ok(())
}
}
#[derive(Debug, Clone)]
pub struct SnapshotImportLoader {
snapshot: ConfigTreeSnapshot,
@@ -1890,7 +2082,7 @@ mod tests {
assert_eq!(
config_field_completion_context(root, root_cursor),
Some(ConfigFieldCompletionContext {
schema_path: vec!["pro".into()],
schema_path: vec![ConfigCompletionPathSegment::Field("pro".into())],
from: root_cursor - 3,
})
);
@@ -1899,7 +2091,10 @@ mod tests {
assert_eq!(
config_field_completion_context(nested, nested_cursor),
Some(ConfigFieldCompletionContext {
schema_path: vec!["profile".into(), "def".into()],
schema_path: vec![
ConfigCompletionPathSegment::Field("profile".into()),
ConfigCompletionPathSegment::Field("def".into()),
],
from: nested_cursor - 3,
})
);
@@ -1908,7 +2103,12 @@ mod tests {
assert_eq!(
config_field_completion_context(array, array_cursor),
Some(ConfigFieldCompletionContext {
schema_path: vec!["profile".into(), "entries".into(), "sel".into()],
schema_path: vec![
ConfigCompletionPathSegment::Field("profile".into()),
ConfigCompletionPathSegment::Field("entries".into()),
ConfigCompletionPathSegment::Index(0),
ConfigCompletionPathSegment::Field("sel".into()),
],
from: array_cursor - 3,
})
);
@@ -1991,6 +2191,420 @@ mod tests {
);
}
fn profile_entries_completion_schema() -> WorkspaceConfigSchemaBundle {
WorkspaceConfigSchemaBundle::compose([ConfigSchemaContribution::new(
"builtin:profile",
"profile",
"1",
"{ profile = { default_profile = String; entries = [...{ selector = String; profile = { slug = String; worker = { mode = String; }; }; }]; }; }",
)
.unwrap()])
.unwrap()
}
#[test]
fn completion_projects_array_element_and_nested_profile_fields() {
let snapshot = ConfigTreeSnapshot::from_entries(1, [entry("main.dcdl", "{}")]).unwrap();
let environment = SnapshotEnvironment::new(snapshot)
.with_schema_bundle(profile_entries_completion_schema());
for (marked_source, expected, prefix_len) in [
(
"{ profile.entries = [{ sel| }] } as WorkspaceConfigSchema",
"selector",
3,
),
(
"{ profile = { entries = [{ pro| }] } } as WorkspaceConfigSchema",
"profile",
3,
),
(
"{ profile.entries = [{ | }] } as WorkspaceConfigSchema",
"selector",
0,
),
(
"{ profile.entries = [{ | }] } as WorkspaceConfigSchema",
"profile",
0,
),
(
"{ profile.entries = [{ profile = { slu| } }] } as WorkspaceConfigSchema",
"slug",
3,
),
(
"{ profile.entries = [{ profile = { | } }] } as WorkspaceConfigSchema",
"worker",
0,
),
(
"{ profile.entries = [{ profile.worker.mo| }] } as WorkspaceConfigSchema",
"mode",
2,
),
(
"{ profile.entries = [{ profile.worker.| }] } as WorkspaceConfigSchema",
"mode",
0,
),
(
"{ profile.entries = [{ profile = { worker = { mo| } } }] } as WorkspaceConfigSchema",
"mode",
2,
),
(
"{ profile.entries = [{}, { sel| }] } as WorkspaceConfigSchema",
"selector",
3,
),
(
"{ profile.entries = [{ selector = \"日本語\"; profile = { slu|g } }] } as WorkspaceConfigSchema",
"slug",
3,
),
(
"{ profile.entries = [{ pro|file }] } as WorkspaceConfigSchema",
"profile",
3,
),
(
"{ profile.entries = [({ profile = { worker = { mo| } } })] } as WorkspaceConfigSchema",
"mode",
2,
),
] {
let cursor = marked_source.find('|').unwrap();
let source = marked_source.replace('|', "");
let completion = environment
.complete_config(&path("main.dcdl"), &source, cursor, true)
.unwrap()
.unwrap_or_else(|| panic!("missing completion for {marked_source}"));
assert_eq!(completion.from, cursor - prefix_len, "{marked_source}");
assert!(
completion.items.iter().any(|item| item.label == expected),
"{marked_source}: {completion:?}"
);
}
}
#[test]
fn completion_tracks_fixed_array_positions_and_nested_array_ranges() {
let snapshot = ConfigTreeSnapshot::from_entries(1, [entry("main.dcdl", "{}")]).unwrap();
let schema = WorkspaceConfigSchemaBundle::compose([ConfigSchemaContribution::new(
"builtin:profile",
"profile",
"1",
"{ profile = { entries = [{ selector = String; }, { profile = { slug = String; worker = { mode = String; }; }; }]; matrix = [...[...{ selector = String; }]]; }; }",
)
.unwrap()])
.unwrap();
let environment = SnapshotEnvironment::new(snapshot).with_schema_bundle(schema);
for (marked_source, expected, unexpected) in [
(
"{ profile.entries = [{ | }] } as WorkspaceConfigSchema",
"selector",
"profile",
),
(
"{ profile.entries = [{}, { | }] } as WorkspaceConfigSchema",
"profile",
"selector",
),
(
"{ profile.entries = [{}, { profile.worker.mo| }] } as WorkspaceConfigSchema",
"mode",
"selector",
),
(
"{ profile.matrix = [[{}, { sel| }]] } as WorkspaceConfigSchema",
"selector",
"profile",
),
(
"{ profile.matrix = [[], [{ sel| }]] } as WorkspaceConfigSchema",
"selector",
"profile",
),
] {
let cursor = marked_source.find('|').unwrap();
let source = marked_source.replace('|', "");
let completion = environment
.complete_config(&path("main.dcdl"), &source, cursor, true)
.unwrap()
.unwrap_or_else(|| panic!("missing completion for {marked_source}"));
assert!(
completion.items.iter().any(|item| item.label == expected),
"{marked_source}: {completion:?}"
);
assert!(
!completion.items.iter().any(|item| item.label == unexpected),
"{marked_source}: {completion:?}"
);
}
for marked_source in [
"{ profile.entries = [{}, {}, { | }] } as WorkspaceConfigSchema",
"{ profile.entries = [{ missing = { | } }] } as WorkspaceConfigSchema",
] {
let cursor = marked_source.find('|').unwrap();
let source = marked_source.replace('|', "");
let completion = environment
.complete_config(&path("main.dcdl"), &source, cursor, true)
.unwrap();
assert!(
completion.is_none_or(|completion| !completion
.items
.iter()
.any(|item| matches!(item.label.as_str(), "selector" | "profile"))),
"{marked_source}"
);
}
}
#[test]
fn completion_preserves_general_schema_member_behavior() {
let snapshot = ConfigTreeSnapshot::from_entries(1, [entry("main.dcdl", "{}")]).unwrap();
let environment = SnapshotEnvironment::new(snapshot)
.with_schema_bundle(profile_entries_completion_schema());
for source in [
"WorkspaceConfigSchema.pro",
"WorkspaceConfigSchema.profile.",
"WorkspaceConfigSchema.profile.entries.",
"WorkspaceConfigSchema.profile.default_pro",
"let schema = WorkspaceConfigSchema; in schema.profile.def",
] {
assert_eq!(
environment
.complete_config(&path("main.dcdl"), source, source.len(), true)
.unwrap(),
environment
.complete(&path("main.dcdl"), source, source.len(), true)
.unwrap(),
"{source}"
);
}
}
#[test]
fn completion_array_schema_retains_nested_profile_diagnostics() {
let valid = "{ profile = { default_profile = \"default\"; entries = [{ selector = \"default\"; profile = { slug = \"default\"; worker = { mode = \"interactive\"; }; }; }]; }; } as WorkspaceConfigSchema";
let snapshot = ConfigTreeSnapshot::from_entries(7, [entry("main.dcdl", valid)]).unwrap();
let environment = SnapshotEnvironment::new(snapshot)
.with_schema_bundle(profile_entries_completion_schema());
assert!(environment.analyze(&path("main.dcdl"), None).is_empty());
for (source, field) in [
(
valid.replace("selector = \"default\"", "selector = 42"),
"selector",
),
(valid.replace("slug = \"default\"", "slug = 42"), "slug"),
(valid.replace("mode = \"interactive\"", "mode = 42"), "mode"),
(
valid.replace("mode = \"interactive\"", "typo = \"interactive\""),
"typo",
),
] {
let diagnostics = environment.analyze(&path("main.dcdl"), Some(&source));
assert!(
diagnostics.iter().any(|diagnostic| {
diagnostic.kind == "constraint_violation"
&& diagnostic.path == path("main.dcdl")
&& diagnostic.revision == 7
&& diagnostic.span.end_byte > diagnostic.span.start_byte
&& (diagnostic.message.contains(field)
|| diagnostic
.labels
.iter()
.any(|label| label.message.contains(field)))
}),
"{field}: {diagnostics:#?}"
);
}
}
#[test]
fn completion_authoring_schema_preserves_omitted_profile_fields_in_evaluation() {
let schema = WorkspaceConfigSchemaBundle::compose([ConfigSchemaContribution::new(
"builtin:profile",
"profile",
"1",
"{ profile = { defaults = {...Unknown}; entries = [...{ selector = String; profile = {...Unknown}; }]; }; }",
)
.unwrap()
.with_authoring_source(
"{ profile = { defaults = { slug = String; worker = { mode = String; }; }; entries = [...{ selector = String; profile = { slug = String; worker = { mode = String; }; }; }]; }; }",
)])
.unwrap();
let source = "{ profile = { defaults = {}; entries = [{ selector = \"default\"; profile = {}; }]; }; } as WorkspaceConfigSchema";
let snapshot = ConfigTreeSnapshot::from_entries(1, [entry("main.dcdl", source)]).unwrap();
let environment = SnapshotEnvironment::new(snapshot).with_schema_bundle(schema.clone());
assert!(environment.analyze(&path("main.dcdl"), None).is_empty());
let contract = ToolchainContract::with_schema_bundle(1, vec![path("main.dcdl")], 1, schema);
let evaluated = environment.evaluate_contract(&contract).unwrap();
assert_eq!(
evaluated.projections[0].data_json,
serde_json::json!({"profile": {"defaults": {}, "entries": [{"selector": "default", "profile": {}}]}})
);
// Both ordinary object paths and array-element paths use editor metadata.
for (marked_source, expected) in [
(
"{ profile.defaults = { slu| } } as WorkspaceConfigSchema",
"slug",
),
(
"{ profile.defaults.worker = { mo| } } as WorkspaceConfigSchema",
"mode",
),
(
"{ profile.entries = [{ pro| }] } as WorkspaceConfigSchema",
"profile",
),
(
"{ profile.entries = [{ profile = { slu| } }] } as WorkspaceConfigSchema",
"slug",
),
(
"{ profile.entries = [{ profile.worker = { mo| } }] } as WorkspaceConfigSchema",
"mode",
),
(
"{ profile.entries = [{ profile = { | } }] } as WorkspaceConfigSchema",
"worker",
),
] {
let cursor = marked_source.find('|').unwrap();
let source = marked_source.replace('|', "");
let completion = environment
.complete_config(&path("main.dcdl"), &source, cursor, true)
.unwrap()
.unwrap_or_else(|| panic!("missing completion for {marked_source}"));
assert!(
completion.items.iter().any(|item| item.label == expected),
"{marked_source}: {completion:?}"
);
}
// Completion does not install its schema into later analysis/evaluation.
assert!(environment.analyze(&path("main.dcdl"), None).is_empty());
assert_eq!(environment.evaluate_contract(&contract).unwrap(), evaluated);
}
#[test]
fn completion_authoring_sources_compose_with_validation_source_fallbacks() {
let schema = WorkspaceConfigSchemaBundle::compose([
ConfigSchemaContribution::new(
"builtin:editor",
"editor",
"1",
"{ editor = {...Unknown}; }",
)
.unwrap()
.with_authoring_source(
"{ editor = { theme = String; settings = { enabled = Bool; }; }; }",
),
ConfigSchemaContribution::new(
"builtin:web",
"web",
"1",
"{ web = { enabled = Bool; }; }",
)
.unwrap(),
])
.unwrap();
let snapshot = ConfigTreeSnapshot::from_entries(1, [entry("main.dcdl", "{}")]).unwrap();
let environment = SnapshotEnvironment::new(snapshot).with_schema_bundle(schema);
for (marked_source, expected) in [
("{ ed| } as WorkspaceConfigSchema", "editor"),
("{ editor = { th| } } as WorkspaceConfigSchema", "theme"),
(
"{ editor.settings = { en| } } as WorkspaceConfigSchema",
"enabled",
),
("{ web = { en| } } as WorkspaceConfigSchema", "enabled"),
] {
let cursor = marked_source.find('|').unwrap();
let source = marked_source.replace('|', "");
let completion = environment
.complete_config(&path("main.dcdl"), &source, cursor, true)
.unwrap()
.unwrap();
assert!(
completion.items.iter().any(|item| item.label == expected),
"{marked_source}: {completion:?}"
);
}
}
#[test]
fn authoring_schema_metadata_is_optional_validated_and_fingerprinted() {
let contribution = ConfigSchemaContribution::new(
"builtin:editor",
"editor",
"1",
"{ editor = {...Unknown}; }",
)
.unwrap();
let serialized = serde_json::to_value(&contribution).unwrap();
assert!(serialized.get("authoring_source").is_none());
assert_eq!(
serde_json::from_value::<ConfigSchemaContribution>(serialized).unwrap(),
contribution
);
let legacy = WorkspaceConfigSchemaBundle::compose([contribution.clone()]).unwrap();
let legacy_input = serde_json::to_vec(&(
CONFIG_SOURCE_CONTRACT_VERSION,
DECODAL_VERSION,
vec![(
contribution.provider_id.as_str(),
contribution.namespace.as_str(),
contribution.version.as_str(),
contribution.source_digest.as_str(),
contribution.projection_validator.as_ref(),
)],
digest_bytes(legacy.source.as_bytes()),
))
.unwrap();
assert_eq!(legacy.fingerprint, digest_bytes(&legacy_input));
legacy.validate().unwrap();
let authoring = contribution
.clone()
.with_authoring_source("{ editor = { theme = String; }; }");
assert_eq!(authoring.source_digest, contribution.source_digest);
let serialized = serde_json::to_value(&authoring).unwrap();
assert_eq!(
serialized["authoring_source"],
"{ editor = { theme = String; }; }"
);
assert_eq!(
serde_json::from_value::<ConfigSchemaContribution>(serialized).unwrap(),
authoring
);
let authored = WorkspaceConfigSchemaBundle::compose([authoring]).unwrap();
authored.validate().unwrap();
assert_eq!(authored.source, legacy.source);
assert_ne!(authored.fingerprint, legacy.fingerprint);
assert_ne!(
ToolchainContract::with_schema_bundle(1, vec![path("main.dcdl")], 1, authored.clone())
.fingerprint,
ToolchainContract::with_schema_bundle(1, vec![path("main.dcdl")], 1, legacy)
.fingerprint,
);
let changed = WorkspaceConfigSchemaBundle::compose([contribution
.clone()
.with_authoring_source("{ editor = { theme = String; mode = String; }; }")])
.unwrap();
assert_ne!(changed.fingerprint, authored.fingerprint);
let mut tampered = authored;
tampered.contributions[0].authoring_source =
changed.contributions[0].authoring_source.clone();
assert!(tampered.validate().is_err());
for source in ["", " \n\t "] {
assert!(
matches!(WorkspaceConfigSchemaBundle::compose([contribution.clone().with_authoring_source(source)]), Err(ConfigTreeError::InvalidSchemaContribution(message)) if message.contains("authoring"))
);
}
}
#[test]
fn relative_imports_and_completion_share_the_snapshot_namespace() {
let snapshot = ConfigTreeSnapshot::from_entries(
+4
View File
@@ -4496,6 +4496,10 @@ pub struct ConfigSchemaContribution {
#[serde(default, skip_serializing_if = "Option::is_none")]
#[cfg_attr(feature = "typescript", ts(optional))]
pub projection_validator: Option<ConfigProjectionValidator>,
/// Completion-only shape for partial values; never materialized as defaults.
#[serde(default, skip_serializing_if = "Option::is_none")]
#[cfg_attr(feature = "typescript", ts(optional))]
pub authoring_source: Option<String>,
pub source_digest: String,
}
+246 -28
View File
@@ -125,6 +125,35 @@ pub struct ProfileSourceArchiveInput {
impl ProfileSourceArchive {
pub fn build(input: ProfileSourceArchiveInput) -> Result<Self, ProfileArchiveError> {
Self::build_with_source_format(input, "decodal", "text/x-decodal")
}
/// Archive an already evaluated Profile value without Decodal evaluation or conversion.
pub fn build_evaluated_profile(
id: String,
selector: String,
value: serde_json::Value,
) -> Result<Self, ProfileArchiveError> {
let path = "profiles/evaluated.json".to_string();
let source = serde_json::to_string(&value)
.map_err(|err| ProfileArchiveError::Json(err.to_string()))?;
Self::build_with_source_format(
ProfileSourceArchiveInput {
id,
entrypoints: BTreeMap::from([(selector, path.clone())]),
imports: BTreeMap::new(),
sources: BTreeMap::from([(path, source)]),
},
"json",
"application/json",
)
}
fn build_with_source_format(
input: ProfileSourceArchiveInput,
kind: &str,
content_type: &str,
) -> Result<Self, ProfileArchiveError> {
if input.sources.len() > MAX_SOURCES {
return Err(ProfileArchiveError::LimitExceeded("source count"));
}
@@ -145,8 +174,8 @@ impl ProfileSourceArchive {
source_meta.push(ProfileSourceArchiveSource {
path: path.clone(),
source_key: path.clone(),
kind: "decodal".to_string(),
content_type: default_decodal_content_type(),
kind: kind.to_string(),
content_type: content_type.to_string(),
digest: sha256_hex(source.as_bytes()),
size_bytes: size,
});
@@ -288,7 +317,17 @@ impl VerifiedProfileSourceArchive {
actual: source.source_key.clone(),
});
}
if source.content_type != default_decodal_content_type() {
let expected_content_type = match source.kind.as_str() {
"decodal" => "text/x-decodal",
"json" => "application/json",
_ => {
return Err(ProfileArchiveError::UnsupportedSource {
path: source.path.clone(),
kind: source.kind.clone(),
});
}
};
if source.content_type != expected_content_type {
return Err(ProfileArchiveError::UnsupportedSource {
path: source.path.clone(),
kind: source.content_type.clone(),
@@ -297,12 +336,6 @@ impl VerifiedProfileSourceArchive {
if !manifest_paths.insert(source.path.clone()) {
return Err(ProfileArchiveError::DuplicateSource(source.path.clone()));
}
if source.kind != "decodal" {
return Err(ProfileArchiveError::UnsupportedSource {
path: source.path.clone(),
kind: source.kind.clone(),
});
}
let content = entries
.get(&source.path)
.ok_or_else(|| ProfileArchiveError::MissingSource(source.path.clone()))?;
@@ -373,30 +406,50 @@ impl VerifiedProfileSourceArchive {
.entrypoints
.get(selector)
.ok_or_else(|| ProfileArchiveError::MissingEntrypoint(selector.to_string()))?;
let mut engine = Engine::new(ArchiveSourceLoader { archive: self });
let source = self
.sources
.get(path)
.ok_or_else(|| ProfileArchiveError::MissingSource(path.clone()))?;
let module = engine.add_root_source(path, path, source).map_err(|err| {
ProfileArchiveError::Decodal {
path: path.clone(),
message: format!("{err:?}"),
let metadata = self
.manifest
.sources
.iter()
.find(|source| &source.path == path)
.ok_or_else(|| ProfileArchiveError::MissingSource(path.clone()))?;
let json = match metadata.kind.as_str() {
"json" => serde_json::from_str(source)
.map_err(|err| ProfileArchiveError::Json(err.to_string()))?,
"decodal" => {
let mut engine = Engine::new(ArchiveSourceLoader { archive: self });
let module = engine.add_root_source(path, path, source).map_err(|err| {
ProfileArchiveError::Decodal {
path: path.clone(),
message: format!("{err:?}"),
}
})?;
let value =
engine
.eval_module(module)
.map_err(|err| ProfileArchiveError::Decodal {
path: path.clone(),
message: format!("{err:?}"),
})?;
let data =
engine
.materialize(&value)
.map_err(|err| ProfileArchiveError::Decodal {
path: path.clone(),
message: format!("{err:?}"),
})?;
decodal_data_to_json(&data)
}
})?;
let value = engine
.eval_module(module)
.map_err(|err| ProfileArchiveError::Decodal {
path: path.clone(),
message: format!("{err:?}"),
})?;
let data = engine
.materialize(&value)
.map_err(|err| ProfileArchiveError::Decodal {
path: path.clone(),
message: format!("{err:?}"),
})?;
let json = decodal_data_to_json(&data);
_ => {
return Err(ProfileArchiveError::UnsupportedSource {
path: path.clone(),
kind: metadata.kind.clone(),
});
}
};
let resolved = resolve_profile_artifact_value(
json,
ProfileSource::Archive {
@@ -674,6 +727,171 @@ mod tests {
.unwrap()
}
fn evaluated_profile() -> serde_json::Value {
serde_json::json!({
"slug": "evaluated",
"description": "Evaluated profile",
"scope": "workspace_read",
"worker": { "mode": "wip" },
"feature": {
"task": { "enabled": true },
"memory": { "enabled": true },
"sub_worker": { "enabled": true }
}
})
}
fn evaluated_archive() -> ProfileSourceArchive {
ProfileSourceArchive::build_evaluated_profile(
"evaluated-archive".to_string(),
"project:evaluated".to_string(),
evaluated_profile(),
)
.unwrap()
}
#[test]
fn evaluated_json_preserves_explicit_worker_mode_and_features() {
let archive = evaluated_archive();
let verified = archive.verify().unwrap();
let source = &verified.manifest().sources[0];
assert_eq!(source.kind, "json");
assert_eq!(source.content_type, "application/json");
assert_eq!(verified.manifest().imports.len(), 0);
assert_eq!(
serde_json::from_str::<serde_json::Value>(&verified.sources[&source.path]).unwrap(),
evaluated_profile()
);
let root = tempfile::tempdir().unwrap();
let manifest = verified
.resolve_profile("project:evaluated", root.path(), "evaluated-worker")
.unwrap();
let json = serde_json::to_value(&manifest).unwrap();
assert_eq!(manifest.worker.name, "evaluated-worker");
assert_eq!(json["worker"]["mode"], "wip");
assert_eq!(json["feature"]["task"]["enabled"], true);
assert_eq!(json["feature"]["memory"]["profile"]["enabled"], true);
assert_eq!(json["feature"]["sub_worker"]["enabled"], true);
}
// Recompute the outer digest so verification reaches the source-level checks.
fn repack_archive(
archive: &mut ProfileSourceArchive,
manifest: &ProfileSourceArchiveManifest,
sources: &BTreeMap<String, String>,
) {
let mut content = Vec::new();
{
let mut builder = Builder::new(&mut content);
append_bytes(
&mut builder,
MANIFEST_PATH,
&serde_json::to_vec(manifest).unwrap(),
)
.unwrap();
for (path, source) in sources {
append_bytes(&mut builder, path, source.as_bytes()).unwrap();
}
builder.finish().unwrap();
}
archive.reference.digest = sha256_hex(&content);
archive.reference.size_bytes = content.len() as u64;
archive.content = content;
}
#[test]
fn evaluated_json_reuses_source_size_limit() {
assert!(matches!(
ProfileSourceArchive::build_evaluated_profile(
"oversized".to_string(),
"default".to_string(),
serde_json::Value::String("x".repeat(MAX_SOURCE_BYTES as usize)),
),
Err(ProfileArchiveError::LimitExceeded("source bytes"))
));
}
#[test]
fn evaluated_json_rejects_archive_and_source_tampering() {
let mut archive = evaluated_archive();
archive.content[0] ^= 1;
assert!(matches!(
archive.verify(),
Err(ProfileArchiveError::ArchiveDigestMismatch { .. })
));
let mut archive = evaluated_archive();
let mut verified = archive.verify().unwrap();
let path = verified.manifest.sources[0].path.clone();
verified
.sources
.get_mut(&path)
.unwrap()
.replace_range(0..1, "[");
repack_archive(&mut archive, &verified.manifest, &verified.sources);
assert!(matches!(
archive.verify(),
Err(ProfileArchiveError::SourceDigestMismatch { path: actual, .. }) if actual == path
));
}
#[test]
fn evaluated_json_requires_matching_source_kind_and_content_type() {
for (kind, content_type) in [
("json", "text/x-decodal"),
("decodal", "application/json"),
("unknown", "application/json"),
] {
let mut archive = evaluated_archive();
let mut verified = archive.verify().unwrap();
verified.manifest.sources[0].kind = kind.to_string();
verified.manifest.sources[0].content_type = content_type.to_string();
repack_archive(&mut archive, &verified.manifest, &verified.sources);
assert!(matches!(
archive.verify(),
Err(ProfileArchiveError::UnsupportedSource { .. })
));
}
}
#[test]
fn evaluated_json_does_not_fallback_to_filesystem_or_default() {
let root = tempfile::tempdir().unwrap();
let profiles = root.path().join("profiles");
std::fs::create_dir(&profiles).unwrap();
let local_profile = profiles.join("evaluated.json");
std::fs::write(
&local_profile,
serde_json::to_vec(&evaluated_profile()).unwrap(),
)
.unwrap();
let verified = evaluated_archive().verify().unwrap();
for selector in [
"default",
"profiles/evaluated.json",
local_profile.to_str().unwrap(),
] {
assert!(matches!(
verified.resolve_profile(selector, root.path(), "evaluated-worker"),
Err(ProfileArchiveError::MissingEntrypoint(actual)) if actual == selector
));
}
// A valid local file must not rescue an invalid archived Profile value either.
let invalid = ProfileSourceArchive::build_evaluated_profile(
"invalid".to_string(),
"project:evaluated".to_string(),
serde_json::json!({ "not_a_profile_field": true }),
)
.unwrap()
.verify()
.unwrap();
assert!(matches!(
invalid.resolve_profile("project:evaluated", root.path(), "evaluated-worker"),
Err(ProfileArchiveError::Profile { .. })
));
}
#[test]
fn build_and_verify_archive() {
let archive = sample_archive();
+5 -1
View File
@@ -140,7 +140,11 @@ pub(crate) fn validate_workspace_config_candidate_projections(
};
crate::prompt_settings::validate_evaluated_prompt_catalog(&candidate.evaluation)?;
if has_provider("builtin:profile") {
crate::profile_settings::project_profiles_from_workspace_config(workspace_id, &state)?;
crate::profile_settings::project_profiles_from_evaluation(
workspace_id,
&state,
&candidate.evaluation,
)?;
}
if has_provider("builtin:runtime") {
crate::runtime_settings::project_runtime_from_workspace_config(workspace_id, &state)?;
+152 -241
View File
@@ -1,7 +1,7 @@
use std::collections::BTreeMap;
use std::path::{Component, Path, PathBuf};
use std::path::Path;
use config_source::{ConfigContentType, ConfigSchemaContribution, VirtualPath};
use config_source::ConfigSchemaContribution;
use manifest::{ProfileSource, builtin_profile_catalog_snapshot, resolve_profile_artifact_value};
use serde::Deserialize;
use server_api::{
@@ -21,24 +21,19 @@ use crate::config_source::{
use crate::store::WorkspaceRecord;
use crate::{Error, Result};
const PROFILE_SCHEMA_SOURCE: &str = r#"{
profile = {
default_profile = String default "builtin:companion";
entries = [...{
selector = String;
source = String;
label = String default "";
description = String default "";
}] default [];
};
}"#;
const PROFILE_SCHEMA_SOURCE: &str = include_str!("../../../resources/config-schema/profile.dcdl");
#[derive(Debug, Default)]
pub struct ProfileConfigSchemaProvider;
impl WorkspaceConfigSchemaProvider for ProfileConfigSchemaProvider {
fn contribution(&self) -> Result<ConfigSchemaContribution> {
ConfigSchemaContribution::new("builtin:profile", "profile", "1", PROFILE_SCHEMA_SOURCE)
ConfigSchemaContribution::new("builtin:profile", "profile", "2", PROFILE_SCHEMA_SOURCE)
.map(|schema| {
schema.with_authoring_source(include_str!(
"../../../resources/config-schema/profile-authoring.dcdl"
))
})
.map_err(|error| Error::Config(error.to_string()))
}
}
@@ -59,7 +54,7 @@ struct VirtualProfileSection {
#[serde(deny_unknown_fields)]
struct VirtualProfileEntry {
selector: String,
source: String,
profile: serde_json::Value,
label: String,
description: String,
}
@@ -68,7 +63,6 @@ struct VirtualProfileEntry {
pub struct ProfileConfigProjection {
pub settings: ProfileSettingsResponse,
entries: BTreeMap<String, VirtualProfileEntry>,
sources: BTreeMap<String, String>,
}
pub fn project_profiles_from_workspace_config(
@@ -96,23 +90,42 @@ pub fn project_profiles_from_workspace_config(
"Profile projection digest mismatch for Workspace {workspace_id}"
)));
}
project_profiles_from_evaluation(workspace_id, state, &evaluation)
}
pub(crate) fn project_profiles_from_evaluation(
workspace_id: &str,
state: &WorkspaceConfigState,
evaluation: &config_source::EvaluationResult,
) -> Result<ProfileConfigProjection> {
if evaluation.projection_digest != state.projection_digest {
return Err(Error::Config(
"Profile evaluation does not match Workspace config state".into(),
));
}
let projected = evaluation.projections.first().ok_or_else(|| {
Error::RegistryInconsistency("Workspace config has no active projection".to_string())
})?;
if projected
.data_json
.pointer("/profile/entries")
.and_then(serde_json::Value::as_array)
.is_some_and(|entries| entries.iter().any(|entry| entry.get("source").is_some()))
{
return Err(profile_validation_error(
"profile_source_registration_removed",
"profile.entries.source is no longer supported: replace source = \"profiles/name.dcdl\" with profile = import \"./profiles/name.dcdl\" (relative to the containing config file). Saved config is not rewritten.",
));
}
let config: VirtualProfileConfig = serde_json::from_value(projected.data_json.clone())
.map_err(|error| Error::RegistryInconsistency(error.to_string()))?;
let mut profiles = builtin_profile_summaries(Some(&config.profile.default_profile));
let mut entries = BTreeMap::new();
let sources = state
.snapshot
.entries
.iter()
.filter(|(_, entry)| entry.content_type == ConfigContentType::Decodal)
.map(|(path, entry)| (path.as_str().to_string(), entry.content.clone()))
.collect::<BTreeMap<_, _>>();
let mut source_summaries = Vec::new();
for entry in config.profile.entries {
if !entry.selector.starts_with("project:") {
if !entry.selector.starts_with("project:")
|| entry.selector.trim_start_matches("project:").is_empty()
{
return Err(profile_validation_error(
"profile_selector_invalid",
"Workspace Profile selectors must use project:*",
@@ -124,61 +137,43 @@ pub fn project_profiles_from_workspace_config(
"Workspace Profile selectors must be unique",
));
}
let source_path = VirtualPath::parse(&entry.source).map_err(|error| {
profile_validation_error("profile_source_path_invalid", &error.to_string())
})?;
let source_entry = state.snapshot.get(&source_path).ok_or_else(|| {
profile_validation_error(
"profile_source_missing",
&format!(
"Profile source {:?} is missing from the active config revision",
entry.source
),
)
})?;
if source_entry.content_type != ConfigContentType::Decodal {
return Err(profile_validation_error(
"profile_source_type_invalid",
"Profile sources must use Decodal content",
));
}
let archive_source = entry.source.clone();
let label = if entry.label.is_empty() {
entry.selector.trim_start_matches("project:").to_string()
} else {
entry.label.clone()
};
resolve_profile_artifact_value(
evaluate_profile_source(&state.snapshot, &source_path)?,
entry.profile.clone(),
ProfileSource::Archive {
archive_id: format!("workspace-config-r{}", state.snapshot.revision),
source: archive_source.clone(),
source: entry.selector.clone(),
},
Path::new("/"),
"workspace-config-validation",
)
.map_err(|error| profile_validation_error("profile_source_invalid", &error.to_string()))?;
.map_err(|error| profile_validation_error("profile_value_invalid", &error.to_string()))?;
profiles.push(WorkspaceProfileSummary {
profile_id: entry.selector.clone(),
selector: entry.selector.clone(),
label,
source_kind: "project".to_string(),
profile_source_id: Some(entry.source.clone()),
profile_source_id: Some(entry.selector.clone()),
description: (!entry.description.is_empty()).then(|| entry.description.clone()),
editable: false,
is_default: config.profile.default_profile == entry.selector,
diagnostics: Vec::new(),
});
let value_bytes = serde_json::to_vec(&entry.profile).expect("Profile value serializes");
source_summaries.push(WorkspaceProfileSourceSummary {
profile_source_id: entry.source.clone(),
display_path: entry.source.clone(),
kind: "virtual_config".to_string(),
content_type: "decodal".to_string(),
content_digest: source_entry.content_digest.clone(),
profile_source_id: entry.selector.clone(),
display_path: format!("profile.entries[{}].profile", source_summaries.len()),
kind: "evaluated_config".to_string(),
content_type: "application/json".to_string(),
content_digest: config_source::digest_bytes(&value_bytes),
provenance: WorkspaceProfileSourceProvenance::ProjectProfileSourceTree,
editable: false,
revision: state.snapshot.revision.to_string(),
size_bytes: source_entry.content.len() as u64,
size_bytes: value_bytes.len() as u64,
diagnostics: Vec::new(),
});
entries.insert(entry.selector.clone(), entry);
@@ -198,46 +193,16 @@ pub fn project_profiles_from_workspace_config(
registry_revision: format!("config:{}", state.snapshot.revision),
config_revision: Some(state.snapshot.revision),
tree_digest: Some(state.snapshot.digest.clone()),
projection_digest: Some(evaluation.projection_digest),
projection_digest: Some(evaluation.projection_digest.clone()),
default_profile: Some(config.profile.default_profile),
profiles,
sources: source_summaries,
diagnostics: Vec::new(),
},
entries,
sources,
})
}
fn evaluate_profile_source(
snapshot: &config_source::ConfigTreeSnapshot,
source_path: &VirtualPath,
) -> Result<serde_json::Value> {
let contract = config_source::ToolchainContract::with_schema_bundle(
config_source::DEFAULT_SCHEMA_VERSION,
vec![source_path.clone()],
config_source::DEFAULT_IMPORT_POLICY_VERSION,
config_source::WorkspaceConfigSchemaBundle::empty(),
);
let evaluation = config_source::SnapshotEnvironment::new(snapshot.clone())
.evaluate_contract(&contract)
.map_err(|diagnostics| {
profile_validation_error(
"profile_source_invalid",
&serde_json::to_string(&diagnostics)
.unwrap_or_else(|_| "Profile source evaluation failed".to_string()),
)
})?;
evaluation
.projections
.into_iter()
.next()
.map(|projection| projection.data_json)
.ok_or_else(|| {
profile_validation_error("profile_source_invalid", "Profile source has no projection")
})
}
pub fn selector_for_workspace_candidate(
projection: &ProfileConfigProjection,
profile: &str,
@@ -420,6 +385,16 @@ pub fn build_virtual_profile_config_bundle_with_prompt_projection(
selector: &str,
prompt_projection: &worker::WorkspacePromptProjection,
) -> Result<Option<ConfigBundle>> {
if projection.settings.workspace_id != workspace_id
|| projection.settings.config_revision != Some(state.snapshot.revision)
|| projection.settings.tree_digest.as_deref() != Some(state.snapshot.digest.as_str())
|| projection.settings.projection_digest.as_deref()
!= Some(state.projection_digest.as_str())
{
return Err(Error::Config(
"Profile projection does not match Workspace config state".into(),
));
}
validate_prompt_projection_matches_state(workspace_id, state, prompt_projection)?;
let prompt_catalog = prompt_projection.catalog().clone();
let profile_selector =
@@ -429,12 +404,7 @@ pub fn build_virtual_profile_config_bundle_with_prompt_projection(
))
})?;
let archive = match projection.entries.get(selector) {
Some(entry) => Some(build_virtual_profile_archive(
selector,
entry,
&projection.sources,
state,
)?),
Some(entry) => Some(build_virtual_profile_archive(selector, entry, state)?),
None => Some(builtin_profile_source_archive(&profile_selector).map_err(Error::Store)?),
};
let bundle_id = virtual_profile_bundle_id(
@@ -492,19 +462,14 @@ pub(crate) fn resolve_profile_manifest_from_config_bundle(
fn build_virtual_profile_archive(
selector: &str,
entry: &VirtualProfileEntry,
all_sources: &BTreeMap<String, String>,
state: &WorkspaceConfigState,
) -> Result<ProfileSourceArchive> {
let mut closure = BTreeMap::new();
let mut imports = BTreeMap::new();
collect_profile_import_closure(&entry.source, all_sources, &mut closure, &mut imports)?;
ProfileSourceArchive::build(ProfileSourceArchiveInput {
id: format!("workspace-config-profile-r{}", state.snapshot.revision),
entrypoints: BTreeMap::from([(selector.to_string(), entry.source.clone())]),
imports,
sources: closure,
})
.map_err(|error| profile_validation_error("profile_source_archive_invalid", &error.to_string()))
ProfileSourceArchive::build_evaluated_profile(
format!("workspace-config-profile-r{}", state.snapshot.revision),
selector.to_string(),
entry.profile.clone(),
)
.map_err(|error| profile_validation_error("profile_value_archive_invalid", &error.to_string()))
}
pub fn workspace_metadata_settings(
@@ -576,124 +541,6 @@ fn profile_validation_error(code: impl Into<String>, message: impl Into<String>)
}
}
fn collect_profile_import_closure(
current_path: &str,
all_sources: &BTreeMap<String, String>,
closure_sources: &mut BTreeMap<String, String>,
imports: &mut BTreeMap<String, String>,
) -> Result<()> {
if closure_sources.contains_key(current_path) {
return Ok(());
}
let content = all_sources.get(current_path).ok_or_else(|| {
profile_validation_error(
"profile_source_import_missing",
&format!("Profile source import closure is missing {current_path}"),
)
})?;
closure_sources.insert(current_path.to_string(), content.clone());
for specifier in collect_decodal_import_specifiers(content) {
let target = resolve_profile_source_import(current_path, &specifier)?;
if !all_sources.contains_key(&target) {
return Err(profile_validation_error(
"profile_source_import_missing",
&format!(
"Profile source import {specifier:?} from {current_path} resolves to missing {target}"
),
));
}
imports.insert(format!("{current_path}\0{specifier}"), target.clone());
collect_profile_import_closure(&target, all_sources, closure_sources, imports)?;
}
Ok(())
}
fn resolve_profile_source_import(current_path: &str, specifier: &str) -> Result<String> {
if specifier.is_empty() || specifier.contains("://") || Path::new(specifier).is_absolute() {
return Err(profile_validation_error(
"profile_source_import_invalid",
"Profile source import must be a virtual relative path",
));
}
let raw = specifier
.strip_prefix("project:")
.or_else(|| specifier.strip_prefix("workspace:"))
.unwrap_or(specifier);
if specifier.contains(':') && raw == specifier {
return Err(profile_validation_error(
"profile_source_import_invalid",
"Unsupported profile source import namespace",
));
}
let base = if raw.starts_with("profiles/") || raw.starts_with("./profiles/") {
PathBuf::from(raw)
} else {
Path::new(current_path)
.parent()
.map(Path::to_path_buf)
.unwrap_or_default()
.join(raw)
};
let normalized = normalize_virtual_profile_source_path(&base.to_string_lossy())?;
Ok(normalized)
}
fn normalize_virtual_profile_source_path(path: &str) -> Result<String> {
let mut normalized = PathBuf::new();
for component in Path::new(path).components() {
match component {
Component::CurDir => {}
Component::Normal(value) => normalized.push(value),
Component::ParentDir => {
if !normalized.pop() {
return Err(profile_validation_error(
"profile_source_import_invalid",
"Profile source import escapes the virtual tree",
));
}
}
Component::RootDir | Component::Prefix(_) => {
return Err(profile_validation_error(
"profile_source_import_invalid",
"Profile source import must be relative",
));
}
}
}
Ok(normalized.to_string_lossy().replace('\\', "/"))
}
fn collect_decodal_import_specifiers(content: &str) -> Vec<String> {
let mut specifiers = Vec::new();
for line in content.lines() {
let trimmed = line.trim_start();
if trimmed.starts_with("//") || trimmed.starts_with('#') {
continue;
}
let Some(index) = trimmed.find("import") else {
continue;
};
let after = trimmed[index + "import".len()..].trim_start();
let Some(first) = after.chars().next() else {
continue;
};
if first == '"' || first == '\'' {
if let Some(end) = after[1..].find(first) {
specifiers.push(after[1..1 + end].to_string());
}
} else {
let ident: String = after
.chars()
.take_while(|ch| !ch.is_whitespace() && *ch != ';' && *ch != ',' && *ch != '{')
.collect();
if !ident.is_empty() {
specifiers.push(ident);
}
}
}
specifiers
}
pub fn selector_for_builtin_candidate(
id: &str,
) -> Option<worker_runtime::catalog::ProfileSelector> {
@@ -711,6 +558,7 @@ pub fn selector_for_builtin_candidate(
#[cfg(test)]
mod tests {
use super::*;
use config_source::{ConfigContentType, VirtualPath};
#[test]
fn workspace_metadata_projects_server_database_record_without_filesystem_diagnostics() {
@@ -779,7 +627,7 @@ mod tests {
config_source::ConfigEntry::new(
VirtualPath::parse("main.dcdl").unwrap(),
ConfigContentType::Decodal,
r#"{ profile = { default_profile = "project:alpha"; entries = [{ selector = "project:alpha"; source = "profiles/alpha.dcdl"; label = "Alpha"; }]; }; }"#,
r#"{ profile = { default_profile = "project:alpha"; entries = [{ selector = "project:alpha"; profile = import "./profiles/alpha.dcdl"; label = "Alpha"; }]; }; }"#,
)
.unwrap(),
config_source::ConfigEntry::new(
@@ -819,18 +667,18 @@ mod tests {
.entrypoints
.get("project:alpha")
.map(String::as_str),
Some("profiles/alpha.dcdl")
Some("profiles/evaluated.json")
);
assert_eq!(archive.reference.source_graph.source_count, 1);
}
#[test]
fn virtual_config_projection_preserves_import_closure() {
fn virtual_config_projection_materializes_import_closure() {
let state = virtual_state(vec![
config_source::ConfigEntry::new(
VirtualPath::parse("main.dcdl").unwrap(),
ConfigContentType::Decodal,
r#"{ profile = { entries = [{ selector = "project:alpha"; source = "profiles/alpha.dcdl"; }]; }; }"#,
r#"{ profile = { entries = [{ selector = "project:alpha"; profile = import "./profiles/alpha.dcdl"; }]; }; }"#,
)
.unwrap(),
config_source::ConfigEntry::new(
@@ -857,8 +705,8 @@ mod tests {
.unwrap()
.unwrap();
let archive = bundle.profile_source_archive.unwrap();
assert_eq!(archive.reference.source_graph.source_count, 2);
assert_eq!(archive.reference.source_graph.import_count, 1);
assert_eq!(archive.reference.source_graph.source_count, 1);
assert_eq!(archive.reference.source_graph.import_count, 0);
}
#[test]
@@ -955,8 +803,8 @@ mod tests {
VirtualPath::parse("main.dcdl").unwrap(),
ConfigContentType::Decodal,
r#"{ profile = { entries = [
{ selector = "project:alpha"; source = "profiles/alpha.dcdl"; },
{ selector = "project:beta"; source = "profiles/beta.dcdl"; },
{ selector = "project:alpha"; profile = import "./profiles/alpha.dcdl"; },
{ selector = "project:beta"; profile = import "./profiles/beta.dcdl"; },
]; }; }"#,
)
.unwrap(),
@@ -1002,20 +850,83 @@ mod tests {
}
#[test]
fn virtual_config_projection_rejects_missing_profile_source() {
let state = virtual_state(vec![
config_source::ConfigEntry::new(
VirtualPath::parse("main.dcdl").unwrap(),
ConfigContentType::Decodal,
r#"{ profile = { entries = [{ selector = "project:alpha"; source = "profiles/missing.dcdl"; }]; }; }"#,
)
.unwrap(),
]);
let error = project_profiles_from_workspace_config("workspace-test", &state).unwrap_err();
assert!(
error
.to_string()
.contains("missing from the active config revision")
fn value_profile_authoring_uses_current_provider_schema_without_inserting_defaults() {
let bundle =
config_source::WorkspaceConfigSchemaBundle::compose([ProfileConfigSchemaProvider
.contribution()
.unwrap()])
.unwrap();
let snapshot = config_source::ConfigTreeSnapshot::from_entries(1, [config_source::ConfigEntry::new(
VirtualPath::parse("main.dcdl").unwrap(), ConfigContentType::Decodal,
r#"{ profile = { entries = [{ selector = "project:alpha"; profile = {}; }]; }; } as WorkspaceConfigSchema"#,
).unwrap()]).unwrap();
let environment = config_source::SnapshotEnvironment::new(snapshot.clone())
.with_schema_bundle(bundle.clone());
for (body, token, label) in [
(
"{ profile = { entries = [{ sel }] } } as WorkspaceConfigSchema",
"sel",
"selector",
),
(
"{ profile = { entries = [{ pro }] } } as WorkspaceConfigSchema",
"pro",
"profile",
),
(
"{ profile = { entries = [{ profile = { wor } }] } } as WorkspaceConfigSchema",
"wor",
"worker",
),
(
"{ profile = { entries = [{ profile = { worker = { mo } } }] } } as WorkspaceConfigSchema",
"mo",
"mode",
),
(
"{ profile = { entries = [{ profile = { feature = { ti } } }] } } as WorkspaceConfigSchema",
"ti",
"ticket",
),
] {
let offset = body.rfind(token).unwrap() + token.len();
let completion = environment
.complete_config(
&VirtualPath::parse("main.dcdl").unwrap(),
body,
offset,
true,
)
.unwrap()
.unwrap();
assert!(
completion.items.iter().any(|item| item.label == label),
"{label}: {completion:?}"
);
}
for invalid in [
r#"{ profile = { entries = [{ selector = 42; profile = {}; }]; }; } as WorkspaceConfigSchema"#,
r#"{ profile = { entries = [{ selector = "project:alpha"; profile = 42; }]; }; } as WorkspaceConfigSchema"#,
] {
assert!(
!environment
.analyze(&VirtualPath::parse("main.dcdl").unwrap(), Some(invalid))
.is_empty()
);
}
let evaluation = environment
.evaluate_contract(&config_source::ToolchainContract::with_schema_bundle(
config_source::DEFAULT_SCHEMA_VERSION,
vec![VirtualPath::parse("main.dcdl").unwrap()],
config_source::DEFAULT_IMPORT_POLICY_VERSION,
bundle,
))
.unwrap();
assert_eq!(
evaluation.projections[0]
.data_json
.pointer("/profile/entries/0/profile"),
Some(&serde_json::json!({}))
);
}
}
+2
View File
@@ -12320,6 +12320,7 @@ fn config_schema_bundle_to_api(
namespace: contribution.namespace,
version: contribution.version,
source: contribution.source,
authoring_source: contribution.authoring_source,
projection_validator: contribution
.projection_validator
.map(config_projection_validator_to_api),
@@ -33466,6 +33467,7 @@ impl IntoResponse for ApiError {
#[cfg(test)]
mod tests {
mod subjektiv_jobs_tests;
mod value_profiles_tests;
mod worker_operations_tests;
include!("server_workspace_config_tests.rs");
use super::*;
@@ -454,7 +454,7 @@ async fn backend_job_project_recipe_is_resolved_and_delivered_without_system_ove
entrypoints: state.contract.entrypoints.iter().map(|p| p.as_str().into()).collect(),
changes: vec![server_api::ConfigTreeChange::Update {
path: entrypoint.as_str().into(), expected_digest: root.content_digest.clone(),
content: "{ profile = { entries = [{ selector = \"project:job-recipe\"; source = \"profiles/job-recipe.dcdl\"; }]; }; }".into(),
content: "{ profile = { entries = [{ selector = \"project:job-recipe\"; profile = import \"./profiles/job-recipe.dcdl\"; }]; }; }".into(),
}, server_api::ConfigTreeChange::Create {
path: "profiles/job-recipe.dcdl".into(), content_type: server_api::ConfigContentType::Decodal,
content: "{ slug = \"job-recipe\"; scope = \"workspace_read\"; model = { id = \"fixture-model\"; }; engine = { instruction = \"role.default\"; }; feature = { task = { enabled = true; }; }; }".into(),
@@ -0,0 +1,362 @@
use super::*;
use crate::profile_settings::{
build_virtual_profile_config_bundle, project_profiles_from_workspace_config,
};
const PROFILE: &str = r#"{
slug = "alpha";
worker = { mode = "wip"; };
model = { ref = "codex-oauth/gpt-5.6-sol"; };
feature = { task = { enabled = true; }; ticket = { enabled = false; }; };
}"#;
fn request(
api: &WorkspaceApi,
source: &str,
extra: Vec<config_source::ConfigTreeChange>,
) -> ConfigCommitRequest {
let state = api
.config_store
.load_workspace_config(TEST_WORKSPACE_ID)
.unwrap()
.unwrap();
let main = state
.snapshot
.get(&config_source::VirtualPath::parse("main.dcdl").unwrap())
.unwrap();
let mut changes = vec![config_source::ConfigTreeChange::Update {
path: main.path.clone(),
expected_digest: main.content_digest.clone(),
content: source.into(),
}];
changes.extend(extra);
ConfigCommitRequest {
base_revision: state.snapshot.revision,
base_digest: state.snapshot.digest,
entrypoints: state.contract.entrypoints,
changes,
}
}
fn source(value: &str) -> String {
format!(
r#"{{ profile = {{ default_profile = "project:alpha"; entries = [{{ selector = "project:alpha"; label = "Alpha"; description = "Value recipe"; profile = {value}; }}]; }}; }}"#
)
}
#[tokio::test]
async fn value_profiles_save_project_and_runtime_consume_the_same_revision() {
let dir = tempfile::tempdir().unwrap();
let api = test_api(dir.path()).await;
let forms = [
PROFILE.to_string(),
r#"import "./recipes/alpha.dcdl""#.into(),
r#"(import "./recipes/base.dcdl") // { worker = { mode = "wip"; }; feature.task.enabled = true; }"#.into(),
];
let mut manifests = Vec::new();
let mut saved_manifest = None;
for (index, form) in forms.iter().enumerate() {
let extra = if index == 0 {
vec![
config_source::ConfigTreeChange::Create {
path: config_source::VirtualPath::parse("recipes/alpha.dcdl").unwrap(),
content_type: config_source::ConfigContentType::Decodal,
content: PROFILE.into(),
},
config_source::ConfigTreeChange::Create {
path: config_source::VirtualPath::parse("recipes/base.dcdl").unwrap(),
content_type: config_source::ConfigContentType::Decodal,
content: PROFILE
.replace("wip", "tools")
.replace("enabled = true", "enabled = false"),
},
]
} else {
vec![]
};
let state = commit_workspace_config_tree(
&api,
TEST_WORKSPACE_ID,
&request(&api, &source(form), extra),
)
.unwrap();
let loaded = api
.config_store
.load_workspace_config(TEST_WORKSPACE_ID)
.unwrap()
.unwrap();
assert_eq!(loaded, state);
// The Browser receives the same fingerprint-bound authoring metadata through REST.
let wire = workspace_config_state_to_api(loaded.clone());
let decoded: config_source::WorkspaceConfigSchemaBundle =
serde_json::from_value(serde_json::to_value(wire.contract.schema_bundle).unwrap())
.unwrap();
decoded.validate().unwrap();
assert_eq!(decoded, loaded.contract.schema_bundle);
assert!(
decoded
.contributions
.iter()
.find(|entry| entry.provider_id == "builtin:profile")
.unwrap()
.authoring_source
.is_some()
);
let projection =
project_profiles_from_workspace_config(TEST_WORKSPACE_ID, &loaded).unwrap();
assert_eq!(
projection.settings.default_profile.as_deref(),
Some("project:alpha")
);
assert_eq!(
projection.settings.config_revision,
Some(state.snapshot.revision)
);
let selected = projection
.settings
.profiles
.iter()
.find(|entry| entry.selector == "project:alpha")
.unwrap();
assert_eq!(selected.label, "Alpha");
assert_eq!(selected.description.as_deref(), Some("Value recipe"));
assert!(selected.is_default);
assert_eq!(selected.profile_source_id.as_deref(), Some("project:alpha"));
let bundle = build_virtual_profile_config_bundle(
&projection,
&state,
TEST_WORKSPACE_ID,
"2026-01-01T00:00:00Z",
"project:alpha",
)
.unwrap()
.unwrap();
assert_eq!(bundle.metadata.digest, bundle.computed_digest());
assert_eq!(
bundle.metadata.revision,
state.snapshot.revision.to_string()
);
let archive = bundle.profile_source_archive.as_ref().unwrap();
let verified = archive.verify().unwrap();
// This is the Runtime's archive consumer. It needs neither the Workspace tree nor imports.
let resolved = verified
.resolve_profile(
"project:alpha",
Path::new("/runtime-worker"),
"value-worker",
)
.unwrap();
assert_eq!(resolved.worker.mode, manifest::WorkerMode::Wip);
assert!(resolved.feature.task.enabled);
assert!(!resolved.feature.ticket.enabled);
assert!(!resolved.feature.ticket.authoring);
assert!(!resolved.feature.sub_worker.enabled);
assert_eq!(archive.reference.source_graph.import_count, 0);
let evaluation = config_source::SnapshotEnvironment::new(state.snapshot.clone())
.evaluate_contract(&state.contract)
.unwrap();
let value = evaluation.projections[0]
.data_json
.pointer("/profile/entries/0/profile")
.unwrap();
assert!(value.is_object());
assert!(
value.get("engine").is_none(),
"omission must remain omission in the Profile value"
);
let mut snapshot = serde_json::to_value(&resolved).unwrap();
snapshot.as_object_mut().unwrap().remove("profile"); // revision-specific provenance only
manifests.push(snapshot);
if index == 0 {
saved_manifest =
Some(manifest::write_persisted_worker_manifest_snapshot(&resolved).unwrap());
}
if index == 2 {
let mut other_state = state.clone();
other_state.snapshot.revision += 1;
assert!(
build_virtual_profile_config_bundle(
&projection,
&other_state,
TEST_WORKSPACE_ID,
"created",
"project:alpha"
)
.unwrap_err()
.to_string()
.contains("does not match")
);
}
}
assert_eq!(manifests[0], manifests[1]);
assert_eq!(manifests[1], manifests[2]);
// Saving a new recipe is not an operation on existing Workers or their persisted Manifest.
let state = commit_workspace_config_tree(
&api,
TEST_WORKSPACE_ID,
&request(
&api,
&source(
&PROFILE
.replace("wip", "tools")
.replace("enabled = true", "enabled = false"),
),
vec![],
),
)
.unwrap();
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 fresh = crate::profile_settings::resolve_profile_manifest_from_config_bundle(
&bundle,
"project:alpha",
)
.unwrap();
assert_eq!(fresh.worker.mode, manifest::WorkerMode::Tools);
assert!(!fresh.feature.task.enabled);
let restored =
manifest::read_persisted_worker_manifest_snapshot(saved_manifest.unwrap()).unwrap();
assert_eq!(restored.worker.mode, manifest::WorkerMode::Wip);
assert!(restored.feature.task.enabled);
}
#[tokio::test]
async fn value_profiles_invalid_saves_preserve_the_active_config() {
let dir = tempfile::tempdir().unwrap();
let api = test_api(dir.path()).await;
let before = commit_workspace_config_tree(
&api,
TEST_WORKSPACE_ID,
&request(&api, &source(PROFILE), vec![]),
)
.unwrap();
let invalid = [
(source("42"), ""),
(source(r#"{ worker = { mode = "not-a-mode"; }; }"#), "profile_value_invalid"),
(source(r#"{ worker = { name = "runtime-only"; }; }"#), "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"),
(r#"{ profile = { default_profile = "project:unknown"; }; }"#.into(), "unknown_default_profile"),
(r#"{ profile = { entries = [{ selector = "project:"; profile = {}; }]; }; }"#.into(), "profile_selector_invalid"),
(r#"{ profile = { entries = [{ selector = "project:alpha"; source = "profiles/alpha.dcdl"; }]; }; }"#.into(), ""),
];
for (source, code) in invalid {
let error =
commit_workspace_config_tree(&api, TEST_WORKSPACE_ID, &request(&api, &source, vec![]))
.unwrap_err();
assert!(error.to_string().contains(code), "{error}");
assert_eq!(
api.config_store
.load_workspace_config(TEST_WORKSPACE_ID)
.unwrap()
.unwrap(),
before
);
}
}
#[tokio::test]
async fn value_profiles_legacy_source_config_requires_explicit_lossless_migration() {
let dir = tempfile::tempdir().unwrap();
let api = test_api(dir.path()).await;
let mut contributions = api.config_schema_registry.compose().unwrap().contributions;
let profile_schema = contributions
.iter_mut()
.find(|entry| entry.provider_id == "builtin:profile")
.unwrap();
*profile_schema = config_source::ConfigSchemaContribution::new("builtin:profile", "profile", "1", r#"{
profile = { default_profile = String default "builtin:companion";
entries = [...{ selector = String; source = String; label = String default ""; description = String default ""; }] default [];
};
}"#).unwrap();
let old_schema = config_source::WorkspaceConfigSchemaBundle::compose(contributions).unwrap();
let old_source = r#"{ profile = { default_profile = "project:alpha"; entries = [{ selector = "project:alpha"; label = "Alpha"; source = "recipes/alpha.dcdl"; }]; }; }"#;
let candidate = api
.config_store
.evaluate_workspace_config_candidate_with_schema(
TEST_WORKSPACE_ID,
&request(
&api,
old_source,
vec![config_source::ConfigTreeChange::Create {
path: config_source::VirtualPath::parse("recipes/alpha.dcdl").unwrap(),
content_type: config_source::ConfigContentType::Decodal,
content: PROFILE.into(),
}],
),
old_schema,
)
.unwrap();
// Fixture simulates a previously persisted v1 schema/revision.
let legacy = api
.config_store
.commit_evaluated_workspace_config(TEST_WORKSPACE_ID, &candidate)
.unwrap();
let error = project_profiles_from_workspace_config(TEST_WORKSPACE_ID, &legacy).unwrap_err();
assert!(
error
.to_string()
.contains("profile_source_registration_removed")
);
assert!(error.to_string().contains("profile = import"));
assert_eq!(
api.config_store
.load_workspace_config(TEST_WORKSPACE_ID)
.unwrap()
.unwrap(),
legacy
);
assert!(
commit_workspace_config_tree(&api, TEST_WORKSPACE_ID, &request(&api, old_source, vec![]))
.is_err()
);
assert_eq!(
api.config_store
.load_workspace_config(TEST_WORKSPACE_ID)
.unwrap()
.unwrap(),
legacy
);
let migrated_source = old_source.replace(
r#"source = "recipes/alpha.dcdl""#,
r#"profile = import "./recipes/alpha.dcdl""#,
);
let migrated = commit_workspace_config_tree(
&api,
TEST_WORKSPACE_ID,
&request(&api, &migrated_source, vec![]),
)
.unwrap();
let projection = project_profiles_from_workspace_config(TEST_WORKSPACE_ID, &migrated).unwrap();
assert_eq!(
projection.settings.default_profile.as_deref(),
Some("project:alpha")
);
assert_eq!(
migrated
.snapshot
.get(&config_source::VirtualPath::parse("recipes/alpha.dcdl").unwrap()),
legacy
.snapshot
.get(&config_source::VirtualPath::parse("recipes/alpha.dcdl").unwrap())
);
assert_eq!(
projection
.settings
.profiles
.iter()
.find(|entry| entry.selector == "project:alpha")
.unwrap()
.label,
"Alpha"
);
}
+28 -2
View File
@@ -17,7 +17,33 @@ A Profile should not contain runtime-bound fields:
Those fields depend on one run, one parent, or one machine. Putting them in a reusable Profile makes reuse unsafe.
Yoi Profiles are data artifacts resolved from builtin profile definitions, `profiles.toml`, Decodal source archives, or explicit JSON/TOML artifacts. Decodal is the authoring syntax used by the Workspace profile editor and Backend Runtime archive path; JSON/TOML artifacts are the low-level resolver interchange format. Runtime launch should not depend on executable profile scripts.
Yoi Profiles are data artifacts resolved from builtin profile definitions, `profiles.toml`, Workspace Config values, or explicit JSON/TOML artifacts. Decodal is the Workspace authoring syntax; JSON/TOML artifacts are the low-level resolver interchange format. Runtime launch does not depend on executable profile scripts.
### Workspace registration and migration
Register the Profile **value**, not a source pathname:
```dcdl
{
profile = {
default_profile = "project:my-companion";
entries = [{
selector = "project:my-companion";
label = "My Companion";
description = "My reusable Worker recipe";
profile = import "./profiles/my-companion.dcdl";
}];
};
} as WorkspaceConfigSchema
```
The imported file can contain `{ worker = { mode = "wip"; }; model = { ref = "codex-oauth/gpt-5.6-sol"; }; feature = { task = { enabled = true; }; }; }`. That record can also be written directly at `entries[0].profile`. Composition uses ordinary Decodal expressions, for example `(import "./profiles/base.dcdl") // { worker = { mode = "wip"; }; }` for an explicit patch, or `&` for compatible refinement. No dedicated Profile file or `profiles/` directory is required. Imports are relative to the **containing virtual config file**, not a host filesystem path; missing imports and virtual-tree escapes are rejected.
`selector` must be a nonempty `project:*` selector, unique in the Workspace. `label` and `description` are optional display metadata. An omitted `default_profile` selects `builtin:companion`; an explicitly unknown default is an error, not a fallback. A project Profile is a partial recipe resolved against `WorkerManifestConfig::resolution_defaults()`, **not** implicitly inherited from a builtin role. Explicit values remain explicit and omitted Profile fields remain absent in the transported value. Builtin-value import helpers are not part of this registration contract.
The former `entries = [{ selector = "project:name"; source = "profiles/name.dcdl"; }]` registration is no longer accepted. Migrate it explicitly to `profile = import "./profiles/name.dcdl";`, preserving the selector, label, description and default. For registrations in a nested config file, adjust the relative import to the original virtual-tree entry (for example `../profiles/name.dcdl`). Do not rename or recreate the Profile file merely for registration. Previously saved source-based revisions remain readable as config data and are not rewritten, deleted, or silently substituted with a builtin. Their Profile projection/launch reports `profile_source_registration_removed` with the replacement form; saves under the new schema reject the old field. Existing Workers continue using their saved Manifest while the author migrates and saves the config.
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; it is never evaluated as defaults or substituted for the validation schema. Decodal structural diagnostics cover registration/value shape, and the shared Backend save boundary checks full Profile/Manifest semantics (including mode, forbidden runtime fields, duplicate selectors and default references). UI and WIP saves use that same boundary; invalid candidates leave the active config revision untouched.
## Manifests
@@ -27,7 +53,7 @@ Source/partial layers may omit fields. Resolved manifests should be explicit eno
`--manifest <path>` exists as an explicit low-level escape hatch. Normal fresh startup selects a `builtin:*` or `project:*` Profile from the Backend-managed Workspace Config revision rather than applying an ambient manifest cascade.
Project Profiles are evaluated from the revisioned Virtual Config's Decodal source/import closure. The Backend packages that closure into a digest-bound Profile source archive, delivers it with the resolved launch bundle, and the Worker persists the resulting Manifest for restore. Files below the Workdir are not implicit Profile override layers.
Project Profiles are evaluated as part of the revisioned Virtual Config, including its imports and value composition. The Backend packages the **evaluated selected value** into a digest-bound JSON entry in the existing Profile archive transport, and binds the launch bundle to the Workspace identity, revision, tree/projection digests and schema/toolchain fingerprints. Runtime verifies the archive and resolves that exact value without re-evaluating Decodal, scanning imports or reading Workspace files. Builtin Profiles retain their embedded Decodal archive path. Saving config changes only future fresh launches; the Worker persists the resulting resolved Manifest and restore uses that saved snapshot rather than selecting the current recipe again. Files below the Workdir are not implicit Profile override layers.
## Local stdio MCP server declarations
+8 -1
View File
@@ -1235,6 +1235,13 @@
"ConfigSchemaContribution": {
"additionalProperties": false,
"properties": {
"authoring_source": {
"description": "Completion-only shape for partial values; never materialized as defaults.",
"type": [
"string",
"null"
]
},
"namespace": {
"type": "string"
},
@@ -15474,7 +15481,7 @@
"info": {
"title": "Yoi Server API",
"version": "0.1.0",
"x-yoi-source-digest": "sha256:fd06f382c6d33a5c3cc98ef9e264545923c53fa95a9be555a85680f98c6065da"
"x-yoi-source-digest": "sha256:a6a7147c9a2f465a5da044e9ba21947cb3d86eacbfe3905696483917e794d5cf"
},
"openapi": "3.1.0",
"paths": {
@@ -0,0 +1,72 @@
# Completion-only shape of partial Profile values. This is never materialized or
# used as defaults: omitted fields remain omitted until Manifest resolution.
let
Flag = { enabled = Bool; };
Model = {
ref = String; scheme = String; base_url = String; model_id = String;
auth = { kind = String; ref = String; file = String; };
capability = Unknown; context_window = Int; max_context_window = Int;
};
Extraction = {
enabled = Bool; model = Model; reasoning = String;
threshold = Int; worker_max_turns = Int;
};
Scope = { intent = String; deny_write = [...String]; symlink_policy = String; };
Profile = {
slug = String;
description = String;
worker = { mode = String; };
model = Model;
engine = {
instruction = String; language = String; reasoning = Unknown;
max_tokens = Int; max_turns = Int; temperature = Unknown; top_p = Unknown;
top_k = Int; stop_sequences = [...String]; tool_call_dispatch = String;
tool_output = { default_max_bytes = Int; per_tool = { ...Int }; };
file_upload = { max_bytes = Int; };
};
scope = Scope;
delegation_scope = Scope;
session = {
record_event_trace = Bool;
};
permissions = { default_action = String; rule = [...{ tool = String; pattern = String; action = String; }]; };
feature = {
task = Flag; web = Flag; image = Flag; sub_worker = Flag; flow = Flag;
worker = { enabled = Bool; direct_spawn = Bool; };
workspace_worker_discovery = Flag; objective = Flag; manage_workdir = Flag;
workdir_catalog = Flag; workspace_config = Flag; orchestration = Flag;
ticket = { enabled = Bool; authoring = Bool; thread = Bool; intake = Bool; workflow = Bool; };
merge_request = { show = Bool; open = Bool; review = Bool; readiness_check = Bool; complete = Bool; };
memory = {
enabled = Bool; staging_tools = Bool;
resident = { inject_summary = Bool; };
extraction = Extraction;
consolidation = { request_enabled = Bool; };
};
subjektiv = { enabled = Bool; consolidation_tools = Bool; extraction = Extraction; };
};
mcp = { stdio_server = [...{
name = String; command = String; args = [...String];
cwd = { kind = String; path = String; };
env = { inherit = [...String]; set = { ...Unknown }; };
}]; };
compaction = {
kind = String; threshold = Unknown; request_threshold = Unknown;
prune_enabled = Bool; worker_context_max_tokens = Unknown;
worker_max_turns = Int; model = Model;
};
web = {
enabled = Bool; allow_private_addresses = Bool;
search = { enabled = Bool; provider = String; api_key_secret = String; timeout_secs = Int; base_url = String; country = String; search_lang = String; ui_lang = String; safesearch = String; };
fetch = { enabled = Bool; timeout_secs = Int; redirect_limit = Int; max_response_bytes = Int; max_output_bytes = Int; allow_private_addresses = Bool; };
};
skills = { directories = [...String]; };
};
in {
profile = {
default_profile = String;
entries = [...{
selector = String; profile = Profile; label = String; description = String;
}];
};
}
+11
View File
@@ -0,0 +1,11 @@
{
profile = {
default_profile = String default "builtin:companion";
entries = [...{
selector = String;
profile = { ...Unknown };
label = String default "";
description = String default "";
}] default [];
};
}
+1 -1
View File
@@ -6,7 +6,7 @@
"dev": "deno run -A npm:vite@8.3.2 dev",
"dev:backend": "cd ../.. && cargo run -p yoi-workspace-server --bin yoi-server -- serve --listen 127.0.0.1:8787",
"check": "deno run -A npm:@sveltejs/kit@3.0.0 sync && deno run -A npm:svelte-check@4.7.6 --tsconfig ./tsconfig.json",
"test": "deno test --allow-read=src,test,tests,../../docs --allow-env=LOG,VSCODE_TEXTMATE_DEBUG,NODE_ENV tests/workspace-model.test.ts tests/repository-api.test.ts tests/workspace-catalog.test.ts tests/profile-api.test.ts tests/skill-api.test.ts tests/tooltip-contract.test.ts tests/bevel-contract.test.ts tests/bevel-line-contract.test.ts tests/typography-contract.test.ts src/lib/workspace/auth/model.test.ts src/lib/workspace/protocol-validator.test.ts tests/auth-api.test.ts src/lib/workspace/api/http.test.ts src/lib/workspace/api/workers.test.ts src/lib/workspace/api/ticket-browser.test.ts src/lib/workspace/header/breadcrumb-model.test.ts src/lib/workspace/console/chat-submit.test.ts src/lib/workspace/console/composer-delivery.test.ts test/composer-history.test.ts tests/composer-paste.test.ts src/lib/workspace/console/composer-command.test.ts src/lib/workspace/console/composer-draft.test.ts src/lib/workspace/console/composer-completion.test.ts src/lib/workspace/console/composer-invocation.test.ts src/lib/workspace/console/file-completions.test.ts test/console/ansi.test.ts src/lib/workspace/console/history.test.ts src/lib/workspace/console/model.test.ts src/lib/workspace/console/turn-navigation.test.ts src/lib/workspace/console/run-status.test.ts src/lib/workspace/companion/api.test.ts tests/workdir-api.test.ts tests/workdir-permissions.test.ts src/lib/workspace/console/tasks.test.ts test/ticket-detail-route-reuse.test.ts test/spa-shell.test.ts test/objective-pages.test.ts test/repositories/ui.test.ts src/lib/workspace/console/worker-console.ui.test.ts src/lib/workspace/settings/model.test.ts tests/runtime-connection.test.ts tests/runtime-management.test.ts tests/runtime-management-source.test.ts src/lib/workspace/sidebar/override-stack.test.ts src/lib/workspace/sidebar/route-ownership.test.ts src/lib/workspace/sidebar/workers.test.ts src/lib/workspace/sidebar/workspace-switcher.test.ts src/lib/workspace/sidebar/worker-subscription.test.ts src/lib/workspace/sidebar/worker-workdir-meta.test.ts src/lib/workspace/sidebar/worker-launch.test.ts tests/runtime-workers.test.ts test/sidebar/worker-actions.test.ts src/lib/workspace/tickets/merge-request-resources.test.ts src/lib/workspace/tickets/ticket-panel.test.ts test/merge-request-status.test.ts test/config-source/decodal-grammar.test.ts test/config-source/editor-state.test.ts test/config-source/fixed-schema-wrapper.test.ts test/config-source/toolchain.test.ts test/config-source/wasm-parity.test.ts test/config-source/api.test.ts tests/memory-api.test.ts test/repository-access/api.test.ts test/repository-access/loader.test.ts test/repository-access/model.test.ts test/repository-access/structure.test.ts test/repository-ssh-connection-ui.test.ts",
"test": "deno test --allow-read=src,test,tests,../../docs,../../resources/config-schema --allow-env=LOG,VSCODE_TEXTMATE_DEBUG,NODE_ENV tests/workspace-model.test.ts tests/repository-api.test.ts tests/workspace-catalog.test.ts tests/profile-api.test.ts tests/skill-api.test.ts tests/tooltip-contract.test.ts tests/bevel-contract.test.ts tests/bevel-line-contract.test.ts tests/typography-contract.test.ts src/lib/workspace/auth/model.test.ts src/lib/workspace/protocol-validator.test.ts tests/auth-api.test.ts src/lib/workspace/api/http.test.ts src/lib/workspace/api/workers.test.ts src/lib/workspace/api/ticket-browser.test.ts src/lib/workspace/header/breadcrumb-model.test.ts src/lib/workspace/console/chat-submit.test.ts src/lib/workspace/console/composer-delivery.test.ts test/composer-history.test.ts tests/composer-paste.test.ts src/lib/workspace/console/composer-command.test.ts src/lib/workspace/console/composer-draft.test.ts src/lib/workspace/console/composer-completion.test.ts src/lib/workspace/console/composer-invocation.test.ts src/lib/workspace/console/file-completions.test.ts test/console/ansi.test.ts src/lib/workspace/console/history.test.ts src/lib/workspace/console/model.test.ts src/lib/workspace/console/turn-navigation.test.ts src/lib/workspace/console/run-status.test.ts src/lib/workspace/companion/api.test.ts tests/workdir-api.test.ts tests/workdir-permissions.test.ts src/lib/workspace/console/tasks.test.ts test/ticket-detail-route-reuse.test.ts test/spa-shell.test.ts test/objective-pages.test.ts test/repositories/ui.test.ts src/lib/workspace/console/worker-console.ui.test.ts src/lib/workspace/settings/model.test.ts tests/runtime-connection.test.ts tests/runtime-management.test.ts tests/runtime-management-source.test.ts src/lib/workspace/sidebar/override-stack.test.ts src/lib/workspace/sidebar/route-ownership.test.ts src/lib/workspace/sidebar/workers.test.ts src/lib/workspace/sidebar/workspace-switcher.test.ts src/lib/workspace/sidebar/worker-subscription.test.ts src/lib/workspace/sidebar/worker-workdir-meta.test.ts src/lib/workspace/sidebar/worker-launch.test.ts tests/runtime-workers.test.ts test/sidebar/worker-actions.test.ts src/lib/workspace/tickets/merge-request-resources.test.ts src/lib/workspace/tickets/ticket-panel.test.ts test/merge-request-status.test.ts test/config-source/decodal-grammar.test.ts test/config-source/editor-state.test.ts test/config-source/fixed-schema-wrapper.test.ts test/config-source/toolchain.test.ts test/config-source/wasm-parity.test.ts test/config-source/api.test.ts tests/memory-api.test.ts test/repository-access/api.test.ts test/repository-access/loader.test.ts test/repository-access/model.test.ts test/repository-access/structure.test.ts test/repository-ssh-connection-ui.test.ts",
"test:component": "deno run -A npm:vitest@5.0.3 run --config vitest.config.ts",
"build": "deno run -A npm:vite@8.3.2 build",
"preview": "deno run -A npm:vite@8.3.2 preview",
@@ -1,6 +1,6 @@
// @generated by yoi-companion-openapi-typescript@1. Do not edit.
// Input: openapi/server-api.json
// Input contract: 0.1.0 (sha256:fd06f382c6d33a5c3cc98ef9e264545923c53fa95a9be555a85680f98c6065da)
// Input contract: 0.1.0 (sha256:a6a7147c9a2f465a5da044e9ba21947cb3d86eacbfe3905696483917e794d5cf)
// Options: companion-operation-schema-closure,openapi-3.1,strict-integer-ranges,sorted-output
// Output: web/workspace/src/lib/generated/companion-api.ts
// Regenerate/check: cargo run -q -p server-api --example generate_companion_api_types -- --check
@@ -237,6 +237,10 @@ export type ConfigSchemaContribution = {
version: string;
source: string;
projection_validator?: ConfigProjectionValidator;
/**
* Completion-only shape for partial values; never materialized as defaults.
*/
authoring_source?: string;
source_digest: string;
};
@@ -1,6 +1,6 @@
// @generated by yoi-repository-openapi-typescript@1. Do not edit.
// Input: openapi/server-api.json
// Input contract: 0.1.0 (sha256:fd06f382c6d33a5c3cc98ef9e264545923c53fa95a9be555a85680f98c6065da)
// Input contract: 0.1.0 (sha256:a6a7147c9a2f465a5da044e9ba21947cb3d86eacbfe3905696483917e794d5cf)
// Options: repository-operation-schema-closure,openapi-3.1,format:uint64=safe-number,strict-safe-integers,sorted-output
// Output: web/workspace/src/lib/generated/repository-api.ts
// Regenerate/check: cargo run -q -p server-api --example generate_repository_openapi_types -- --check
@@ -1,6 +1,6 @@
// @generated by yoi-runtime-openapi-typescript@1. Do not edit.
// Input: openapi/server-api.json
// Input contract: 0.1.0 (sha256:fd06f382c6d33a5c3cc98ef9e264545923c53fa95a9be555a85680f98c6065da)
// Input contract: 0.1.0 (sha256:a6a7147c9a2f465a5da044e9ba21947cb3d86eacbfe3905696483917e794d5cf)
// Options: runtime-operation-schema-closure,openapi-3.1,strict-integer-ranges,sorted-output
// Output: web/workspace/src/lib/generated/runtime-api.ts
// Regenerate/check: cargo run -q -p server-api --example generate_runtime_api_types -- --check
@@ -1,6 +1,6 @@
// @generated by yoi-ticket-browser-openapi-typescript@1. Do not edit.
// Input: openapi/server-api.json
// Input contract: 0.1.0 (sha256:fd06f382c6d33a5c3cc98ef9e264545923c53fa95a9be555a85680f98c6065da)
// Input contract: 0.1.0 (sha256:a6a7147c9a2f465a5da044e9ba21947cb3d86eacbfe3905696483917e794d5cf)
// Options: ticket-objective-merge-request-browser-operation-schema-closure,openapi-3.1,strict-integer-ranges,sorted-output
// Output: web/workspace/src/lib/generated/ticket-api.ts
// Regenerate/check: cargo run -q -p server-api --example generate_ticket_api_types -- --check
@@ -1,6 +1,6 @@
// @generated by yoi-worker-openapi-typescript@1. Do not edit.
// Input: openapi/server-api.json
// Input contract: 0.1.0 (sha256:fd06f382c6d33a5c3cc98ef9e264545923c53fa95a9be555a85680f98c6065da)
// Input contract: 0.1.0 (sha256:a6a7147c9a2f465a5da044e9ba21947cb3d86eacbfe3905696483917e794d5cf)
// Options: worker-operation-schema-closure,openapi-3.1,strict-integer-ranges,sorted-output
// Output: web/workspace/src/lib/generated/worker-launch-api.ts
// Regenerate/check: cargo run -q -p server-api --example generate_worker_launch_api_types -- --check
@@ -187,7 +187,7 @@ function parseContribution(value: unknown): ConfigSchemaContribution {
const item = record(
value,
["provider_id", "namespace", "version", "source", "source_digest"],
["projection_validator"],
["projection_validator", "authoring_source"],
);
const projection = item.projection_validator === undefined
? undefined
@@ -197,6 +197,12 @@ function parseContribution(value: unknown): ConfigSchemaContribution {
namespace: boundedString(item.namespace),
version: boundedString(item.version),
source: boundedString(item.source, MAX_SCHEMA_SOURCE_BYTES),
...(item.authoring_source === undefined ? {} : {
authoring_source: boundedString(
item.authoring_source,
MAX_SCHEMA_SOURCE_BYTES,
),
}),
...(projection === undefined ? {} : { projection_validator: projection }),
source_digest: boundedString(item.source_digest, MAX_DIGEST_BYTES),
};
@@ -1,4 +1,4 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
import type { ConfigProjectionValidator } from "./ConfigProjectionValidator";
export type ConfigSchemaContribution = { provider_id: string, namespace: string, version: string, source: string, projection_validator?: ConfigProjectionValidator | null, source_digest: string, };
export type ConfigSchemaContribution = { provider_id: string, namespace: string, version: string, source: string, authoring_source?: string | null, projection_validator?: ConfigProjectionValidator | null, source_digest: string, };
@@ -244,3 +244,41 @@ Deno.test("config source API surfaces bounded failed evaluation details", async
}
assert(message.includes("structured diagnostics"));
});
Deno.test("config source API carries bounded optional authoring schemas without changing validation source", () => {
const body = withSchema("validation");
const authoringSource = "shape\n".repeat(6000);
const contribution = {
...body.contract.schema_bundle.contributions[0],
authoring_source: authoringSource,
};
const withAuthoring = {
...body,
contract: {
...body.contract,
schema_bundle: {
...body.contract.schema_bundle,
contributions: [contribution],
},
},
};
const parsed = parseWorkspaceConfigTreeResponse(withAuthoring);
assertEquals(
parsed.contract.schema_bundle.contributions[0].authoring_source,
authoringSource,
);
assertEquals(parsed.contract.schema_bundle.source, "validation");
for (const invalid of [null, 42, {}, "é".repeat(4 * 1024 * 1024) + "x"]) {
assertThrows(() =>
parseWorkspaceConfigTreeResponse({
...withAuthoring,
contract: {
...body.contract,
schema_bundle: {
...body.contract.schema_bundle,
contributions: [{ ...contribution, authoring_source: invalid }],
},
},
}), ConfigSourceApiError);
}
});
@@ -381,3 +381,113 @@ Deno.test("generated WASM completes asserted WorkspaceConfigSchema keys", () =>
assertEquals(result.from, 2);
assertEquals(result.items.some((item) => item.label === "profile"), true);
});
Deno.test("generated WASM authors value-based Profiles using the Backend schema without materializing authoring fields", async () => {
const source = await Deno.readTextFile(
new URL(
"../../../../resources/config-schema/profile.dcdl",
import.meta.url,
),
);
const authoring_source = await Deno.readTextFile(
new URL(
"../../../../resources/config-schema/profile-authoring.dcdl",
import.meta.url,
),
);
const schema = compose_schema_bundle([{
provider_id: "builtin:profile",
namespace: "profile",
version: "2",
source,
source_digest: await digestText(source),
authoring_source,
}]) as WorkspaceConfigSchemaBundle;
const content =
'{ profile = { entries = [{ selector = "project:alpha"; profile = {}; }]; }; } as WorkspaceConfigSchema';
const tree: ConfigTreeSnapshot = {
revision: 8,
digest: "sha256:value-profile-tree",
entries: {
"main.dcdl": {
path: "main.dcdl",
content_type: "decodal",
content,
content_digest: await digestText(content),
},
},
};
const valueContract = {
...contract,
entrypoints: ["main.dcdl"],
schema_bundle: schema,
fingerprint: await toolchainFingerprint(["main.dcdl"], schema),
};
set_snapshot(tree);
set_schema_bundle(schema);
const evaluated = evaluate_snapshot(tree, valueContract) as {
projections: Array<
{ data_json: { profile: { entries: Array<{ profile: unknown }> } } }
>;
};
assertEquals(
evaluated.projections[0].data_json.profile.entries[0].profile,
{},
);
for (
const [body, token, label] of [
[
"{ profile = { entries = [{ sel }] } } as WorkspaceConfigSchema",
"sel",
"selector",
],
[
"{ profile = { entries = [{ pro }] } } as WorkspaceConfigSchema",
"pro",
"profile",
],
[
"{ profile = { entries = [{ profile = { wor } }] } } as WorkspaceConfigSchema",
"wor",
"worker",
],
[
"{ profile = { entries = [{ profile = { worker = { mo } } }] } } as WorkspaceConfigSchema",
"mo",
"mode",
],
[
"{ profile = { entries = [{ profile = { feature = { ta } } }] } } as WorkspaceConfigSchema",
"ta",
"task",
],
]
) {
const cursor = body.lastIndexOf(token) + token.length;
const result = complete_current("main.dcdl", body, cursor, true) as {
from: number;
items: Array<{ label: string }>;
};
assertEquals(
result.items.some((item) => item.label === label),
true,
label,
);
assertEquals(result.from, cursor - token.length);
}
const invalidContent = content.replace("profile = {};", "profile = 42;");
const invalid = {
...tree,
entries: {
"main.dcdl": { ...tree.entries["main.dcdl"], content: invalidContent },
},
};
const diagnostics = analyze_snapshot(
invalid,
"main.dcdl",
undefined,
) as Array<
unknown
>;
assertEquals(diagnostics.length > 0, true);
});