diff --git a/crates/config-source/src/lib.rs b/crates/config-source/src/lib.rs index a5df85dce..2d6604d82 100644 --- a/crates/config-source/src/lib.rs +++ b/crates/config-source/src/lib.rs @@ -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, #[serde(default, skip_serializing_if = "Option::is_none")] pub projection_validator: Option, 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) -> 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::>() .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::>(), - 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::>(), + 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::>(); + // 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::>() + .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, + schema_path: Vec, from: usize, } #[derive(Debug)] enum ConfigCompletionContainer { Object { - schema_path: Vec, + schema_path: Vec, pending_path: Vec, last_identifier_from: Option, trailing_dot: bool, reading_value: bool, }, Array { - schema_path: Vec, + schema_path: Vec, }, Other { - schema_path: Vec, + schema_path: Vec, }, } @@ -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 { +fn pending_container_path( + containers: &[ConfigCompletionContainer], +) -> Vec { 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) -> 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::(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::(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( diff --git a/crates/server-api/src/lib.rs b/crates/server-api/src/lib.rs index 0062c3bc5..65c717d8b 100644 --- a/crates/server-api/src/lib.rs +++ b/crates/server-api/src/lib.rs @@ -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, + /// 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, pub source_digest: String, } diff --git a/crates/worker-runtime/src/profile_archive.rs b/crates/worker-runtime/src/profile_archive.rs index 6c056b878..a38ff72dd 100644 --- a/crates/worker-runtime/src/profile_archive.rs +++ b/crates/worker-runtime/src/profile_archive.rs @@ -125,6 +125,35 @@ pub struct ProfileSourceArchiveInput { impl ProfileSourceArchive { pub fn build(input: ProfileSourceArchiveInput) -> Result { + 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 { + 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 { 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::(&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, + ) { + 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(); diff --git a/crates/workspace-server/src/config_source.rs b/crates/workspace-server/src/config_source.rs index 2a127bd9c..19d566416 100644 --- a/crates/workspace-server/src/config_source.rs +++ b/crates/workspace-server/src/config_source.rs @@ -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)?; diff --git a/crates/workspace-server/src/profile_settings.rs b/crates/workspace-server/src/profile_settings.rs index 5c38a099b..2d08b2e31 100644 --- a/crates/workspace-server/src/profile_settings.rs +++ b/crates/workspace-server/src/profile_settings.rs @@ -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::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, - sources: BTreeMap, } 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 { + 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::>(); 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 { - 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> { + 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, state: &WorkspaceConfigState, ) -> Result { - 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, message: impl Into) } } -fn collect_profile_import_closure( - current_path: &str, - all_sources: &BTreeMap, - closure_sources: &mut BTreeMap, - imports: &mut BTreeMap, -) -> 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 { - 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 { - 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 { - 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 { @@ -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!({})) ); } } diff --git a/crates/workspace-server/src/server.rs b/crates/workspace-server/src/server.rs index 84fb046e1..ac8759821 100644 --- a/crates/workspace-server/src/server.rs +++ b/crates/workspace-server/src/server.rs @@ -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::*; diff --git a/crates/workspace-server/src/server/tests/subjektiv_jobs_tests.rs b/crates/workspace-server/src/server/tests/subjektiv_jobs_tests.rs index 78c191a4d..6905bfe58 100644 --- a/crates/workspace-server/src/server/tests/subjektiv_jobs_tests.rs +++ b/crates/workspace-server/src/server/tests/subjektiv_jobs_tests.rs @@ -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(), diff --git a/crates/workspace-server/src/server/tests/value_profiles_tests.rs b/crates/workspace-server/src/server/tests/value_profiles_tests.rs new file mode 100644 index 000000000..984f8f5b4 --- /dev/null +++ b/crates/workspace-server/src/server/tests/value_profiles_tests.rs @@ -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, +) -> 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" + ); +} diff --git a/docs/design/profiles-manifests-prompts.md b/docs/design/profiles-manifests-prompts.md index 4e98f622a..8d5192f39 100644 --- a/docs/design/profiles-manifests-prompts.md +++ b/docs/design/profiles-manifests-prompts.md @@ -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 ` 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 diff --git a/openapi/server-api.json b/openapi/server-api.json index c217d3c0f..9eb8e17d3 100644 --- a/openapi/server-api.json +++ b/openapi/server-api.json @@ -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": { diff --git a/resources/config-schema/profile-authoring.dcdl b/resources/config-schema/profile-authoring.dcdl new file mode 100644 index 000000000..62317038e --- /dev/null +++ b/resources/config-schema/profile-authoring.dcdl @@ -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; + }]; + }; +} diff --git a/resources/config-schema/profile.dcdl b/resources/config-schema/profile.dcdl new file mode 100644 index 000000000..aff4ccadc --- /dev/null +++ b/resources/config-schema/profile.dcdl @@ -0,0 +1,11 @@ +{ + profile = { + default_profile = String default "builtin:companion"; + entries = [...{ + selector = String; + profile = { ...Unknown }; + label = String default ""; + description = String default ""; + }] default []; + }; +} diff --git a/web/workspace/deno.json b/web/workspace/deno.json index 9479ef44f..e17a7fb8b 100644 --- a/web/workspace/deno.json +++ b/web/workspace/deno.json @@ -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", diff --git a/web/workspace/src/lib/generated/companion-api.ts b/web/workspace/src/lib/generated/companion-api.ts index 8a530c53d..f85678a77 100644 --- a/web/workspace/src/lib/generated/companion-api.ts +++ b/web/workspace/src/lib/generated/companion-api.ts @@ -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 diff --git a/web/workspace/src/lib/generated/legacy-server-api.ts b/web/workspace/src/lib/generated/legacy-server-api.ts index 7a34b2e60..aeb99cdaf 100644 --- a/web/workspace/src/lib/generated/legacy-server-api.ts +++ b/web/workspace/src/lib/generated/legacy-server-api.ts @@ -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; }; diff --git a/web/workspace/src/lib/generated/repository-api.ts b/web/workspace/src/lib/generated/repository-api.ts index 5b72d2098..2ac059cfb 100644 --- a/web/workspace/src/lib/generated/repository-api.ts +++ b/web/workspace/src/lib/generated/repository-api.ts @@ -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 diff --git a/web/workspace/src/lib/generated/runtime-api.ts b/web/workspace/src/lib/generated/runtime-api.ts index 53eeae412..9197abbc9 100644 --- a/web/workspace/src/lib/generated/runtime-api.ts +++ b/web/workspace/src/lib/generated/runtime-api.ts @@ -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 diff --git a/web/workspace/src/lib/generated/ticket-api.ts b/web/workspace/src/lib/generated/ticket-api.ts index 657aa7d1b..530b8fbe0 100644 --- a/web/workspace/src/lib/generated/ticket-api.ts +++ b/web/workspace/src/lib/generated/ticket-api.ts @@ -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 diff --git a/web/workspace/src/lib/generated/worker-launch-api.ts b/web/workspace/src/lib/generated/worker-launch-api.ts index f56aba174..2ec93ec9e 100644 --- a/web/workspace/src/lib/generated/worker-launch-api.ts +++ b/web/workspace/src/lib/generated/worker-launch-api.ts @@ -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 diff --git a/web/workspace/src/lib/workspace/config-source/api.ts b/web/workspace/src/lib/workspace/config-source/api.ts index c277ccc5d..2afb76407 100644 --- a/web/workspace/src/lib/workspace/config-source/api.ts +++ b/web/workspace/src/lib/workspace/config-source/api.ts @@ -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), }; diff --git a/web/workspace/src/lib/workspace/config-source/generated/config_source_wasm_bg.wasm b/web/workspace/src/lib/workspace/config-source/generated/config_source_wasm_bg.wasm index 4662c4ac3..8fc0b4245 100644 Binary files a/web/workspace/src/lib/workspace/config-source/generated/config_source_wasm_bg.wasm and b/web/workspace/src/lib/workspace/config-source/generated/config_source_wasm_bg.wasm differ diff --git a/web/workspace/src/lib/workspace/config-source/generated/types/ConfigSchemaContribution.ts b/web/workspace/src/lib/workspace/config-source/generated/types/ConfigSchemaContribution.ts index 69d5a595d..957a1a06a 100644 --- a/web/workspace/src/lib/workspace/config-source/generated/types/ConfigSchemaContribution.ts +++ b/web/workspace/src/lib/workspace/config-source/generated/types/ConfigSchemaContribution.ts @@ -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, }; diff --git a/web/workspace/test/config-source/api.test.ts b/web/workspace/test/config-source/api.test.ts index d0269c215..6bded5f07 100644 --- a/web/workspace/test/config-source/api.test.ts +++ b/web/workspace/test/config-source/api.test.ts @@ -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); + } +}); diff --git a/web/workspace/test/config-source/wasm-parity.test.ts b/web/workspace/test/config-source/wasm-parity.test.ts index 23449ea63..637299db7 100644 --- a/web/workspace/test/config-source/wasm-parity.test.ts +++ b/web/workspace/test/config-source/wasm-parity.test.ts @@ -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); +});