diff --git a/Cargo.lock b/Cargo.lock index de317867..b4a6768f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -596,9 +596,11 @@ dependencies = [ "decodal", "decodal-language-service", "decodal-language-tools", + "minijinja", "pretty_assertions", "serde", "serde_json", + "serde_yaml", "sha2 0.11.0", "thiserror 2.0.18", "ts-rs", @@ -986,24 +988,24 @@ checksum = "092966b41edc516079bdf31ec78a2e0588d1d0c08f78b91d8307215928642b2b" [[package]] name = "decodal" -version = "0.2.0" +version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2b6e47d6bc66cd3cd42c8df8ff77a994a7743e889045c6b762a6dbc360ad8494" +checksum = "30e2a1ff0bf0d4160b998401a82aef64d26395e5b4798c55fbca6880a52f8e64" [[package]] name = "decodal-language-service" -version = "0.2.0" +version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f25e462dce7c86743bd229ba91b831daf7928d524de9cef4ef861257ca156aa8" +checksum = "577f8cdf109dc318c6bef32c95194f0b164ccea234ab0f70a02aeb3296813b16" dependencies = [ "decodal", ] [[package]] name = "decodal-language-tools" -version = "0.2.0" +version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "08d8e3eb978cb1c2259df838ba2a8102bc45553d7b415216c80fc5b6a3f378c6" +checksum = "2a18ca80b27386c5dbb160c74e88e6c1bfe33fd80f3cd5108bebe800b8aa698c" dependencies = [ "decodal", "serde_json", @@ -4793,7 +4795,6 @@ dependencies = [ "fs4", "llm-engine", "manifest", - "minijinja", "protocol", "pulldown-cmark", "ratatui", @@ -4807,6 +4808,7 @@ dependencies = [ "toml", "unicode-width", "uuid", + "worker", ] [[package]] @@ -6058,6 +6060,7 @@ dependencies = [ "chrono", "clap", "client", + "config-source", "dotenv", "flow", "fs4", diff --git a/Cargo.toml b/Cargo.toml index 78bd14e2..ad9a86a8 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -98,9 +98,9 @@ yoi-workspace-server = { path = "crates/workspace-server" } async-trait = "0.1" axum = "0.8" base64 = "0.22.1" -decodal = "0.2.0" -decodal-language-service = "0.2.0" -decodal-language-tools = "0.2.0" +decodal = "0.4.0" +decodal-language-service = "0.4.0" +decodal-language-tools = "0.4.0" fs4 = "0.13" futures = "0.3" libc = "0.2" diff --git a/crates/client/src/ticket_role.rs b/crates/client/src/ticket_role.rs index c31bca70..21f80f19 100644 --- a/crates/client/src/ticket_role.rs +++ b/crates/client/src/ticket_role.rs @@ -1028,7 +1028,7 @@ profile = "builtin:companion" r#" [ticket.roles.reviewer] profile = "builtin:companion" -launch_prompt = "$workspace/ticket/reviewer/launch" +launch_prompt = "ticket.reviewer.launch" "#, ); let mut context = TicketRoleLaunchContext::new(temp.path(), TicketRole::Reviewer); @@ -1043,11 +1043,11 @@ launch_prompt = "$workspace/ticket/reviewer/launch" assert_eq!(plan.profile, "builtin:companion"); assert_eq!( plan.launch_prompt_ref.as_deref(), - Some("$workspace/ticket/reviewer/launch") + Some("ticket.reviewer.launch") ); assert!(matches!(&plan.run_segments[0], Segment::Text { .. })); assert!(!text.contains("Configured launch_prompt")); - assert!(!text.contains("$workspace/ticket/reviewer/launch")); + assert!(!text.contains("ticket.reviewer.launch")); assert!(!text.contains("Profile selector: builtin:companion")); assert!(!text.contains("Role: reviewer")); assert!(!text.contains("system_instruction")); @@ -1228,7 +1228,7 @@ profile = "./coder.toml" r#" [ticket.roles.coder] profile = "inherit" -system_instruction = "$workspace/not-supported" +system_instruction = "unsupported" "#, ); let context = TicketRoleLaunchContext::new(temp.path(), TicketRole::Coder); diff --git a/crates/config-source/Cargo.toml b/crates/config-source/Cargo.toml index d86dc5d5..b9ee3ecf 100644 --- a/crates/config-source/Cargo.toml +++ b/crates/config-source/Cargo.toml @@ -9,8 +9,10 @@ publish = false decodal.workspace = true decodal-language-service.workspace = true decodal-language-tools.workspace = true +minijinja = "2.19.0" serde = { workspace = true, features = ["derive"] } serde_json.workspace = true +serde_yaml.workspace = true sha2.workspace = true thiserror.workspace = true ts-rs = "12.0.1" diff --git a/crates/config-source/src/lib.rs b/crates/config-source/src/lib.rs index 3e5ba964..1dd92cc5 100644 --- a/crates/config-source/src/lib.rs +++ b/crates/config-source/src/lib.rs @@ -3,20 +3,20 @@ use std::fmt; use decodal::{ Data, Diagnostic, DiagnosticKind, Engine, HostEnvironment, ImportCandidate, ImportLoader, - LoadedImport, Span, + LoadedImport, Span, Value, }; use decodal_language_service::{CompletionResult, LanguageService}; use serde::{Deserialize, Serialize}; use sha2::{Digest, Sha256}; pub const CONFIG_SOURCE_CONTRACT_VERSION: u32 = 2; -pub const DECODAL_VERSION: &str = "0.2.0"; +pub const DECODAL_VERSION: &str = "0.4.0"; pub const DEFAULT_SCHEMA_VERSION: u32 = 1; pub const DEFAULT_IMPORT_POLICY_VERSION: u32 = 1; pub const WORKSPACE_CONFIG_SCHEMA_GLOBAL: &str = "WorkspaceConfigSchema"; pub const WORKSPACE_CONFIG_SCHEMA_SOURCE: &str = "workspace-config-schema.dcdl"; pub const WORKSPACE_CONFIG_EVALUATION_SOURCE: &str = - "WorkspaceConfigSchema & import \"__MAIN_ENTRYPOINT__\""; + "import \"__MAIN_ENTRYPOINT__\" as WorkspaceConfigSchema"; pub const MAX_ENTRY_COUNT: usize = 256; pub const MAX_CHANGE_COUNT: usize = 256; pub const MAX_ENTRY_BYTES: usize = 256 * 1024; @@ -91,6 +91,151 @@ impl ConfigContentType { } } +/// Stable value projection used when a virtual config source imports Markdown. +/// +/// Frontmatter delimiters are transport syntax and are intentionally absent from +/// `content`; unknown frontmatter keys stay in `frontmatter` without a +/// domain-specific parser interpreting them. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct MarkdownDocumentProjection { + pub frontmatter: serde_json::Map, + pub content: String, +} + +/// Parse one Markdown source into the common virtual-config import shape. +/// +/// Files without a leading YAML frontmatter delimiter produce an empty +/// frontmatter object and preserve the complete file as `content`. +pub fn project_markdown_document(source: &str) -> Result { + let Some(after_opening) = source + .strip_prefix("---\n") + .or_else(|| source.strip_prefix("---\r\n")) + else { + return Ok(MarkdownDocumentProjection { + frontmatter: serde_json::Map::new(), + content: source.to_string(), + }); + }; + + let mut frontmatter_end = None; + let mut offset = 0usize; + for line_with_ending in after_opening.split_inclusive('\n') { + let line = line_with_ending.trim_end_matches(['\r', '\n']); + if line == "---" { + frontmatter_end = Some((offset, offset + line_with_ending.len())); + break; + } + offset += line_with_ending.len(); + } + if frontmatter_end.is_none() && after_opening.ends_with("---") { + let start = after_opening.len() - 3; + if start == 0 || after_opening[..start].ends_with('\n') { + frontmatter_end = Some((start, after_opening.len())); + } + } + let Some((frontmatter_end, content_start)) = frontmatter_end else { + return Err("opening YAML frontmatter delimiter has no closing delimiter".to_string()); + }; + + let frontmatter_source = &after_opening[..frontmatter_end]; + let frontmatter = if frontmatter_source.trim().is_empty() { + serde_json::Map::new() + } else { + let yaml = serde_yaml::from_str::(frontmatter_source) + .map_err(|error| format!("invalid YAML frontmatter: {error}"))?; + let value = yaml_to_json(yaml)?; + value + .as_object() + .cloned() + .ok_or_else(|| "YAML frontmatter must be a mapping".to_string())? + }; + + Ok(MarkdownDocumentProjection { + frontmatter, + content: after_opening[content_start..].to_string(), + }) +} + +fn yaml_to_json(value: serde_yaml::Value) -> Result { + match value { + serde_yaml::Value::Null => Ok(serde_json::Value::Null), + serde_yaml::Value::Bool(value) => Ok(serde_json::Value::Bool(value)), + serde_yaml::Value::Number(value) => { + if let Some(value) = value.as_i64() { + Ok(serde_json::Value::Number(value.into())) + } else if let Some(value) = value.as_u64() { + Ok(serde_json::Value::Number(value.into())) + } else if let Some(value) = value.as_f64() { + serde_json::Number::from_f64(value) + .map(serde_json::Value::Number) + .ok_or_else(|| "YAML frontmatter contains a non-finite number".to_string()) + } else { + Err("YAML frontmatter contains an unsupported number".to_string()) + } + } + serde_yaml::Value::String(value) => Ok(serde_json::Value::String(value)), + serde_yaml::Value::Sequence(values) => values + .into_iter() + .map(yaml_to_json) + .collect::, _>>() + .map(serde_json::Value::Array), + serde_yaml::Value::Mapping(values) => { + let mut object = serde_json::Map::new(); + for (key, value) in values { + let serde_yaml::Value::String(key) = key else { + return Err("YAML frontmatter mapping keys must be strings".to_string()); + }; + object.insert(key, yaml_to_json(value)?); + } + Ok(serde_json::Value::Object(object)) + } + serde_yaml::Value::Tagged(_) => Err("YAML frontmatter tags are not supported".to_string()), + } +} + +fn markdown_projection_to_value(projection: MarkdownDocumentProjection) -> Result { + Ok(Value::object([ + ( + "frontmatter", + json_to_decodal_value(serde_json::Value::Object(projection.frontmatter))?, + ), + ("content", Value::string(projection.content)), + ])) +} + +fn json_to_decodal_value(value: serde_json::Value) -> Result { + match value { + serde_json::Value::Null => Err( + "YAML null values cannot be represented as concrete Decodal import values".to_string(), + ), + serde_json::Value::Bool(value) => Ok(Value::bool(value)), + serde_json::Value::Number(value) => { + if let Some(value) = value.as_i64() { + Ok(Value::int(value)) + } else if let Some(value) = value.as_u64() { + i64::try_from(value) + .map(Value::int) + .map_err(|_| "YAML integer exceeds the Decodal i64 range".to_string()) + } else if let Some(value) = value.as_f64() { + Ok(Value::float(value)) + } else { + Err("JSON number cannot be represented as a Decodal value".to_string()) + } + } + serde_json::Value::String(value) => Ok(Value::string(value)), + serde_json::Value::Array(values) => values + .into_iter() + .map(json_to_decodal_value) + .collect::, _>>() + .map(Value::array), + serde_json::Value::Object(values) => values + .into_iter() + .map(|(name, value)| Ok((name, json_to_decodal_value(value)?))) + .collect::, _>>() + .map(Value::object), + } +} + #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, ts_rs::TS)] pub struct ConfigEntry { pub path: VirtualPath, @@ -325,6 +470,17 @@ impl ConfigTreeChange { } } +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, ts_rs::TS)] +#[serde(tag = "kind", rename_all = "snake_case")] +#[ts(export)] +pub enum ConfigProjectionValidator { + StaticTemplateCatalog { + namespace: String, + #[serde(default, skip_serializing_if = "BTreeMap::is_empty")] + key_aliases: BTreeMap, + }, +} + #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, ts_rs::TS)] #[ts(export)] pub struct ConfigSchemaContribution { @@ -332,6 +488,8 @@ pub struct ConfigSchemaContribution { pub namespace: String, pub version: String, pub source: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub projection_validator: Option, pub source_digest: String, } @@ -372,9 +530,15 @@ impl ConfigSchemaContribution { version, source_digest: digest_bytes(source.as_bytes()), source, + projection_validator: None, }) } + pub fn with_projection_validator(mut self, validator: ConfigProjectionValidator) -> Self { + self.projection_validator = Some(validator); + self + } + fn validate(&self) -> Result<(), ConfigTreeError> { let expected = digest_bytes(self.source.as_bytes()); if self.source_digest != expected { @@ -440,6 +604,7 @@ impl WorkspaceConfigSchemaBundle { contribution.namespace.as_str(), contribution.version.as_str(), contribution.source_digest.as_str(), + contribution.projection_validator.as_ref(), ) }) .collect::>(), @@ -686,8 +851,11 @@ impl SnapshotEnvironment { )] })?; engine.bind_global_runtime(WORKSPACE_CONFIG_SCHEMA_GLOBAL, schema); - let evaluation_source = - WORKSPACE_CONFIG_EVALUATION_SOURCE.replace("__MAIN_ENTRYPOINT__", entrypoint.as_str()); + let evaluation_source = if contract.schema_bundle.contributions.is_empty() { + format!("import \"{}\"", entrypoint.as_str()) + } else { + WORKSPACE_CONFIG_EVALUATION_SOURCE.replace("__MAIN_ENTRYPOINT__", entrypoint.as_str()) + }; let evaluation_module = engine .add_root_source( "workspace-config-evaluation.dcdl", @@ -721,6 +889,15 @@ impl SnapshotEnvironment { )] })?; let data_json = decodal_data_to_json(&data); + if let Err(message) = + validate_projection_contracts(&data_json, &contract.schema_bundle.contributions) + { + return Err(vec![self.config_error( + entrypoint.clone(), + "projection_validation", + message, + )]); + } let projection_digest = digest_bytes( serde_json::to_vec(&data_json) .expect("Decodal projection serializes") @@ -850,8 +1027,26 @@ impl ImportLoader for SnapshotImportLoader { format!("virtual config import is missing: {path}"), ) })?; + let cache_key = snapshot_import_cache_key(entry); + if path.as_str().ends_with(".md") { + let projection = project_markdown_document(&entry.content).map_err(|message| { + Diagnostic::new( + DiagnosticKind::Import, + Span::default(), + format!("failed to import Markdown `{path}`: {message}"), + ) + })?; + let value = markdown_projection_to_value(projection).map_err(|message| { + Diagnostic::new( + DiagnosticKind::Import, + Span::default(), + format!("failed to import Markdown `{path}`: {message}"), + ) + })?; + return Ok(LoadedImport::value(cache_key, value)); + } Ok(LoadedImport::source( - path.as_str(), + cache_key, path.as_str(), entry.content.clone(), )) @@ -873,6 +1068,13 @@ impl ImportLoader for SnapshotImportLoader { } } +fn snapshot_import_cache_key(entry: &ConfigEntry) -> String { + // The source id remains the virtual path for diagnostics and relative-import + // resolution. The cache identity also includes immutable source content so + // equal paths from different revisions cannot alias in an Engine cache. + format!("{}@{}", entry.path, entry.content_digest) +} + pub fn resolve_import( current: &VirtualPath, specifier: &str, @@ -1050,6 +1252,165 @@ fn decodal_data_to_json(data: &Data) -> serde_json::Value { } } +fn validate_projection_contracts( + projection: &serde_json::Value, + contributions: &[ConfigSchemaContribution], +) -> Result<(), String> { + for contribution in contributions { + let Some(ConfigProjectionValidator::StaticTemplateCatalog { + namespace, + key_aliases, + }) = &contribution.projection_validator + else { + continue; + }; + let value = projection + .get(namespace) + .ok_or_else(|| format!("projection has no '{namespace}' template namespace"))?; + let mut templates = BTreeMap::new(); + flatten_string_catalog("", value, &mut templates)?; + for (source, target) in key_aliases { + if let Some(value) = templates.remove(source) { + if templates.insert(target.clone(), value).is_some() { + return Err(format!( + "template catalog alias '{source}' collides with '{target}'" + )); + } + } + } + validate_static_template_catalog(&templates)?; + } + Ok(()) +} + +fn flatten_string_catalog( + prefix: &str, + value: &serde_json::Value, + output: &mut BTreeMap, +) -> Result<(), String> { + match value { + serde_json::Value::String(source) if !prefix.is_empty() => { + output.insert(prefix.to_string(), source.clone()); + Ok(()) + } + serde_json::Value::Object(fields) => { + for (name, value) in fields { + let key = if prefix.is_empty() { + name.clone() + } else { + format!("{prefix}.{name}") + }; + flatten_string_catalog(&key, value, output)?; + } + Ok(()) + } + _ => Err(format!( + "template catalog leaf '{}' must be a string", + if prefix.is_empty() { "" } else { prefix } + )), + } +} + +pub fn validate_static_template_catalog( + templates: &BTreeMap, +) -> Result<(), String> { + if templates.is_empty() { + return Err("template catalog is empty".to_string()); + } + let mut environment = minijinja::Environment::new(); + environment.set_undefined_behavior(minijinja::UndefinedBehavior::Strict); + let mut graph = BTreeMap::new(); + for (name, source) in templates { + environment + .add_template_owned(name.clone(), source.clone()) + .map_err(|error| format!("template '{name}' does not compile: {error}"))?; + let includes = parse_static_template_includes(name, source)?; + for target in &includes { + if !templates.contains_key(target) { + return Err(format!( + "template '{name}' includes missing target '{target}'" + )); + } + } + graph.insert(name.clone(), includes); + } + fn visit( + node: &str, + graph: &BTreeMap>, + visiting: &mut Vec, + visited: &mut BTreeSet, + ) -> Result<(), String> { + if let Some(position) = visiting.iter().position(|entry| entry == node) { + let mut cycle = visiting[position..].to_vec(); + cycle.push(node.to_string()); + return Err(format!("template include cycle: {}", cycle.join(" -> "))); + } + if visited.contains(node) { + return Ok(()); + } + visiting.push(node.to_string()); + for target in &graph[node] { + visit(target, graph, visiting, visited)?; + } + visiting.pop(); + visited.insert(node.to_string()); + Ok(()) + } + let mut visited = BTreeSet::new(); + for node in graph.keys() { + visit(node, &graph, &mut Vec::new(), &mut visited)?; + } + Ok(()) +} + +fn parse_static_template_includes(template: &str, source: &str) -> Result, String> { + let mut includes = Vec::new(); + let mut rest = source; + while let Some(open) = rest.find("{%") { + let after_open = &rest[open + 2..]; + let Some(close) = after_open.find("%}") else { + break; + }; + let body = after_open[..close].trim(); + let body = body.strip_prefix('-').unwrap_or(body).trim_start(); + let body = body.strip_suffix('-').unwrap_or(body).trim_end(); + if body.starts_with("include") { + let argument = body["include".len()..].trim(); + let bytes = argument.as_bytes(); + if bytes.len() < 2 + || !matches!(bytes[0], b'\'' | b'"') + || bytes[bytes.len() - 1] != bytes[0] + { + return Err(format!( + "template '{template}' include target must be one exact quoted dotted name" + )); + } + let target = &argument[1..argument.len() - 1]; + if target.is_empty() + || target.contains('/') + || target.contains('\\') + || target.contains('$') + || target.ends_with(".md") + || target.split('.').any(|segment| { + segment.is_empty() + || !segment.chars().all(|character| { + character.is_ascii_lowercase() + || character.is_ascii_digit() + || character == '_' + }) + }) + { + return Err(format!( + "template '{template}' has invalid catalog-root include target '{target}'" + )); + } + includes.push(target.to_string()); + } + rest = &after_open[close + 2..]; + } + Ok(includes) +} + fn snapshot_digest(entries: &BTreeMap) -> String { let mut hasher = Sha256::new(); hasher.update(b"yoi-config-tree-v1\0"); @@ -1144,6 +1505,10 @@ mod tests { ConfigEntry::new(path(path_value), ConfigContentType::Decodal, content).unwrap() } + fn text_entry(path_value: &str, content: &str) -> ConfigEntry { + ConfigEntry::new(path(path_value), ConfigContentType::Text, content).unwrap() + } + #[test] fn virtual_paths_reject_ambiguous_or_escaping_forms() { for invalid in ["", "/root.dcdl", "a//b", "a/./b", "a/../b", "a\\b", "a\0b"] { @@ -1270,10 +1635,9 @@ mod tests { } #[test] - fn workspace_schema_is_applied_with_normal_decodal_composition() { + fn workspace_schema_applies_defaults_with_asymmetric_decodal_validation() { let snapshot = - ConfigTreeSnapshot::from_entries(1, [entry("main.dcdl", "{ web = {}; custom = 42; }")]) - .unwrap(); + ConfigTreeSnapshot::from_entries(1, [entry("main.dcdl", "{ web = {}; }")]).unwrap(); let schema = WorkspaceConfigSchemaBundle::compose([ConfigSchemaContribution::new( "builtin:web", "web", @@ -1287,7 +1651,194 @@ mod tests { .evaluate_contract(&contract) .unwrap(); assert_eq!(result.projections[0].data_json["web"]["enabled"], false); - assert_eq!(result.projections[0].data_json["custom"], 42); + } + + #[test] + fn workspace_schema_rejects_unknown_root_and_nested_fields() { + let schema = || { + WorkspaceConfigSchemaBundle::compose([ConfigSchemaContribution::new( + "builtin:web", + "web", + "1", + "{ web = { enabled = Bool default false; }; }", + ) + .unwrap()]) + .unwrap() + }; + for (source, unknown_field) in [ + ("{ web = {}; custom = 42; }", "custom"), + ("{ web = { typo = true; }; }", "typo"), + ] { + let snapshot = + ConfigTreeSnapshot::from_entries(1, [entry("main.dcdl", source)]).unwrap(); + let diagnostics = SnapshotEnvironment::new(snapshot) + .evaluate_contract(&ToolchainContract::with_schema_bundle( + 1, + vec![path("main.dcdl")], + 1, + schema(), + )) + .unwrap_err(); + assert_eq!(diagnostics[0].path, path("main.dcdl")); + assert_eq!(diagnostics[0].kind, "constraintviolation"); + assert!(diagnostics[0].message.contains(unknown_field)); + assert!(diagnostics[0].span.end_byte > diagnostics[0].span.start_byte); + } + } + + #[test] + fn workspace_schema_supports_typed_associative_collections() { + let snapshot = ConfigTreeSnapshot::from_entries( + 1, + [entry( + "main.dcdl", + "{ features = { web = { enabled = true; }; tickets = { enabled = false; }; }; }", + )], + ) + .unwrap(); + let schema = WorkspaceConfigSchemaBundle::compose([ConfigSchemaContribution::new( + "builtin:features", + "features", + "1", + "{ features = {...{ enabled = Bool; }}; }", + ) + .unwrap()]) + .unwrap(); + let result = SnapshotEnvironment::new(snapshot) + .evaluate_contract(&ToolchainContract::with_schema_bundle( + 1, + vec![path("main.dcdl")], + 1, + schema, + )) + .unwrap(); + assert_eq!( + result.projections[0].data_json["features"]["web"]["enabled"], + true + ); + assert_eq!( + result.projections[0].data_json["features"]["tickets"]["enabled"], + false + ); + } + + #[test] + fn workspace_typed_associative_values_remain_closed_and_typed() { + let schema = || { + WorkspaceConfigSchemaBundle::compose([ConfigSchemaContribution::new( + "builtin:features", + "features", + "1", + "{ features = {...{ enabled = Bool; }}; }", + ) + .unwrap()]) + .unwrap() + }; + for (source, expected_kind) in [ + ( + "{ features = { web = { enabled = \"yes\"; }; }; }", + "constraintviolation", + ), + ("{ features = { web = {}; }; }", "materialize"), + ( + "{ features = { web = { enabled = true; typo = 1; }; }; }", + "constraintviolation", + ), + ] { + let snapshot = + ConfigTreeSnapshot::from_entries(1, [entry("main.dcdl", source)]).unwrap(); + let diagnostics = SnapshotEnvironment::new(snapshot) + .evaluate_contract(&ToolchainContract::with_schema_bundle( + 1, + vec![path("main.dcdl")], + 1, + schema(), + )) + .unwrap_err(); + assert_eq!(diagnostics[0].path, path("main.dcdl")); + assert_eq!(diagnostics[0].kind, expected_kind); + assert!(diagnostics[0].span.end_byte > diagnostics[0].span.start_byte); + } + } + + #[test] + fn language_service_and_formatter_accept_decodal_0_4_schema_syntax() { + let source = + "{} as { features = {...{ enabled = Bool; }}; web = { enabled = Bool; ...Unknown }; }"; + let snapshot = ConfigTreeSnapshot::from_entries(1, [entry("schema.dcdl", source)]).unwrap(); + let environment = SnapshotEnvironment::new(snapshot); + let diagnostics = environment.analyze(&path("schema.dcdl"), None); + assert!( + diagnostics + .iter() + .all(|diagnostic| diagnostic.kind != "syntax"), + "{diagnostics:#?}" + ); + + let completion_source = "{} as { web = { enabled = Unk } }"; + let completion = environment + .complete( + &path("schema.dcdl"), + completion_source, + completion_source.find("Unk").unwrap() + "Unk".len(), + true, + ) + .unwrap() + .expect("explicit completion is available"); + assert!(format!("{completion:?}").contains("Unknown")); + + let formatted = environment.format(source).unwrap(); + assert!(formatted.contains(" as ")); + assert!(formatted.contains("...Unknown")); + assert!( + environment + .analyze(&path("schema.dcdl"), Some(&formatted)) + .iter() + .all(|diagnostic| diagnostic.kind != "syntax") + ); + } + + #[test] + fn workspace_schema_preserves_fields_only_where_rest_is_explicit() { + let snapshot = ConfigTreeSnapshot::from_entries( + 1, + [entry( + "main.dcdl", + "{ web = { enabled = true; extension_value = 42; }; }", + )], + ) + .unwrap(); + let schema = WorkspaceConfigSchemaBundle::compose([ConfigSchemaContribution::new( + "builtin:web", + "web", + "1", + "{ web = { enabled = Bool; ...Unknown }; }", + ) + .unwrap()]) + .unwrap(); + let result = SnapshotEnvironment::new(snapshot) + .evaluate_contract(&ToolchainContract::with_schema_bundle( + 1, + vec![path("main.dcdl")], + 1, + schema, + )) + .unwrap(); + assert_eq!( + result.projections[0].data_json["web"]["extension_value"], + 42 + ); + } + + #[test] + fn unresolved_unknown_cannot_be_materialized() { + let snapshot = + ConfigTreeSnapshot::from_entries(1, [entry("main.dcdl", "Unknown")]).unwrap(); + let diagnostics = SnapshotEnvironment::new(snapshot) + .evaluate_contract(&ToolchainContract::new(1, vec![path("main.dcdl")], 1)) + .unwrap_err(); + assert_eq!(diagnostics[0].path, path("main.dcdl")); + assert!(!diagnostics[0].message.is_empty()); } #[test] @@ -1333,6 +1884,107 @@ mod tests { assert_ne!(empty.fingerprint, configured.fingerprint); } + #[test] + fn snapshot_import_cache_key_binds_virtual_path_and_content_digest() { + let first = text_entry("skills/debug-rust/SKILL.md", "first"); + let second = text_entry("skills/debug-rust/SKILL.md", "second"); + let first_key = snapshot_import_cache_key(&first); + assert!(first_key.starts_with("skills/debug-rust/SKILL.md@sha256:")); + assert!(first_key.ends_with(&first.content_digest)); + assert_ne!(first_key, snapshot_import_cache_key(&second)); + } + + #[test] + fn markdown_import_projects_frontmatter_and_content_as_a_value() { + let markdown = concat!( + "---\n", + "name: debug-rust\n", + "description: Debug Rust failures\n", + "custom-authority: no\n", + "allowed-tools: Read Grep\n", + "metadata:\n owner: platform\n", + "---\n", + "# Debug Rust\n", + ); + let snapshot = ConfigTreeSnapshot::from_entries( + 3, + [ + entry( + "main.dcdl", + r#"{ skill = import "./skills/debug-rust/SKILL.md" as { frontmatter = { name = String; description = String; ...Unknown }; content = String; }; }"#, + ), + text_entry("skills/debug-rust/SKILL.md", markdown), + ], + ) + .unwrap(); + let result = SnapshotEnvironment::new(snapshot.clone()) + .evaluate_contract(&ToolchainContract::new(1, vec![path("main.dcdl")], 1)) + .unwrap(); + let skill = &result.projections[0].data_json["skill"]; + assert_eq!(skill["frontmatter"]["name"], "debug-rust"); + assert_eq!(skill["frontmatter"]["custom-authority"], "no"); + assert_eq!(skill["frontmatter"]["allowed-tools"], "Read Grep"); + assert_eq!(skill["frontmatter"]["metadata"]["owner"], "platform"); + assert_eq!(skill["content"], "# Debug Rust\n"); + assert_eq!( + snapshot.entries[&path("skills/debug-rust/SKILL.md")].content_digest, + digest_bytes(markdown.as_bytes()) + ); + } + + #[test] + fn markdown_import_without_frontmatter_preserves_the_whole_body() { + let source = "# Plain skill\nKeep --- inside the body.\n"; + assert_eq!( + project_markdown_document(source).unwrap(), + MarkdownDocumentProjection { + frontmatter: serde_json::Map::new(), + content: source.to_string(), + } + ); + let snapshot = ConfigTreeSnapshot::from_entries( + 1, + [ + entry("main.dcdl", r#"import "./skills/plain/SKILL.md""#), + text_entry("skills/plain/SKILL.md", source), + ], + ) + .unwrap(); + let result = SnapshotEnvironment::new(snapshot) + .evaluate_contract(&ToolchainContract::new(1, vec![path("main.dcdl")], 1)) + .unwrap(); + assert_eq!( + result.projections[0].data_json["frontmatter"], + serde_json::json!({}) + ); + assert_eq!(result.projections[0].data_json["content"], source); + } + + #[test] + fn malformed_markdown_frontmatter_is_an_import_diagnostic() { + assert_eq!( + project_markdown_document("---\nname: missing-close\nbody\n").unwrap_err(), + "opening YAML frontmatter delimiter has no closing delimiter" + ); + let snapshot = ConfigTreeSnapshot::from_entries( + 1, + [ + entry("main.dcdl", r#"import "./skills/broken/SKILL.md""#), + text_entry( + "skills/broken/SKILL.md", + "---\nname: [unterminated\n---\nbody\n", + ), + ], + ) + .unwrap(); + let diagnostics = SnapshotEnvironment::new(snapshot) + .evaluate_contract(&ToolchainContract::new(1, vec![path("main.dcdl")], 1)) + .unwrap_err(); + assert_eq!(diagnostics[0].kind, "import"); + assert!(diagnostics[0].message.contains("invalid YAML frontmatter")); + assert!(diagnostics[0].message.contains("skills/broken/SKILL.md")); + } + #[test] fn host_environment_evaluation_uses_only_snapshot_imports() { let snapshot = ConfigTreeSnapshot::from_entries( diff --git a/crates/manifest/src/config.rs b/crates/manifest/src/config.rs index a1e14bb5..8be096c7 100644 --- a/crates/manifest/src/config.rs +++ b/crates/manifest/src/config.rs @@ -349,11 +349,6 @@ impl From for FeatureConfigPartial { pub struct WorkerMetaConfig { #[serde(default)] pub name: Option, - /// Optional `PromptCatalog` manifest pack override. See - /// [`crate::WorkerMeta::prompt_pack`] for semantics. Relative paths - /// are resolved through [`WorkerManifestConfig::resolve_paths`]. - #[serde(default)] - pub prompt_pack: Option, } #[derive(Debug, Clone, Default, Serialize, Deserialize)] @@ -554,9 +549,6 @@ impl WorkerManifestConfig { base.display() ); resolve_auth_file(&mut self.model.auth, base); - if let Some(ref mut pack) = self.worker.prompt_pack { - *pack = join_if_relative(base, pack); - } for rule in &mut self.scope.allow { rule.target = join_if_relative(base, &rule.target); } @@ -718,7 +710,6 @@ impl WorkerMetaConfig { fn merge(self, upper: Self) -> Self { Self { name: upper.name.or(self.name), - prompt_pack: upper.prompt_pack.or(self.prompt_pack), } } } @@ -1018,10 +1009,6 @@ impl TryFrom for WorkerManifest { .worker .name .ok_or(ResolveError::MissingField("worker.name"))?; - let prompt_pack = cfg.worker.prompt_pack; - if let Some(ref p) = prompt_pack { - ensure_absolute("worker.prompt_pack", p)?; - } validate_model_paths(&cfg.model, "model.auth.file")?; @@ -1150,7 +1137,7 @@ impl TryFrom for WorkerManifest { validate_mcp_config(&cfg.mcp)?; Ok(WorkerManifest { - worker: WorkerMeta { name, prompt_pack }, + worker: WorkerMeta { name }, model: cfg.model, engine, scope: cfg.scope, @@ -1187,7 +1174,6 @@ mod tests { WorkerManifestConfig { worker: WorkerMetaConfig { name: Some("test".into()), - prompt_pack: None, }, model: ModelManifest { scheme: Some(SchemeKind::Anthropic), @@ -1505,7 +1491,6 @@ mod tests { let lower = WorkerManifestConfig { worker: WorkerMetaConfig { name: Some("lower".into()), - prompt_pack: None, }, model: ModelManifest { model_id: Some("lower-model".into()), @@ -1516,7 +1501,6 @@ mod tests { let upper = WorkerManifestConfig { worker: WorkerMetaConfig { name: Some("upper".into()), - prompt_pack: None, }, ..Default::default() }; @@ -1925,7 +1909,6 @@ enabled = false .merge(WorkerManifestConfig { worker: WorkerMetaConfig { name: Some("feature-test".into()), - prompt_pack: None, }, model: ModelManifest { scheme: Some(SchemeKind::Anthropic), @@ -2008,7 +1991,6 @@ enabled = true .merge(WorkerManifestConfig { worker: WorkerMetaConfig { name: Some("feature-merge-test".into()), - prompt_pack: None, }, model: ModelManifest { scheme: Some(SchemeKind::Anthropic), @@ -2075,7 +2057,6 @@ permission = "write" let overlay = WorkerManifestConfig { worker: WorkerMetaConfig { name: Some("x".into()), - prompt_pack: None, }, model: ModelManifest { scheme: Some(SchemeKind::Anthropic), diff --git a/crates/manifest/src/defaults.rs b/crates/manifest/src/defaults.rs index eb703dbc..c725f1cd 100644 --- a/crates/manifest/src/defaults.rs +++ b/crates/manifest/src/defaults.rs @@ -42,10 +42,9 @@ pub const COMPACT_OVERVIEW_WARNING_TOKENS: u64 = 16_000; /// See [`crate::CompactionConfig::overview_deadline_tokens`]. pub const COMPACT_OVERVIEW_DEADLINE_TOKENS: u64 = 40_000; -/// Default instruction asset reference used when `worker.instruction` -/// is omitted. See the `PromptLoader` prefix addressing scheme for the -/// `$yoi/` / `$user/` / `$workspace/` namespaces. -pub const DEFAULT_INSTRUCTION: &str = "$yoi/default"; +/// Default exact catalog-root dotted Prompt name used when +/// `worker.instruction` is omitted. +pub const DEFAULT_INSTRUCTION: &str = "default"; /// Default language policy used by the main worker for normal prose /// responses. See [`crate::EngineManifest::language`]. diff --git a/crates/manifest/src/lib.rs b/crates/manifest/src/lib.rs index 5d33a09c..1f6d1f74 100644 --- a/crates/manifest/src/lib.rs +++ b/crates/manifest/src/lib.rs @@ -19,8 +19,8 @@ pub use paths::user_profiles_path; pub use profile::{ ProfileDiscovery, ProfileError, ProfileManifestSnapshot, ProfileMetadata, ProfileRegistry, ProfileRegistryEntry, ProfileRegistrySource, ProfileResolveOptions, ProfileResolver, - ProfileSelector, ProfileSource, ResolvedProfile, WorkspaceOverrideSnapshot, - resolve_profile_artifact, resolve_profile_artifact_value, + ProfileSelector, ProfileSource, ResolvedProfile, resolve_profile_artifact, + resolve_profile_artifact_value, }; pub use protocol::{Permission, ScopeRule}; pub use scope::{DelegationScope, Scope, ScopeError, SharedScope}; @@ -500,29 +500,13 @@ pub struct MemoryConfig { #[derive(Debug, Clone, Serialize, Deserialize)] pub struct WorkerMeta { pub name: String, - /// Optional path to a TOML override file read as the top layer of - /// `worker::PromptCatalog`. Subject to the same relative-path - /// resolution as other manifest paths (joined against the - /// manifest's base directory). `None` leaves the 4th overlay layer - /// empty; auto-discovered user and workspace packs still apply. - /// - /// Note: unlike `worker.instruction`, this is a plain filesystem - /// path — not a `$prefix/` prompt reference. Pack files carry - /// structured TOML data, while `worker.instruction` points at a - /// minijinja `.md` template; the two use different addressing - /// conventions on purpose. - #[serde(default)] - pub prompt_pack: Option, } /// Worker-level configuration embedded in the manifest. #[derive(Debug, Clone, Serialize, Deserialize)] pub struct EngineManifest { - /// Reference to the instruction prompt asset used as the body of - /// the worker's system prompt. Uses the `PromptLoader` prefix - /// addressing scheme (`$yoi/...`, `$user/...`, - /// `$workspace/...`) and is always populated after resolution — - /// unset manifests fall through to [`defaults::DEFAULT_INSTRUCTION`]. + /// Exact catalog-root dotted Prompt name (for example `default` or + /// `role.coder`). #[serde(default = "default_instruction")] pub instruction: String, /// Language policy used by the main worker for normal prose responses. @@ -959,7 +943,7 @@ model_id = "claude-sonnet-4-20250514" auth = { kind = "api_key", file = "/abs/keys/anthropic" } [engine] -instruction = "$user/reviewer" +instruction = "role.reviewer" max_tokens = 4096 temperature = 0.3 top_p = 0.9 @@ -995,7 +979,7 @@ permission = "write" _ => panic!("expected ApiKey"), }; assert_eq!(file, Some(std::path::Path::new("/abs/keys/anthropic"))); - assert_eq!(manifest.engine.instruction, "$user/reviewer"); + assert_eq!(manifest.engine.instruction, "role.reviewer"); assert_eq!(manifest.engine.max_tokens, Some(4096)); assert_eq!(manifest.engine.temperature, Some(0.3)); assert_eq!(manifest.engine.top_p, Some(0.9)); diff --git a/crates/manifest/src/paths.rs b/crates/manifest/src/paths.rs index a8b0cf0b..61982078 100644 --- a/crates/manifest/src/paths.rs +++ b/crates/manifest/src/paths.rs @@ -3,7 +3,7 @@ //! 用途別に三つの base directory を持つ: //! //! - **`config_dir`** — 人が手で書く / 編集する設定。`profiles.toml`, -//! `providers.toml`, `models.toml`, `prompts/`, `prompts.toml` 等 +//! `providers.toml`, `models.toml` 等 //! - **`data_dir`** — プログラムが書く永続データ。`sessions/` 等 //! - **`secret_data_dir`** — local secret store の読み書き base。既存 //! secret store は path-derived key を使うため、通常 data とは別に @@ -85,16 +85,6 @@ pub fn user_profiles_path() -> Option { user_profiles_path_from_config_dir(config_dir()) } -/// `/prompts/` — user prompts ライブラリ。 -pub fn user_prompts_dir() -> Option { - user_prompts_dir_from_config_dir(config_dir()) -} - -/// `/prompts.toml` — user prompt pack。 -pub fn user_pack_file() -> Option { - user_pack_file_from_config_dir(config_dir()) -} - /// `/` — providers.toml / models.toml 等の /// user override ファイル。 pub fn user_catalog_override(file_name: &str) -> Option { @@ -200,14 +190,6 @@ fn user_profiles_path_from_config_dir(config_dir: Option) -> Option) -> Option { - Some(config_dir?.join("prompts")) -} - -fn user_pack_file_from_config_dir(config_dir: Option) -> Option { - Some(config_dir?.join("prompts.toml")) -} - fn user_catalog_override_from_config_dir( config_dir: Option, file_name: &str, @@ -465,14 +447,6 @@ mod tests { user_profiles_path_from_config_dir(config_dir.clone()).unwrap(), PathBuf::from("/sand/config/profiles.toml") ); - assert_eq!( - user_prompts_dir_from_config_dir(config_dir.clone()).unwrap(), - PathBuf::from("/sand/config/prompts") - ); - assert_eq!( - user_pack_file_from_config_dir(config_dir.clone()).unwrap(), - PathBuf::from("/sand/config/prompts.toml") - ); assert_eq!( user_catalog_override_from_config_dir(config_dir, "providers.toml").unwrap(), PathBuf::from("/sand/config/providers.toml") diff --git a/crates/manifest/src/profile.rs b/crates/manifest/src/profile.rs index f330a3bd..65b8d99c 100644 --- a/crates/manifest/src/profile.rs +++ b/crates/manifest/src/profile.rs @@ -22,7 +22,6 @@ use crate::{ const PROFILE_FORMAT_V1: &str = "yoi.profile.v1"; const BUILTIN_MODEL_CATALOG: &str = include_str!("../../../resources/models/builtin.toml"); -const WORKSPACE_OVERRIDE_LOCAL_FILENAME: &str = "override.local.toml"; struct BuiltinProfile { name: &'static str, @@ -322,10 +321,10 @@ pub struct ProfileDiscovery { } impl ProfileDiscovery { - pub fn for_cwd(cwd: &Path) -> Self { + pub fn for_cwd(_cwd: &Path) -> Self { Self { user_config: paths::user_profiles_path(), - project_config: find_project_profiles_from(cwd), + project_config: None, } } pub fn with_sources(user_config: Option, project_config: Option) -> Self { @@ -363,19 +362,6 @@ pub struct ProfileManifestSnapshot { pub source: ProfileSource, #[serde(default, skip_serializing_if = "Option::is_none")] pub profile: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub workspace_override: Option, -} - -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -pub struct WorkspaceOverrideSnapshot { - pub path: PathBuf, -} - -#[derive(Debug)] -struct WorkspaceOverrideLayer { - path: PathBuf, - config: WorkerManifestConfig, } #[derive(Debug, Clone)] @@ -495,7 +481,6 @@ impl ProfileResolver { .as_deref() .unwrap_or_else(|| Path::new(".")), )?; - let workspace_override = load_workspace_override_from(&workspace_base)?; let raw_artifact = read_profile_artifact_file(&absolute_path)?; resolve_profile_value( source, @@ -504,7 +489,6 @@ impl ProfileResolver { options, raw_artifact.clone(), raw_artifact, - workspace_override, ) } @@ -519,7 +503,6 @@ impl ProfileResolver { .as_deref() .unwrap_or_else(|| Path::new(".")), )?; - let workspace_override = load_workspace_override_from(&workspace_base)?; let raw_artifact = builtin_profile_artifact(label).ok_or_else(|| { ProfileError::InvalidProfile(format!("unknown builtin profile artifact `{label}`")) })?; @@ -530,7 +513,6 @@ impl ProfileResolver { options, raw_artifact.clone(), raw_artifact, - workspace_override, ) } } @@ -542,7 +524,6 @@ fn resolve_profile_value( options: ProfileResolveOptions, value: serde_json::Value, raw_artifact: serde_json::Value, - workspace_override: Option, ) -> Result { if !workspace_base.is_absolute() { return Err(ProfileError::InvalidPath { @@ -566,7 +547,6 @@ fn resolve_profile_value( let config = WorkerManifestConfig { worker: WorkerMetaConfig { name: Some(worker_name), - prompt_pack: None, }, model: profile.model.unwrap_or_default(), engine: profile.engine.unwrap_or_default(), @@ -585,29 +565,11 @@ fn resolve_profile_value( memory: profile.memory, skills: profile.skills, }; - let mut config = - WorkerManifestConfig::builtin_defaults().merge(config.resolve_paths(profile_dir)); - let workspace_override_snapshot = if let Some(override_layer) = workspace_override { - let override_base = - override_layer - .path - .parent() - .ok_or_else(|| ProfileError::InvalidPath { - path: override_layer.path.clone(), - message: "workspace override path has no parent directory".into(), - })?; - config = config.merge(override_layer.config.resolve_paths(override_base)); - Some(WorkspaceOverrideSnapshot { - path: override_layer.path, - }) - } else { - None - }; + let config = WorkerManifestConfig::builtin_defaults().merge(config.resolve_paths(profile_dir)); let mut manifest = WorkerManifest::try_from(config).map_err(ProfileError::ManifestResolve)?; manifest.profile = Some(ProfileManifestSnapshot { source: source.clone(), profile: profile_meta.clone(), - workspace_override: workspace_override_snapshot, }); let manifest_snapshot = serde_json::to_value(&manifest).map_err(ProfileError::SnapshotSerialize)?; @@ -751,70 +713,6 @@ fn load_profile_registry_file( Ok(()) } -fn load_workspace_override_from( - workspace_base: &Path, -) -> Result, ProfileError> { - find_workspace_override_from(workspace_base) - .map(|path| load_workspace_override_file(&path)) - .transpose() -} - -fn load_workspace_override_file(path: &Path) -> Result { - let content = - std::fs::read_to_string(path).map_err(|source| ProfileError::WorkspaceOverrideRead { - path: path.to_path_buf(), - source, - })?; - let config = WorkerManifestConfig::from_toml(&content).map_err(|source| { - ProfileError::WorkspaceOverrideParse { - path: path.to_path_buf(), - source, - } - })?; - if config.worker.name.is_some() { - return Err(ProfileError::InvalidWorkspaceOverride { - path: path.to_path_buf(), - message: "workspace-local manifest overrides cannot set worker.name; Worker identity is a runtime input".into(), - }); - } - Ok(WorkspaceOverrideLayer { - path: path.to_path_buf(), - config, - }) -} - -fn find_workspace_override_from(start: &Path) -> Option { - let start = start - .canonicalize() - .ok() - .unwrap_or_else(|| start.to_path_buf()); - let mut cur: Option<&Path> = Some(start.as_path()); - while let Some(dir) = cur { - let candidate = dir.join(".yoi").join(WORKSPACE_OVERRIDE_LOCAL_FILENAME); - if candidate.is_file() { - return Some(candidate); - } - cur = dir.parent(); - } - None -} - -fn find_project_profiles_from(start: &Path) -> Option { - let start = start - .canonicalize() - .ok() - .unwrap_or_else(|| start.to_path_buf()); - let mut cur: Option<&Path> = Some(start.as_path()); - while let Some(dir) = cur { - let candidate = dir.join(".yoi").join("profiles.toml"); - if candidate.is_file() { - return Some(candidate); - } - cur = dir.parent(); - } - None -} - fn add_builtin_profiles(registry: &mut ProfileRegistry) { for profile in BUILTIN_PROFILES { registry.push_entry(ProfileRegistryEntry::embedded( @@ -1285,7 +1183,6 @@ pub fn resolve_profile_artifact_value( ProfileResolveOptions::with_worker_name(worker_name), raw_artifact.clone(), raw_artifact, - None, ) } @@ -1313,20 +1210,6 @@ pub enum ProfileError { #[source] source: toml::de::Error, }, - #[error("failed to read workspace local manifest override {}: {source}", .path.display())] - WorkspaceOverrideRead { - path: PathBuf, - #[source] - source: std::io::Error, - }, - #[error("failed to parse workspace local manifest override {}: {source}", .path.display())] - WorkspaceOverrideParse { - path: PathBuf, - #[source] - source: toml::de::Error, - }, - #[error("invalid workspace local manifest override {}: {message}", .path.display())] - InvalidWorkspaceOverride { path: PathBuf, message: String }, #[error("no default profile is configured")] NoDefaultProfile, #[error("profile resolution requires an explicit runtime Worker name")] @@ -1574,27 +1457,15 @@ mod tests { assert!(reviewer.compaction.is_some()); } - #[test] - fn orchestrator_role_keeps_review_routing_owned_by_coder() { - let prompt = include_str!("../../../resources/prompts/role/orchestrator.md"); - - assert!(prompt.contains("assigned Coder owns its review/fix loop")); - assert!(prompt.contains("then use `SpawnTicketCoder`")); - assert!(prompt.contains("verify its current assignment names that Coder")); - assert!(prompt.contains("never route implementation to an unassigned Coder")); - assert!(prompt.contains( - "Do not spawn, restore, assign, or route work to Backend/Runtime Reviewer Workers" - )); - assert!(prompt.contains("never compensate by creating an independent Reviewer Worker")); - assert!(!prompt.contains("sibling Coder/Reviewer Workers")); - } - #[test] fn profile_resolution_requires_runtime_worker_name() { let tmp = TempDir::new().unwrap(); let err = ProfileResolver::new() .with_workspace_base(tmp.path()) - .resolve(&ProfileSelector::Default, ProfileResolveOptions::default()) + .resolve( + &ProfileSelector::source_named(ProfileRegistrySource::Builtin, "companion"), + ProfileResolveOptions::default(), + ) .unwrap_err(); assert!(matches!(err, ProfileError::MissingRuntimeWorkerName)); } @@ -1857,134 +1728,6 @@ worker_context_max_tokens = 68000 } ); } - #[test] - fn workspace_local_override_layers_over_profile_defaults() { - let tmp = TempDir::new().unwrap(); - let workspace = tmp.path().join("project"); - let nested = workspace.join("nested"); - let yoi_dir = workspace.join(".yoi"); - std::fs::create_dir_all(&nested).unwrap(); - std::fs::create_dir_all(&yoi_dir).unwrap(); - let override_path = yoi_dir.join(WORKSPACE_OVERRIDE_LOCAL_FILENAME); - std::fs::write( - &override_path, - r#" -[worker] -prompt_pack = "prompts.toml" -[engine] -language = "ja" -[session] -record_event_trace = false -"#, - ) - .unwrap(); - - let resolved = ProfileResolver::new() - .with_workspace_base(&nested) - .resolve( - &ProfileSelector::Default, - ProfileResolveOptions::with_worker_name("runtime-worker"), - ) - .unwrap(); - - assert_eq!(resolved.manifest.worker.name, "runtime-worker"); - assert_eq!(resolved.manifest.engine.language, "ja"); - assert!(!resolved.manifest.session.record_event_trace); - assert_eq!( - resolved.manifest.worker.prompt_pack.as_deref(), - Some(yoi_dir.join("prompts.toml").as_path()) - ); - assert!(resolved.manifest.scope.allow.is_empty()); - assert_eq!( - resolved - .manifest - .profile - .as_ref() - .and_then(|snapshot| snapshot.workspace_override.as_ref()) - .map(|snapshot| snapshot.path.as_path()), - Some(override_path.as_path()) - ); - } - - #[test] - fn workspace_local_override_uses_nearest_ancestor() { - let tmp = TempDir::new().unwrap(); - let workspace = tmp.path().join("project"); - let nested = workspace.join("nested"); - let child = nested.join("child"); - let parent_yoi = workspace.join(".yoi"); - let nested_yoi = nested.join(".yoi"); - std::fs::create_dir_all(&child).unwrap(); - std::fs::create_dir_all(&parent_yoi).unwrap(); - std::fs::create_dir_all(&nested_yoi).unwrap(); - std::fs::write( - parent_yoi.join(WORKSPACE_OVERRIDE_LOCAL_FILENAME), - r#" -[worker] -prompt_pack = "parent-prompts.toml" -[engine] -language = "parent" -"#, - ) - .unwrap(); - let nested_override_path = nested_yoi.join(WORKSPACE_OVERRIDE_LOCAL_FILENAME); - std::fs::write( - &nested_override_path, - r#" -[worker] -prompt_pack = "nested-prompts.toml" -[engine] -language = "nested" -"#, - ) - .unwrap(); - - let resolved = ProfileResolver::new() - .with_workspace_base(&child) - .resolve( - &ProfileSelector::Default, - ProfileResolveOptions::with_worker_name("runtime-worker"), - ) - .unwrap(); - - assert_eq!(resolved.manifest.engine.language, "nested"); - assert_eq!( - resolved.manifest.worker.prompt_pack.as_deref(), - Some(nested_yoi.join("nested-prompts.toml").as_path()) - ); - assert_eq!( - resolved - .manifest - .profile - .as_ref() - .and_then(|snapshot| snapshot.workspace_override.as_ref()) - .map(|snapshot| snapshot.path.as_path()), - Some(nested_override_path.as_path()) - ); - } - - #[test] - fn workspace_local_override_rejects_runtime_worker_name() { - let tmp = TempDir::new().unwrap(); - let yoi_dir = tmp.path().join(".yoi"); - std::fs::create_dir_all(&yoi_dir).unwrap(); - std::fs::write( - yoi_dir.join(WORKSPACE_OVERRIDE_LOCAL_FILENAME), - "[worker]\nname = \"not-local\"\n", - ) - .unwrap(); - - let err = ProfileResolver::new() - .with_workspace_base(tmp.path()) - .resolve( - &ProfileSelector::Default, - ProfileResolveOptions::with_worker_name("runtime-worker"), - ) - .unwrap_err(); - assert!(matches!(err, ProfileError::InvalidWorkspaceOverride { .. })); - assert!(err.to_string().contains("worker.name")); - } - #[test] fn unsupported_profile_extension_has_clear_diagnostic() { let tmp = TempDir::new().unwrap(); @@ -2003,7 +1746,7 @@ language = "nested" ); } #[test] - fn discovery_reads_user_and_project_registry_and_project_default_wins() { + fn explicit_discovery_sources_preserve_non_workspace_tooling_contract() { let tmp = TempDir::new().unwrap(); let user_config = tmp.path().join("profiles.toml"); let project_dir = tmp.path().join("project/.yoi"); diff --git a/crates/ticket/src/config.rs b/crates/ticket/src/config.rs index 062cce59..3d16b9bb 100644 --- a/crates/ticket/src/config.rs +++ b/crates/ticket/src/config.rs @@ -1047,19 +1047,19 @@ worktree_name = "custom-orchestrator" [ticket.roles.intake] profile = "project:intake" -launch_prompt = "$workspace/ticket/intake/launch" +launch_prompt = "ticket.intake.launch" [ticket.roles.orchestrator] profile = "project:orchestrator" -launch_prompt = "$workspace/ticket/orchestrator/launch" +launch_prompt = "ticket.orchestrator.launch" [ticket.roles.coder] profile = "inherit" -launch_prompt = "$workspace/ticket/coder/launch" +launch_prompt = "ticket.coder.launch" [ticket.roles.reviewer] profile = "project:reviewer" -launch_prompt = "$workspace/ticket/reviewer/launch" +launch_prompt = "ticket.reviewer.launch" "#, ); @@ -1095,7 +1095,7 @@ launch_prompt = "$workspace/ticket/reviewer/launch" .launch_prompt_for(TicketRole::Reviewer) .unwrap() .as_str(), - "$workspace/ticket/reviewer/launch" + "ticket.reviewer.launch" ); } @@ -1338,7 +1338,7 @@ profile = "builtin:companion" r#" [roles.coder] profile = "inherit" -system_instruction = "$workspace/not-supported" +system_instruction = "unsupported" "#, ); diff --git a/crates/tui/Cargo.toml b/crates/tui/Cargo.toml index bfd05343..a66cf8e8 100644 --- a/crates/tui/Cargo.toml +++ b/crates/tui/Cargo.toml @@ -25,7 +25,7 @@ session-store = { workspace = true } fs4 = { workspace = true } ticket = { workspace = true } serde = { workspace = true, features = ["derive"] } -minijinja = "2.19.0" +worker = { path = "../worker" } pulldown-cmark = { version = "0.13.3", default-features = false } llm-engine.workspace = true diff --git a/crates/tui/src/dashboard/mod.rs b/crates/tui/src/dashboard/mod.rs index c6942f9e..17c76502 100644 --- a/crates/tui/src/dashboard/mod.rs +++ b/crates/tui/src/dashboard/mod.rs @@ -69,8 +69,7 @@ use render::{PanelListRow, row_hit_boxes}; const MAX_ENTRIES: usize = 50; const CLOSED_VISIBLE_ROWS: usize = 3; -const ORCHESTRATOR_IDLE_QUEUE_NOTICE_TEMPLATE: &str = - include_str!("../../../../resources/prompts/panel/orchestrator_idle_queue_notice.md"); +const ORCHESTRATOR_IDLE_QUEUE_NOTICE_PROMPT: &str = "panel.orchestrator_idle_queue_notice"; const ORCHESTRATOR_QUEUE_ATTENTION_MAX_TICKETS: usize = 6; const ORCHESTRATOR_QUEUE_ATTENTION_MAX_TEXT_CHARS: usize = 120; const ORCHESTRATOR_QUEUE_ATTENTION_MAX_MESSAGE_CHARS: usize = 2_400; @@ -3791,15 +3790,9 @@ fn orchestrator_queue_template_ticket( fn render_orchestrator_queue_attention_template( context: &OrchestratorQueueTemplateContext, -) -> Result { - let mut env = minijinja::Environment::new(); - env.set_undefined_behavior(minijinja::UndefinedBehavior::Strict); - env.add_template( - "orchestrator_idle_queue_notice", - ORCHESTRATOR_IDLE_QUEUE_NOTICE_TEMPLATE, - )?; - env.get_template("orchestrator_idle_queue_notice")? - .render(context) +) -> Result { + worker::PromptCatalog::builtins_only()? + .render_serializable(ORCHESTRATOR_IDLE_QUEUE_NOTICE_PROMPT, context) } fn orchestrator_work_set_detail( diff --git a/crates/tui/src/spawn.rs b/crates/tui/src/spawn.rs index 91e34a1b..8f925c26 100644 --- a/crates/tui/src/spawn.rs +++ b/crates/tui/src/spawn.rs @@ -1,7 +1,7 @@ //! Inline-viewport "spawn Worker and attach" UX. //! //! Rendered at the user's current cursor position when `yoi` is invoked -//! with no positional argument. Discovers `.yoi/profiles.toml` profile +//! with no positional argument. Uses user-configured and bundled Profile //! choices plus bundled profiles, defaults to the builtin profile, prompts for //! the Worker's name, and on confirmation launches the Worker runtime command as an //! independent process. Once the process reports its socket via the @@ -654,68 +654,28 @@ mod tests { } #[test] - fn profile_choices_use_project_registry_default() { + fn profile_choices_ignore_repository_local_profile_registry() { let temp = tempfile::tempdir().unwrap(); let project = temp.path().join("project"); let yoi = project.join(".yoi"); std::fs::create_dir_all(&yoi).unwrap(); std::fs::write( yoi.join("profiles.toml"), - r#" -default = "coder" -[profile] -coder = "profiles/coder.toml" -"#, + "default = \"coder\"\n[profile]\ncoder = \"profiles/coder.toml\"\n", ) .unwrap(); let (choices, default_index) = profile_choices_for_cwd(&project); - let default_choice = choices - .iter() - .position(|choice| choice.selector.as_deref() == Some("project:coder")) - .expect("project default choice is present"); - assert_eq!(default_index, default_choice); - let selected = &choices[default_index]; - assert_eq!(selected.selector.as_deref(), Some("project:coder")); - assert_eq!(selected.label, "project:coder (default)"); - assert!(selected.is_default); - } - - #[test] - fn profile_choices_include_builtin_and_project_default_marker() { - let temp = tempfile::tempdir().unwrap(); - let project = temp.path().join("project"); - let yoi = project.join(".yoi"); - std::fs::create_dir_all(&yoi).unwrap(); - std::fs::write( - yoi.join("profiles.toml"), - r#" -default = "coder" -[profile.coder] -path = "profiles/coder.toml" -description = "Project coder" -"#, - ) - .unwrap(); - - let (choices, default_index) = profile_choices_for_cwd(&project); - assert_eq!(choices[0].selector.as_deref(), Some("builtin:companion")); - assert_eq!( - choices[0].label, - "builtin:companion — Bundled Companion role profile" + assert_eq!(default_index, 0); + assert!( + choices + .iter() + .all(|choice| { choice.selector.as_deref() != Some("project:coder") }) ); - let project_index = choices - .iter() - .position(|choice| choice.selector.as_deref() == Some("project:coder")) - .expect("project default choice is present"); - assert_eq!(default_index, project_index); - assert_eq!( - choices[project_index].selector.as_deref(), - Some("project:coder") - ); - assert_eq!( - choices[project_index].label, - "project:coder (default) — Project coder" + assert!( + choices + .iter() + .any(|choice| { choice.selector.as_deref() == Some("builtin:companion") }) ); } diff --git a/crates/worker-runtime/src/config_bundle.rs b/crates/worker-runtime/src/config_bundle.rs index 12a440be..eeb3b5db 100644 --- a/crates/worker-runtime/src/config_bundle.rs +++ b/crates/worker-runtime/src/config_bundle.rs @@ -24,6 +24,8 @@ pub struct ConfigBundle { #[serde(default, skip_serializing_if = "Vec::is_empty")] pub declarations: Vec, #[serde(default, skip_serializing_if = "Option::is_none")] + pub prompt_catalog: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] pub profile_source_archive: Option, #[serde(default, skip_serializing_if = "Option::is_none")] pub profile_source_archive_handle: Option, @@ -69,6 +71,16 @@ impl ConfigBundle { )); } + if let Some(prompt_catalog) = &self.prompt_catalog { + lines.push(format!( + "prompt_catalog\0{}\0{}\0{}\0{}", + prompt_catalog.config_revision, + prompt_catalog.schema_fingerprint, + prompt_catalog.toolchain_fingerprint, + prompt_catalog.catalog_digest + )); + } + if let Some(archive) = &self.profile_source_archive { lines.push(format!( "profile_archive\0{}\0{}\0{}", @@ -274,6 +286,12 @@ pub(crate) fn validate_config_bundle(bundle: &ConfigBundle) -> Result<(), Runtim validate_declaration_reference(&bundle.metadata.id, declaration)?; } + if let Some(prompt_catalog) = &bundle.prompt_catalog { + prompt_catalog.verify_digest().map_err(|error| { + RuntimeError::InvalidRequest(format!("invalid Prompt catalog projection: {error}")) + })?; + } + if let Some(archive) = &bundle.profile_source_archive { validate_profile_source_archive_ref(&archive.reference).map_err(|err| { RuntimeError::InvalidRequest(format!("invalid profile source archive: {err}")) @@ -582,6 +600,7 @@ mod tests { name: "credential".to_string(), reference: reference.to_string(), }], + prompt_catalog: None, profile_source_archive: None, profile_source_archive_handle: None, } @@ -615,6 +634,32 @@ mod tests { validate_config_bundle(&bundle_with_declaration("vault:team.api-key")).unwrap(); } + #[test] + fn validates_immutable_prompt_catalog_projection() { + let mut bundle = bundle_with_declaration("secret:github-token"); + bundle.prompt_catalog = Some( + worker::EffectivePromptCatalog::new( + std::collections::BTreeMap::from([("default".to_string(), "hello".to_string())]), + 7, + "schema", + "toolchain", + ) + .unwrap(), + ); + bundle = bundle.with_computed_digest(); + validate_config_bundle(&bundle).unwrap(); + + bundle + .prompt_catalog + .as_mut() + .unwrap() + .templates + .insert("default".into(), "tampered".into()); + bundle = bundle.with_computed_digest(); + let error = validate_config_bundle(&bundle).unwrap_err(); + assert!(error.to_string().contains("catalog digest mismatch")); + } + #[test] fn bundle_summary_redacts_runtime_internal_resource_handle() { let mut bundle = bundle_with_declaration("secret:github-token"); diff --git a/crates/worker-runtime/src/fs_store.rs b/crates/worker-runtime/src/fs_store.rs index 42bada38..a3ddcae0 100644 --- a/crates/worker-runtime/src/fs_store.rs +++ b/crates/worker-runtime/src/fs_store.rs @@ -1,5 +1,5 @@ use crate::catalog::{CreateWorkerRequest, WorkingDirectoryStatus}; -use crate::config_bundle::ConfigBundle; +use crate::config_bundle::{ConfigBundle, validate_config_bundle}; use crate::diagnostics::{DiagnosticSeverity, RuntimeDiagnostic}; use crate::error::RuntimeError; use crate::identity::{WorkerId, WorkerRef}; @@ -324,6 +324,13 @@ impl RuntimeSnapshot { message: format!("runtime snapshot backend is {:?}", self.backend), }); } + for bundle in self.config_bundles.values() { + validate_config_bundle(bundle).map_err(|error| RuntimeError::StoreCorrupt { + operation: "read runtime snapshot", + path: path.to_path_buf(), + message: format!("invalid config bundle {}: {error}", bundle.metadata.id), + })?; + } Ok(()) } diff --git a/crates/worker-runtime/src/http_server.rs b/crates/worker-runtime/src/http_server.rs index 6e71276a..3bdfbeff 100644 --- a/crates/worker-runtime/src/http_server.rs +++ b/crates/worker-runtime/src/http_server.rs @@ -1811,12 +1811,21 @@ mod tests { label: Some("test".to_string()), }], declarations: Vec::new(), + prompt_catalog: None, profile_source_archive: None, profile_source_archive_handle: None, } .with_computed_digest() } + fn store_coder_test_bundle(runtime: &Runtime) { + runtime + .store_config_bundle(test_bundle(ProfileSelector::Builtin( + "builtin:coder".to_string(), + ))) + .unwrap(); + } + fn scoped_task_request(objective: &str, workspace_id: &str) -> CreateWorkerRequest { let mut request = task_request(objective); request.workspace_api = Some(WorkspaceApiRef { @@ -1922,6 +1931,7 @@ mod tests { let runtime = Runtime::with_execution_backend(RuntimeOptions::default(), Arc::new(AcceptingBackend)) .unwrap(); + store_coder_test_bundle(&runtime); let (auth, signer) = auth_config_and_signer(); let token_a = token_for_workspace(&signer, "workspace-a"); let token_b = token_for_workspace(&signer, "workspace-b"); @@ -2002,6 +2012,7 @@ mod tests { let runtime = Runtime::with_execution_backend(RuntimeOptions::default(), Arc::new(AcceptingBackend)) .unwrap(); + store_coder_test_bundle(&runtime); let (auth, signer_a, signer_b) = auth_config_and_two_signers(); let token_a = token_for_workspace(&signer_a, "workspace-a"); let token_b = token_for_workspace(&signer_b, "workspace-a"); @@ -2648,6 +2659,7 @@ mod ws_tests { label: Some("ws".to_string()), }], declarations: Vec::new(), + prompt_catalog: None, profile_source_archive: None, profile_source_archive_handle: None, } diff --git a/crates/worker-runtime/src/profile_archive.rs b/crates/worker-runtime/src/profile_archive.rs index b8362347..6c056b87 100644 --- a/crates/worker-runtime/src/profile_archive.rs +++ b/crates/worker-runtime/src/profile_archive.rs @@ -743,10 +743,10 @@ mod tests { .load(Some("profiles/main.dcdl"), "./shared.dcdl") .unwrap(); match loaded { - LoadedImport::Source(source) => { - assert_eq!(source.key, "profiles/shared.dcdl"); + LoadedImport::Source { key, .. } => { + assert_eq!(key, "profiles/shared.dcdl"); } - LoadedImport::Value(_) => panic!("expected source import"), + LoadedImport::Value { .. } => panic!("expected source import"), } } diff --git a/crates/worker-runtime/src/runtime.rs b/crates/worker-runtime/src/runtime.rs index 653a756b..360280de 100644 --- a/crates/worker-runtime/src/runtime.rs +++ b/crates/worker-runtime/src/runtime.rs @@ -261,6 +261,19 @@ impl Runtime { digest: bundle.metadata.digest.clone(), }; let summary = bundle.summary(); + if let Some(existing) = state.config_bundles.get(&bundle.metadata.id) { + if existing.metadata.digest != bundle.metadata.digest { + return Err(RuntimeError::ConfigBundleDigestMismatch { + bundle_id: bundle.metadata.id.clone(), + expected_digest: existing.metadata.digest.clone(), + actual_digest: bundle.metadata.digest.clone(), + }); + } + return Ok(ConfigBundleAvailability { + reference, + summary: existing.summary(), + }); + } state .config_bundles .insert(bundle.metadata.id.clone(), bundle); @@ -526,6 +539,7 @@ impl Runtime { message: "worker creation requires an execution backend".to_string(), } })?; + let config_bundle = state.resolve_config_bundle_ref(request.config_bundle.as_ref())?; let worker_id = WorkerId::generated(state.next_worker_sequence); state.next_worker_sequence += 1; @@ -551,7 +565,7 @@ impl Runtime { workspace_scope: scope.cloned(), context: self.execution_context(worker_ref.clone()), working_directory: None, - config_bundle: None, + config_bundle, }; (backend, worker_ref, spawn_request) }; @@ -868,7 +882,7 @@ impl Runtime { let (backend, request) = { let mut state = self.lock()?; state.ensure_running()?; - let (worker_request, previous_working_directory, config_bundle, run_generation) = { + let (worker_request, previous_working_directory, run_generation) = { let worker = state.worker(worker_ref)?; if worker.execution_handle.is_some() { return Ok(worker.detail()); @@ -879,19 +893,14 @@ impl Runtime { worker_ref.worker_id ))); } - let config_bundle = worker - .request - .config_bundle - .as_ref() - .and_then(|bundle_ref| state.config_bundles.get(&bundle_ref.id)) - .cloned(); ( worker.request.clone(), worker.working_directory.clone(), - config_bundle, worker.run_generation.saturating_add(1).max(1), ) }; + let config_bundle = + state.resolve_config_bundle_ref(worker_request.config_bundle.as_ref())?; let backend = state.execution_backend.clone().ok_or_else(|| { RuntimeError::WorkerExecutionUnavailable { worker_id: worker_ref.worker_id.clone(), @@ -1558,31 +1567,20 @@ impl Runtime { .collect::>(); let mut candidates = Vec::with_capacity(worker_ids.len()); for worker_id in worker_ids { - let ( - worker_ref, - request, - previous_working_directory, - config_bundle, - run_generation, - ) = { + let (worker_ref, request, previous_working_directory, run_generation) = { let worker = state .workers .get(&worker_id) .expect("collected Worker exists"); - let config_bundle = worker - .request - .config_bundle - .as_ref() - .and_then(|bundle_ref| state.config_bundles.get(&bundle_ref.id)) - .cloned(); ( worker.worker_ref.clone(), worker.request.clone(), worker.working_directory.clone(), - config_bundle, worker.run_generation.saturating_add(1).max(1), ) }; + let config_bundle = + state.resolve_config_bundle_ref(request.config_bundle.as_ref())?; state .workers .get_mut(&worker_id) @@ -2075,6 +2073,17 @@ impl RuntimeState { }) } + fn resolve_config_bundle_ref( + &self, + reference: Option<&ConfigBundleRef>, + ) -> Result, RuntimeError> { + let Some(reference) = reference else { + return Ok(None); + }; + self.check_config_bundle_ref(reference)?; + Ok(self.config_bundles.get(&reference.id).cloned()) + } + fn validate_worker_config_boundary( &self, _request: &CreateWorkerRequest, @@ -2831,6 +2840,7 @@ mod tests { name: "read".to_string(), reference: "capability:read".to_string(), }], + prompt_catalog: None, profile_source_archive: None, profile_source_archive_handle: None, } @@ -2843,6 +2853,7 @@ mod tests { restore_result: Mutex>, restore_count: Mutex, run_generations: Mutex>, + config_bundles: Mutex>>, contexts: Mutex>, dispatched_inputs: Mutex>, preserve_commit_ack_submission_id: AtomicBool, @@ -2890,6 +2901,10 @@ mod tests { .lock() .unwrap() .push(request.run_generation); + self.config_bundles + .lock() + .unwrap() + .push(request.config_bundle.clone()); self.contexts .lock() .unwrap() @@ -2913,6 +2928,10 @@ mod tests { .lock() .unwrap() .push(request.run_generation); + self.config_bundles + .lock() + .unwrap() + .push(request.config_bundle.clone()); if let Some(result) = self.restore_result.lock().unwrap().clone() { return result; } @@ -3409,11 +3428,30 @@ mod tests { #[test] fn synced_config_bundle_is_stored_checked_and_used_for_worker_creation() { - let runtime = runtime_with_backend(); - let bundle = test_bundle(); + let backend = Arc::new(TestExecutionBackend::default()); + let runtime = + Runtime::with_execution_backend(RuntimeOptions::default(), backend.clone()).unwrap(); + let mut bundle = test_bundle(); + bundle.prompt_catalog = Some( + worker::EffectivePromptCatalog::new( + BTreeMap::from([("default".to_string(), "workspace prompt".to_string())]), + 7, + "schema", + "toolchain", + ) + .unwrap(), + ); + bundle = bundle.with_computed_digest(); let availability = runtime.store_config_bundle(bundle.clone()).unwrap(); assert_eq!(availability.reference.id, "bundle-1"); assert_eq!(availability.reference.digest, bundle.metadata.digest); + let mut conflicting_bundle = bundle.clone(); + conflicting_bundle.profiles[0].label = Some("conflicting".to_string()); + conflicting_bundle = conflicting_bundle.with_computed_digest(); + assert!(matches!( + runtime.store_config_bundle(conflicting_bundle), + Err(RuntimeError::ConfigBundleDigestMismatch { .. }) + )); let listed = runtime.list_config_bundles().unwrap(); assert_eq!(listed.len(), 1); @@ -3428,6 +3466,53 @@ mod tests { .create_worker(bundled_task_request("synced", &bundle)) .unwrap(); assert_eq!(detail.config_bundle, Some(availability.reference)); + assert_eq!( + backend.config_bundles.lock().unwrap().as_slice(), + &[Some(bundle.clone())] + ); + + runtime.stop_worker(&detail.worker_ref, None).unwrap(); + runtime.restore_worker(&detail.worker_ref).unwrap(); + assert_eq!( + backend.config_bundles.lock().unwrap().as_slice(), + &[Some(bundle.clone()), Some(bundle)] + ); + } + + #[test] + fn restore_fails_closed_when_recorded_config_bundle_is_missing_or_mismatched() { + let (runtime, backend) = runtime_and_backend(); + let bundle = test_bundle(); + let detail = runtime + .create_worker(bundled_task_request("missing-on-restore", &bundle)) + .unwrap(); + runtime.stop_worker(&detail.worker_ref, None).unwrap(); + runtime.lock().unwrap().config_bundles.clear(); + assert!(matches!( + runtime.restore_worker(&detail.worker_ref), + Err(RuntimeError::ConfigBundleMissing { .. }) + )); + assert_eq!(backend.config_bundles.lock().unwrap().len(), 1); + + let (runtime, backend) = runtime_and_backend(); + let bundle = test_bundle(); + let detail = runtime + .create_worker(bundled_task_request("mismatch-on-restore", &bundle)) + .unwrap(); + runtime.stop_worker(&detail.worker_ref, None).unwrap(); + let mut replacement = bundle.clone(); + replacement.profiles[0].label = Some("replacement".to_string()); + replacement = replacement.with_computed_digest(); + runtime + .lock() + .unwrap() + .config_bundles + .insert(replacement.metadata.id.clone(), replacement); + assert!(matches!( + runtime.restore_worker(&detail.worker_ref), + Err(RuntimeError::ConfigBundleDigestMismatch { .. }) + )); + assert_eq!(backend.config_bundles.lock().unwrap().len(), 1); } #[test] diff --git a/crates/worker-runtime/src/worker_backend.rs b/crates/worker-runtime/src/worker_backend.rs index 45e359bd..a57661a3 100644 --- a/crates/worker-runtime/src/worker_backend.rs +++ b/crates/worker-runtime/src/worker_backend.rs @@ -53,7 +53,7 @@ use worker::feature::builtin::{ #[cfg(feature = "ws-server")] use worker::ipc::protocol_session::{live_log_entry_event, subscribe_worker_protocol_session}; use worker::{ - PromptLoader, SegmentLogSink, WORKER_INPUT_SUBMISSION_EXTENSION_DOMAIN, Worker, + PromptCatalogSource, SegmentLogSink, WORKER_INPUT_SUBMISSION_EXTENSION_DOMAIN, Worker, WorkerController, WorkerControllerTransport, WorkerError, WorkerFilesystemAuthority, WorkerHandle, WorkerSharedState, WorkerWorkspaceContext, WorkspaceClient, WorkspaceId, }; @@ -329,12 +329,12 @@ impl ProfileRuntimeWorkerFactory { fn restore_fallback_manifest( worker_name: &str, - ) -> Result<(manifest::WorkerManifest, PromptLoader), String> { + ) -> Result<(manifest::WorkerManifest, PromptCatalogSource), String> { let mut config = manifest::WorkerManifestConfig::builtin_defaults(); config.worker.name = Some(worker_name.to_string()); let manifest = manifest::WorkerManifest::try_from(config) .map_err(|err| format!("failed to build restore fallback manifest: {err}"))?; - Ok((manifest, PromptLoader::builtins_only())) + Ok((manifest, PromptCatalogSource::builtins_only())) } async fn resolve_profile_source_archive( &self, @@ -566,7 +566,7 @@ impl RuntimeWorkerFactory for ProfileRuntimeWorkerFactory { let archive = self .resolve_profile_source_archive(&request.request.profile_source) .await?; - let (manifest, loader) = { + let (manifest, mut loader) = { let manifest = archive .resolve_profile(selector, &worker_root, &worker_name) .map_err(|err| format!("failed to resolve profile source archive: {err}"))?; @@ -584,6 +584,13 @@ impl RuntimeWorkerFactory for ProfileRuntimeWorkerFactory { )? } }; + if let Some(prompt_catalog) = request + .config_bundle + .as_ref() + .and_then(|bundle| bundle.prompt_catalog.clone()) + { + loader = loader.with_effective_catalog(prompt_catalog); + } let flow_transition_enabled = manifest.feature.flow.enabled; let worker_aggregate_dir = self.worker_aggregate_dir(&request.worker_ref)?; @@ -719,7 +726,14 @@ impl RuntimeWorkerFactory for ProfileRuntimeWorkerFactory { self.worker_mutation_identity.as_ref(), self.embedded_worker_mutation_dispatcher.as_ref(), ); - let (manifest, loader) = Self::restore_fallback_manifest(&worker_name)?; + let (manifest, mut loader) = Self::restore_fallback_manifest(&worker_name)?; + if let Some(prompt_catalog) = request + .config_bundle + .as_ref() + .and_then(|bundle| bundle.prompt_catalog.clone()) + { + loader = loader.with_effective_catalog(prompt_catalog); + } let worker_aggregate_dir = self.worker_aggregate_dir(&request.worker_ref)?; let session_dir = worker_aggregate_dir.join("session"); @@ -2090,6 +2104,7 @@ mod tests { label: Some("adapter-test".to_string()), }], declarations: Vec::new(), + prompt_catalog: None, profile_source_archive: Some(sample_profile_archive()), profile_source_archive_handle: None, } diff --git a/crates/worker/Cargo.toml b/crates/worker/Cargo.toml index c7c7785e..27e3dae0 100644 --- a/crates/worker/Cargo.toml +++ b/crates/worker/Cargo.toml @@ -29,6 +29,7 @@ tools = { workspace = true } workdir = { workspace = true } minijinja = "2.19.0" chrono = "0.4" +config-source = { path = "../config-source" } include_dir = "0.7.4" fs4 = { workspace = true, features = ["sync"] } flow = { path = "../flow" } @@ -51,6 +52,3 @@ serial_test = "3.4.0" tempfile = { workspace = true } wat = "1.241.2" yoi-plugin-pdk = { workspace = true } - -[build-dependencies] -toml = { workspace = true } diff --git a/crates/worker/build.rs b/crates/worker/build.rs index fb53fa1c..1f587307 100644 --- a/crates/worker/build.rs +++ b/crates/worker/build.rs @@ -1,49 +1,3 @@ -//! Emits `$OUT_DIR/internal_keys.rs` containing the sorted list of keys -//! present in `resources/prompts/internal.toml`. The generated slice is -//! included into `src/prompts.rs` where a `const _` assertion compares -//! it bidirectionally against the `WorkerPrompt` enum's own key list, so -//! that a mismatch fails the build (see ticket: worker-prompt-catalog). -use std::env; -use std::fs; -use std::path::PathBuf; - fn main() { - let manifest_dir = env::var("CARGO_MANIFEST_DIR").expect("CARGO_MANIFEST_DIR"); - let toml_path = PathBuf::from(&manifest_dir) - .join("..") - .join("..") - .join("resources") - .join("prompts") - .join("internal.toml"); - - println!("cargo:rerun-if-changed={}", toml_path.display()); - println!("cargo:rerun-if-changed=build.rs"); - - let toml_str = fs::read_to_string(&toml_path) - .unwrap_or_else(|e| panic!("failed to read {}: {e}", toml_path.display())); - - let parsed: toml::Value = toml::from_str(&toml_str) - .unwrap_or_else(|e| panic!("failed to parse {}: {e}", toml_path.display())); - - let prompt_section = parsed - .get("prompt") - .and_then(|v| v.as_table()) - .unwrap_or_else(|| panic!("{} must contain a `[prompt]` table", toml_path.display())); - - let mut keys: Vec = prompt_section.keys().cloned().collect(); - keys.sort(); - - let out_dir = env::var("OUT_DIR").expect("OUT_DIR"); - let out_path = PathBuf::from(out_dir).join("internal_keys.rs"); - - let mut code = String::from("pub(crate) const INTERNAL_KEYS: &[&str] = &[\n"); - for k in &keys { - code.push_str(" "); - code.push_str(&format!("{k:?}")); - code.push_str(",\n"); - } - code.push_str("];\n"); - - fs::write(&out_path, code) - .unwrap_or_else(|e| panic!("failed to write {}: {e}", out_path.display())); + println!("cargo:rerun-if-changed=../../resources/prompts"); } diff --git a/crates/worker/src/entrypoint.rs b/crates/worker/src/entrypoint.rs index 60bc4657..ad345d03 100644 --- a/crates/worker/src/entrypoint.rs +++ b/crates/worker/src/entrypoint.rs @@ -3,7 +3,8 @@ use std::path::{Path, PathBuf}; use std::process::ExitCode; use crate::{ - PromptLoader, Worker, WorkerController, WorkerFilesystemAuthority, WorkerWorkspaceContext, + PromptCatalogSource, Worker, WorkerController, WorkerFilesystemAuthority, + WorkerWorkspaceContext, }; use clap::{CommandFactory, FromArgMatches, Parser}; use manifest::{Permission, ScopeConfig, ScopeRule, WorkerManifest, WorkerManifestConfig, paths}; @@ -137,7 +138,7 @@ fn sanitise_worker_name(raw: &str) -> String { } } -fn resolve_manifest(cli: &Cli) -> Result<(WorkerManifest, PromptLoader), String> { +fn resolve_manifest(cli: &Cli) -> Result<(WorkerManifest, PromptCatalogSource), String> { let process_root = runtime_workspace_root(cli)?; let runtime_worker_name = runtime_worker_name(cli, &process_root); let ((mut manifest, loader), apply_direct_launch_policy) = if let Some(config_json) = @@ -178,29 +179,31 @@ fn apply_session_restore_overrides(manifest: &mut WorkerManifest, cli: &Cli) -> Ok(()) } -fn load_spawn_config_json(config_json: &str) -> Result<(WorkerManifest, PromptLoader), String> { +fn load_spawn_config_json( + config_json: &str, +) -> Result<(WorkerManifest, PromptCatalogSource), String> { let config = serde_json::from_str::(config_json) .map_err(|e| format!("failed to parse --spawn-config-json: {e}"))?; let manifest = WorkerManifest::try_from(WorkerManifestConfig::builtin_defaults().merge(config)) .map_err(|e| format!("failed to resolve --spawn-config-json: {e}"))?; - Ok((manifest, PromptLoader::builtins_only())) + Ok((manifest, PromptCatalogSource::builtins_only())) } fn load_builtin_default_manifest( worker_name: &str, -) -> Result<(WorkerManifest, PromptLoader), String> { +) -> Result<(WorkerManifest, PromptCatalogSource), String> { let mut config = WorkerManifestConfig::builtin_defaults(); config.worker.name = Some(worker_name.to_string()); let manifest = WorkerManifest::try_from(config) .map_err(|e| format!("failed to resolve builtin worker defaults: {e}"))?; - Ok((manifest, PromptLoader::builtins_only())) + Ok((manifest, PromptCatalogSource::builtins_only())) } pub fn resolve_runtime_profile_manifest( _profile: Option<&str>, _workspace_root: &Path, _worker_name: &str, -) -> Result<(WorkerManifest, PromptLoader), String> { +) -> Result<(WorkerManifest, PromptCatalogSource), String> { Err( "runtime profile resolution requires a pre-resolved manifest/profile archive from Backend authority" .to_string(), @@ -211,7 +214,7 @@ pub fn resolve_runtime_profile_manifest_from_manifest( mut manifest: WorkerManifest, workspace_root: &Path, worker_name: &str, -) -> Result<(WorkerManifest, PromptLoader), String> { +) -> Result<(WorkerManifest, PromptCatalogSource), String> { if manifest.worker.name.is_empty() { manifest.worker.name = worker_name.to_string(); } @@ -219,28 +222,28 @@ pub fn resolve_runtime_profile_manifest_from_manifest( // Do not run plugin discovery here: runtime-created Workers receive their // resolved manifest/profile archive from Backend authority, not by scanning // materialized workdir-local plugin stores. - Ok((manifest, PromptLoader::builtins_only())) + Ok((manifest, PromptCatalogSource::builtins_only())) } pub fn resolve_runtime_profile_manifest_from_manifest_without_filesystem( mut manifest: WorkerManifest, _workspace_root: &Path, worker_name: &str, -) -> Result<(WorkerManifest, PromptLoader), String> { +) -> Result<(WorkerManifest, PromptCatalogSource), String> { if manifest.worker.name.is_empty() { manifest.worker.name = worker_name.to_string(); } manifest.scope = ScopeConfig::default(); manifest.delegation_scope = ScopeConfig::default(); // Same as the filesystem-capable runtime path: no local discovery. - Ok((manifest, PromptLoader::builtins_only())) + Ok((manifest, PromptCatalogSource::builtins_only())) } fn load_single_manifest( path: &Path, explicit_worker_name: Option<&str>, default_worker_name: &str, -) -> Result<(WorkerManifest, PromptLoader), String> { +) -> Result<(WorkerManifest, PromptCatalogSource), String> { let toml = std::fs::read_to_string(path) .map_err(|e| format!("failed to read manifest {}: {e}", path.display()))?; let absolute_path = if path.is_absolute() { @@ -274,7 +277,7 @@ fn load_single_manifest( path.display() )); } - Ok((manifest, PromptLoader::builtins_only())) + Ok((manifest, PromptCatalogSource::builtins_only())) } fn read_rule(target: PathBuf) -> ScopeRule { @@ -751,11 +754,9 @@ permission = "write" let cli = Cli::try_parse_from(["yoi worker", "--manifest", manifest.to_str().unwrap()]).unwrap(); - let (manifest, loader) = resolve_manifest(&cli).unwrap(); + let (manifest, _loader) = resolve_manifest(&cli).unwrap(); assert_eq!(manifest.worker.name, "single"); - assert!(loader.user_dir().is_none()); - assert!(loader.workspace_dir().is_none()); } #[test] @@ -764,7 +765,7 @@ permission = "write" let yoi_dir = tmp.path().join(".yoi"); std::fs::create_dir_all(&yoi_dir).unwrap(); write( - &yoi_dir.join("override.local.toml"), + &yoi_dir.join("ignored-local-file.toml"), r#" [worker] name = "from-local-override" @@ -822,7 +823,7 @@ permission = "write" let yoi_dir = workspace.join(".yoi"); std::fs::create_dir_all(&yoi_dir).unwrap(); write( - &yoi_dir.join("override.local.toml"), + &yoi_dir.join("ignored-local-file.toml"), r#" [worker] name = "from-local-override" @@ -834,12 +835,10 @@ language = "override" let cli = Cli::try_parse_from(["yoi worker", "--workspace", workspace.to_str().unwrap()]) .unwrap(); - let (manifest, loader) = resolve_manifest(&cli).unwrap(); + let (manifest, _loader) = resolve_manifest(&cli).unwrap(); assert_eq!(manifest.worker.name, "runtime-workspace"); assert_ne!(manifest.engine.language, "override"); - assert!(loader.user_dir().is_none()); - assert!(loader.workspace_dir().is_none()); assert_scope_contains(&manifest.scope.allow, &workspace, Permission::Write); } @@ -1031,12 +1030,8 @@ permission = "write" ]) .unwrap(); - let (manifest, loader) = resolve_manifest(&cli).unwrap(); + let (manifest, _loader) = resolve_manifest(&cli).unwrap(); assert_eq!(manifest.worker.name, "single-file"); - assert!(loader.user_dir().is_none()); - assert!(loader.workspace_dir().is_none()); - assert!(loader.user_pack_file().is_none()); - assert!(loader.workspace_pack_file().is_none()); } } diff --git a/crates/worker/src/feature.rs b/crates/worker/src/feature.rs index 3ceb71fe..41b996b9 100644 --- a/crates/worker/src/feature.rs +++ b/crates/worker/src/feature.rs @@ -1858,8 +1858,8 @@ mod tests { #[test] fn instruction_contributions_are_deduped_in_registration_order() { - let workflow = instruction("workflow", "$yoi/common/tickets"); - let orchestration = instruction("orchestration", "$yoi/common/worker-orchestration"); + let workflow = instruction("workflow", "common.tickets"); + let orchestration = instruction("orchestration", "common.worker_orchestration"); let contributions = dedupe_instruction_contributions([ workflow.clone(), orchestration.clone(), @@ -1871,8 +1871,8 @@ mod tests { #[test] fn undeclared_instruction_contribution_is_rejected() { - let declared = instruction("declared", "$yoi/common/tickets"); - let undeclared = instruction("undeclared", "$yoi/common/tickets"); + let declared = instruction("declared", "common.tickets"); + let undeclared = instruction("undeclared", "common.tickets"); let descriptor = FeatureDescriptor::builtin("instruction", "Instruction").with_instruction(declared); let mut hook_builder = HookRegistryBuilder::default(); diff --git a/crates/worker/src/feature/builtin/ticket.rs b/crates/worker/src/feature/builtin/ticket.rs index fbf4f2ec..fb21a3b9 100644 --- a/crates/worker/src/feature/builtin/ticket.rs +++ b/crates/worker/src/feature/builtin/ticket.rs @@ -33,7 +33,7 @@ const FEATURE_NAME: &str = "Ticket tools"; const FEATURE_DESCRIPTION: &str = "Typed local Ticket work-item operations over a bounded backend root. \ The tools operate through the ticket crate backend and do not grant generic filesystem write scope."; const TICKET_WORKFLOW_INSTRUCTION_ID: &str = "ticket.workflow"; -const TICKET_WORKFLOW_PROMPT_REF: &str = "$yoi/common/tickets"; +const TICKET_WORKFLOW_PROMPT_REF: &str = "common.tickets"; pub const TICKET_SERVICE_ID: &str = "ticket.authority"; const TICKET_SERVICE_VERSION: &str = "1"; diff --git a/crates/worker/src/feature/builtin/worker_observation.rs b/crates/worker/src/feature/builtin/worker_observation.rs index 855b92ca..376c31ce 100644 --- a/crates/worker/src/feature/builtin/worker_observation.rs +++ b/crates/worker/src/feature/builtin/worker_observation.rs @@ -23,10 +23,7 @@ const DEFAULT_PAGE_LIMIT: usize = 20; const MAX_PAGE_LIMIT: usize = 100; const MAX_READ_BYTES: usize = 16 * 1024; const OBSERVATION_INSTRUCTION_ID: &str = "worker-observation.policy"; -const OBSERVATION_PROMPT_REF: &str = "$yoi/common/worker-observation"; -#[cfg(test)] -const OBSERVATION_PROMPT_SOURCE: &str = - include_str!("../../../../../resources/prompts/common/worker-observation.md"); +const OBSERVATION_PROMPT_REF: &str = "common.worker_observation"; fn observation_instruction() -> FeatureInstructionDeclaration { FeatureInstructionDeclaration::new( @@ -801,6 +798,8 @@ mod tests { #[test] fn prompt_source_names_the_worker_observation_contract() { + let catalog = crate::PromptCatalog::builtins_only().unwrap(); + let source = &catalog.projection().templates["common.worker_observation"]; for token in [ "ListWorkerSessions", "ViewSessionOverview", @@ -808,7 +807,7 @@ mod tests { "ReadSessionEntry", "SessionEntryRef", ] { - assert!(OBSERVATION_PROMPT_SOURCE.contains(token), "missing {token}"); + assert!(source.contains(token), "missing {token}"); } } diff --git a/crates/worker/src/lib.rs b/crates/worker/src/lib.rs index 469ad674..675efb78 100644 --- a/crates/worker/src/lib.rs +++ b/crates/worker/src/lib.rs @@ -32,8 +32,10 @@ pub use manifest::{ WorkerMetaConfig, }; pub use model_client::{ProviderError, build_client}; -pub use prompt::catalog::{CatalogError, PromptCatalog, WorkerPrompt}; -pub use prompt::loader::PromptLoader; +pub use prompt::catalog::{ + CatalogError, EffectivePromptCatalog, PromptCatalog, WorkerPrompt, prompt_schema_source, +}; +pub use prompt::source::PromptCatalogSource; pub use prompt::system::{SystemPromptContext, SystemPromptError, SystemPromptTemplate}; pub use protocol::{ErrorCode, Event, Method, TurnResult, WorkerStatus}; pub use runtime::dir::RuntimeDir; diff --git a/crates/worker/src/prompt/catalog.rs b/crates/worker/src/prompt/catalog.rs index d6baab49..0b874567 100644 --- a/crates/worker/src/prompt/catalog.rs +++ b/crates/worker/src/prompt/catalog.rs @@ -1,122 +1,129 @@ -//! Central catalog of Worker-level prompt strings. +//! Typed effective Prompt catalog. //! -//! Prompts that Worker injects into a Engine (compaction system prompt, -//! notification wrapper, interrupt notes, system-prompt trailing -//! sections, AGENTS.md truncation notice, ...) are enumerated by -//! [`WorkerPrompt`] and rendered through a single [`PromptCatalog`]. Direct -//! `const &str` / `format!` authoring of these strings elsewhere in -//! `crates/worker` is deliberately avoided — new injection points add a -//! variant here, which forces a matching entry in -//! `resources/prompts/internal.toml` (checked at build time) and keeps -//! the "Worker tone" editable in one place. -//! -//! # Layering -//! -//! Values are merged key-wise from low priority to high: -//! -//! 1. **builtin** — `resources/prompts/internal.toml`, baked into the -//! binary. Must cover every [`WorkerPrompt`] variant (build-time check). -//! 2. **user** — `/prompts.toml`, when a caller supplies it. -//! Optional. -//! 3. **workspace** — `/.yoi/prompts.toml`, when a caller -//! supplies it. Optional. -//! 4. **manifest pack** — `manifest.worker.prompt_pack`, an explicit path -//! per-Worker. Optional. -//! -//! Unknown keys in layers 2–4 are logged via `tracing::warn!` and -//! ignored (forward compatibility). Layer 1 is enforced at build time. -//! -//! # Template language -//! -//! All values are minijinja templates. `{% include "$prefix/..." %}` -//! resolves through the same [`PromptLoader`] used by the system-prompt -//! template, so long prompt bodies can be factored into `.md` files -//! under `resources/prompts/...`, the user prompts library, or the -//! workspace prompts library. +//! Builtins are evaluated from the embedded `resources/prompts/catalog.dcdl` +//! source tree. Markdown imports use the same `{ frontmatter, content }` +//! projection as Workspace config; the catalog DCDL selects `.content`. +//! Workspace configuration materializes a complete closed `prompts` object, +//! which is carried as an immutable [`EffectivePromptCatalog`] projection. -use std::collections::HashMap; -use std::fs; -use std::path::{Path, PathBuf}; +use std::collections::BTreeMap; use std::sync::Arc; +use config_source::{ + ConfigContentType, ConfigEntry, ConfigTreeSnapshot, SnapshotEnvironment, ToolchainContract, + VirtualPath, digest_bytes, +}; +use include_dir::{Dir, include_dir}; use minijinja::value::Value; -use minijinja::{Environment, ErrorKind, UndefinedBehavior}; -use serde::Deserialize; +use minijinja::{Environment, UndefinedBehavior}; +use serde::{Deserialize, Serialize}; use thiserror::Error; -use tracing::warn; -use crate::prompt::loader::PromptLoader; +use crate::prompt::source::PromptCatalogSource; -// Generated by build.rs from `resources/prompts/internal.toml`. -include!(concat!(env!("OUT_DIR"), "/internal_keys.rs")); +static BUILTIN_PROMPT_SOURCES: Dir<'static> = + include_dir!("$CARGO_MANIFEST_DIR/../../resources/prompts"); +const BUILTIN_CATALOG_ENTRY: &str = "catalog.dcdl"; +const BUILTIN_TOOLCHAIN_FINGERPRINT: &str = "builtin:prompts:decodal-0.4"; -/// Source of the builtin pack. Baked in at compile time. -const INTERNAL_TOML: &str = include_str!("../../../../resources/prompts/internal.toml"); +/// Immutable Prompt projection delivered by Workspace authority. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct EffectivePromptCatalog { + pub templates: BTreeMap, + pub config_revision: u64, + pub schema_fingerprint: String, + pub toolchain_fingerprint: String, + pub catalog_digest: String, +} + +impl EffectivePromptCatalog { + pub fn new( + templates: BTreeMap, + config_revision: u64, + schema_fingerprint: impl Into, + toolchain_fingerprint: impl Into, + ) -> Result { + validate_prompt_templates(&templates)?; + let catalog_digest = catalog_digest(&templates)?; + Ok(Self { + templates, + config_revision, + schema_fingerprint: schema_fingerprint.into(), + toolchain_fingerprint: toolchain_fingerprint.into(), + catalog_digest, + }) + } + + pub fn from_projection( + prompts: &serde_json::Value, + config_revision: u64, + schema_fingerprint: impl Into, + toolchain_fingerprint: impl Into, + ) -> Result { + let mut templates = BTreeMap::new(); + flatten_templates("", prompts, &mut templates)?; + if let Some(default_prompt) = templates.remove("default_prompt") { + templates.insert("default".to_string(), default_prompt); + } + Self::new( + templates, + config_revision, + schema_fingerprint, + toolchain_fingerprint, + ) + } + + pub fn verify_digest(&self) -> Result<(), CatalogError> { + let actual = catalog_digest(&self.templates)?; + if actual != self.catalog_digest { + return Err(CatalogError::DigestMismatch { + expected: self.catalog_digest.clone(), + actual, + }); + } + validate_prompt_templates(&self.templates) + } +} /// Worker-level prompt injection point. -/// -/// Adding a new variant also requires adding a matching key to -/// `resources/prompts/internal.toml`; the build fails otherwise. #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] pub enum WorkerPrompt { - /// System prompt of the compaction (summary) Engine. CompactSystem, - /// System prompt of the memory extract Engine. MemoryExtractSystem, - /// System prompt of the memory consolidation (integration + tidy) Engine. MemoryConsolidationSystem, - /// System prompt of the bounded Flow transition verifier. FlowVerifierSystem, - /// Wrapper around an incoming `Method::Notify` message injected into - /// the next LLM request context as a transient system message. NotifyWrapper, - /// Synthetic `Item::ToolResult` summary used to close out orphaned - /// tool calls when a paused turn is interrupted by the user. InterruptToolResultSummary, - /// System note prepended to the new turn after an interrupt. InterruptSystemNote, - /// Trailing `## Working boundaries` section appended to every - /// materialised system prompt. WorkingBoundariesSection, - /// Trailing `## Project instructions (AGENTS.md)` section, appended - /// after the scope summary when an AGENTS.md is present. AgentsMdSection, - /// Trailing `## Resident memory summary` section, appended after the - /// AGENTS.md section when memory is enabled, resident injection is enabled, - /// and the workspace Memory document has a valid non-empty body. ResidentMemorySummarySection, - /// Trailing Worker orchestration guidance, appended when registered tools - /// include Worker-management capabilities. WorkerOrchestrationGuidanceSection, - /// Weak Companion Notify payload for explicit Orchestrator Ticket events. TicketEventCompanionNotice, - /// LLM-facing description for the SubWorkerSpawn tool, including discovered - /// profile selectors. SubWorkerSpawnToolDescription, } impl WorkerPrompt { pub fn key(self) -> &'static str { match self { - Self::CompactSystem => "compact_system", - Self::MemoryExtractSystem => "memory_extract_system", - Self::MemoryConsolidationSystem => "memory_consolidation_system", - Self::FlowVerifierSystem => "flow_verifier_system", - Self::NotifyWrapper => "notify_wrapper", - Self::InterruptToolResultSummary => "interrupt_tool_result_summary", - Self::InterruptSystemNote => "interrupt_system_note", - Self::WorkingBoundariesSection => "working_boundaries_section", - Self::AgentsMdSection => "agents_md_section", - Self::ResidentMemorySummarySection => "resident_memory_summary_section", - Self::WorkerOrchestrationGuidanceSection => "worker_orchestration_guidance_section", - Self::TicketEventCompanionNotice => "ticket_event_companion_notice", - Self::SubWorkerSpawnToolDescription => "sub_worker_spawn_tool_description", + Self::CompactSystem => "internal.compact_system", + Self::MemoryExtractSystem => "internal.memory_extract_system", + Self::MemoryConsolidationSystem => "internal.memory_consolidation_system", + Self::FlowVerifierSystem => "internal.flow_verifier_system", + Self::NotifyWrapper => "internal.notify_wrapper", + Self::InterruptToolResultSummary => "internal.interrupt_tool_result_summary", + Self::InterruptSystemNote => "internal.interrupt_system_note", + Self::WorkingBoundariesSection => "internal.working_boundaries_section", + Self::AgentsMdSection => "internal.agents_md_section", + Self::ResidentMemorySummarySection => "internal.resident_memory_summary_section", + Self::WorkerOrchestrationGuidanceSection => { + "internal.worker_orchestration_guidance_section" + } + Self::TicketEventCompanionNotice => "worker.ticket_event_companion_notice", + Self::SubWorkerSpawnToolDescription => "internal.sub_worker_spawn_tool_description", } } - /// All variants in declaration order. The associated `KEYS` slice - /// mirrors this for const-eval coverage checks against - /// `INTERNAL_KEYS` (generated by `build.rs`). pub const ALL: &'static [WorkerPrompt] = &[ WorkerPrompt::CompactSystem, WorkerPrompt::MemoryExtractSystem, @@ -132,96 +139,18 @@ impl WorkerPrompt { WorkerPrompt::TicketEventCompanionNotice, WorkerPrompt::SubWorkerSpawnToolDescription, ]; - - pub const KEYS: &'static [&'static str] = &[ - "compact_system", - "memory_extract_system", - "memory_consolidation_system", - "flow_verifier_system", - "notify_wrapper", - "interrupt_tool_result_summary", - "interrupt_system_note", - "working_boundaries_section", - "agents_md_section", - "resident_memory_summary_section", - "worker_orchestration_guidance_section", - "ticket_event_companion_notice", - "sub_worker_spawn_tool_description", - ]; } -// --- build-time bidirectional coverage check -------------------------------- - -const _: () = { - // Every enum key must appear in the builtin TOML. - let mut i = 0; - while i < WorkerPrompt::KEYS.len() { - if !const_slice_contains(INTERNAL_KEYS, WorkerPrompt::KEYS[i]) { - panic!( - "resources/prompts/internal.toml is missing a key declared by \ - WorkerPrompt — regenerate the TOML or remove the variant" - ); - } - i += 1; - } - // Every TOML key must correspond to an enum variant. - let mut i = 0; - while i < INTERNAL_KEYS.len() { - if !const_slice_contains(WorkerPrompt::KEYS, INTERNAL_KEYS[i]) { - panic!( - "resources/prompts/internal.toml has a key not declared by \ - WorkerPrompt — add the variant or drop the key" - ); - } - i += 1; - } -}; - -const fn const_str_eq(a: &str, b: &str) -> bool { - let a = a.as_bytes(); - let b = b.as_bytes(); - if a.len() != b.len() { - return false; - } - let mut i = 0; - while i < a.len() { - if a[i] != b[i] { - return false; - } - i += 1; - } - true -} - -const fn const_slice_contains(haystack: &[&str], needle: &str) -> bool { - let mut i = 0; - while i < haystack.len() { - if const_str_eq(haystack[i], needle) { - return true; - } - i += 1; - } - false -} - -// --- errors ---------------------------------------------------------------- - #[derive(Debug, Error)] pub enum CatalogError { - #[error("failed to read prompt pack {}: {source}", .path.display())] - Io { - path: PathBuf, - #[source] - source: std::io::Error, - }, - #[error("failed to parse prompt pack {}: {source}", .path.display())] - ParseToml { - path: PathBuf, - #[source] - source: toml::de::Error, - }, - #[error("failed to parse builtin prompt pack: {0}")] - ParseBuiltin(#[source] toml::de::Error), + #[error("failed to build builtin Prompt source tree: {0}")] + BuiltinTree(String), + #[error("failed to evaluate builtin Prompt source tree: {0}")] + BuiltinEvaluation(String), + #[error("effective Prompt projection at '{path}' must be an object or string")] + InvalidProjection { path: String }, + #[error("invalid effective Prompt template catalog: {0}")] + InvalidTemplateCatalog(String), #[error("failed to compile prompt template '{key}': {source}")] TemplateCompile { key: String, @@ -236,544 +165,478 @@ pub enum CatalogError { }, #[error("prompt key '{key}' is not registered in the catalog")] UnknownKey { key: String }, + #[error("failed to serialize effective Prompt catalog: {0}")] + Serialize(#[from] serde_json::Error), + #[error("effective Prompt catalog digest mismatch: expected {expected}, got {actual}")] + DigestMismatch { expected: String, actual: String }, } -// --- pack file shape ------------------------------------------------------- - -#[derive(Debug, Deserialize)] -struct PackFile { - #[serde(default)] - prompt: HashMap, -} - -// --- catalog --------------------------------------------------------------- - -/// Merged, compiled worker-prompt catalog. -/// -/// Owns a `minijinja::Environment` with one template registered per -/// [`WorkerPrompt`] key (after the 4-layer merge). Includes inside templates -/// are resolved via a provided [`PromptLoader`], so values can pull from -/// `$yoi` / `$user` / `$workspace`. pub struct PromptCatalog { env: Environment<'static>, - loader: PromptLoader, + projection: EffectivePromptCatalog, } impl std::fmt::Debug for PromptCatalog { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - f.debug_struct("PromptCatalog").finish_non_exhaustive() + f.debug_struct("PromptCatalog") + .field("config_revision", &self.projection.config_revision) + .field("catalog_digest", &self.projection.catalog_digest) + .finish_non_exhaustive() } } impl PromptCatalog { - pub(crate) fn loader(&self) -> PromptLoader { - self.loader.clone() - } - - /// Builtin-only catalog. All `{% include %}` references must resolve - /// through `$yoi` (user/workspace prefixes are unavailable). pub fn builtins_only() -> Result, CatalogError> { - Self::load(&PromptLoader::builtins_only(), None) + let templates = builtin_prompt_templates()?; + let projection = EffectivePromptCatalog::new( + templates, + 0, + BUILTIN_TOOLCHAIN_FINGERPRINT, + BUILTIN_TOOLCHAIN_FINGERPRINT, + )?; + Self::from_projection(projection).map(Arc::new) } - /// Load the catalog honouring the 4-layer overlay. - /// - /// - Layer 1 (builtin): `INTERNAL_TOML` baked into the binary. - /// - Layer 2 (user): `loader.user_pack_file()` if present. - /// - Layer 3 (workspace): `loader.workspace_pack_file()` if present. - /// - Layer 4 (manifest): `manifest_pack` as an absolute filesystem - /// path (pre-resolved by profile/manifest resolution). - pub fn load( - loader: &PromptLoader, - manifest_pack: Option<&Path>, - ) -> Result, CatalogError> { - let mut merged = parse_builtin_pack()?; - - if let Some(path) = loader.user_pack_file() { - if path.is_file() { - let pack = parse_pack_file(path)?; - merge_into(&mut merged, pack, "user"); - } + pub fn load(loader: &PromptCatalogSource) -> Result, CatalogError> { + if let Some(projection) = loader.effective_catalog() { + return Self::from_projection(projection.clone()).map(Arc::new); } - if let Some(path) = loader.workspace_pack_file() { - if path.is_file() { - let pack = parse_pack_file(path)?; - merge_into(&mut merged, pack, "workspace"); - } - } - if let Some(path) = manifest_pack { - let pack = parse_pack_file(path)?; - merge_into(&mut merged, pack, "manifest"); - } - - build_catalog(merged, loader.clone()).map(Arc::new) + Self::builtins_only() } - /// Render a prompt by variant. `ctx` provides template variables; use - /// [`Value::UNDEFINED`] (or a helper below) when the template takes - /// no inputs. - pub fn render(&self, prompt: WorkerPrompt, ctx: Value) -> Result { - let key = prompt.key(); - let tmpl = self + pub fn from_projection(projection: EffectivePromptCatalog) -> Result { + projection.verify_digest()?; + let mut env = Environment::new(); + env.set_undefined_behavior(UndefinedBehavior::Strict); + for (key, source) in &projection.templates { + env.add_template_owned(key.clone(), source.clone()) + .map_err(|source| CatalogError::TemplateCompile { + key: key.clone(), + source, + })?; + } + Ok(Self { env, projection }) + } + + pub fn projection(&self) -> &EffectivePromptCatalog { + &self.projection + } + + pub(crate) fn source(&self) -> PromptCatalogSource { + PromptCatalogSource::builtins_only().with_effective_catalog(self.projection.clone()) + } + + pub fn contains(&self, key: &str) -> bool { + self.projection.templates.contains_key(key) + } + + pub fn render_serializable( + &self, + key: &str, + context: &T, + ) -> Result { + self.render_name(key, Value::from_serialize(context)) + } + + pub fn render_name(&self, key: &str, ctx: Value) -> Result { + let template = self .env .get_template(key) - .map_err(|_| CatalogError::UnknownKey { - key: key.to_string(), - })?; - tmpl.render(ctx).map_err(|source| CatalogError::Render { - key: key.to_string(), + .map_err(|_| CatalogError::UnknownKey { key: key.into() })?; + template.render(ctx).map_err(|source| CatalogError::Render { + key: key.into(), source, }) } - /// Render `WorkerPrompt::CompactSystem` (no inputs). + pub fn render(&self, prompt: WorkerPrompt, ctx: Value) -> Result { + self.render_name(prompt.key(), ctx) + } + pub fn compact_system(&self) -> Result { self.render(WorkerPrompt::CompactSystem, Value::UNDEFINED) } - - /// Render `WorkerPrompt::MemoryExtractSystem` with `{{ language }}`. pub fn memory_extract_system(&self, language: &str) -> Result { self.render( WorkerPrompt::MemoryExtractSystem, single("language", language), ) } - - /// Render `WorkerPrompt::MemoryConsolidationSystem` with `{{ language }}`. pub fn memory_consolidation_system(&self, language: &str) -> Result { self.render( WorkerPrompt::MemoryConsolidationSystem, single("language", language), ) } - - /// Render `WorkerPrompt::FlowVerifierSystem` (no inputs). pub fn flow_verifier_system(&self) -> Result { self.render(WorkerPrompt::FlowVerifierSystem, Value::UNDEFINED) } - - /// Render `WorkerPrompt::NotifyWrapper` with `{{ message }}`. pub fn notify_wrapper(&self, message: &str) -> Result { self.render(WorkerPrompt::NotifyWrapper, single("message", message)) } - - /// Render `WorkerPrompt::InterruptToolResultSummary` (no inputs). pub fn interrupt_tool_result_summary(&self) -> Result { self.render(WorkerPrompt::InterruptToolResultSummary, Value::UNDEFINED) } - - /// Render `WorkerPrompt::InterruptSystemNote` (no inputs). pub fn interrupt_system_note(&self) -> Result { self.render(WorkerPrompt::InterruptSystemNote, Value::UNDEFINED) } - - /// Render `WorkerPrompt::WorkingBoundariesSection` with `{{ scope_summary }}`. pub fn working_boundaries_section(&self, scope_summary: &str) -> Result { self.render( WorkerPrompt::WorkingBoundariesSection, single("scope_summary", scope_summary), ) } - - /// Render `WorkerPrompt::AgentsMdSection` with `{{ agents_md }}`. pub fn agents_md_section(&self, agents_md: &str) -> Result { self.render( WorkerPrompt::AgentsMdSection, single("agents_md", agents_md), ) } - - /// Render `WorkerPrompt::ResidentMemorySummarySection` with `{{ summary }}`. pub fn resident_memory_summary_section(&self, summary: &str) -> Result { self.render( WorkerPrompt::ResidentMemorySummarySection, single("summary", summary), ) } - - /// Render `WorkerPrompt::WorkerOrchestrationGuidanceSection` (no inputs). pub fn worker_orchestration_guidance_section(&self) -> Result { self.render( WorkerPrompt::WorkerOrchestrationGuidanceSection, Value::UNDEFINED, ) } - - /// Render `WorkerPrompt::SubWorkerSpawnToolDescription`. pub fn sub_worker_spawn_tool_description( &self, available_profiles: &str, default_profile: &str, profile_diagnostic: &str, ) -> Result { - use std::collections::BTreeMap; - let mut m: BTreeMap<&'static str, Value> = BTreeMap::new(); - m.insert("available_profiles", Value::from(available_profiles)); - m.insert("default_profile", Value::from(default_profile)); - m.insert("profile_diagnostic", Value::from(profile_diagnostic)); - self.render(WorkerPrompt::SubWorkerSpawnToolDescription, Value::from(m)) + let mut context = BTreeMap::new(); + context.insert("available_profiles", Value::from(available_profiles)); + context.insert("default_profile", Value::from(default_profile)); + context.insert("profile_diagnostic", Value::from(profile_diagnostic)); + self.render( + WorkerPrompt::SubWorkerSpawnToolDescription, + Value::from(context), + ) } } +/// DCDL schema contribution for the closed `WorkspaceConfig.prompts` namespace. +/// Every leaf defaults to its builtin value, so Workspace config is a right-biased +/// deep patch while evaluation materializes a complete effective catalog. +pub fn prompt_schema_source() -> Result { + let mut templates = builtin_prompt_templates()?; + if let Some(default) = templates.remove("default") { + templates.insert("default_prompt".to_string(), default); + } + let tree = unflatten_templates(&templates); + let mut output = String::from("{ prompts = "); + write_schema_object(&mut output, &tree)?; + output.push_str(" default {}; }"); + Ok(output) +} + +pub fn builtin_prompt_templates() -> Result, CatalogError> { + let mut entries = Vec::new(); + collect_builtin_entries(&BUILTIN_PROMPT_SOURCES, "", &mut entries)?; + let snapshot = ConfigTreeSnapshot::from_entries(0, entries) + .map_err(|error| CatalogError::BuiltinTree(error.to_string()))?; + let entry = VirtualPath::parse(BUILTIN_CATALOG_ENTRY) + .map_err(|error| CatalogError::BuiltinTree(error.to_string()))?; + let result = SnapshotEnvironment::new(snapshot) + .evaluate_contract(&ToolchainContract::new(1, vec![entry], 1)) + .map_err(|diagnostics| { + CatalogError::BuiltinEvaluation( + diagnostics + .into_iter() + .map(|diagnostic| { + format!( + "{}:{}:{}..{}: {}", + diagnostic.path, + diagnostic.kind, + diagnostic.span.start_byte, + diagnostic.span.end_byte, + diagnostic.message + ) + }) + .collect::>() + .join("; "), + ) + })?; + let projection = result + .projections + .first() + .ok_or_else(|| CatalogError::BuiltinEvaluation("catalog produced no projection".into()))?; + let mut templates = BTreeMap::new(); + flatten_templates("", &projection.data_json, &mut templates)?; + if let Some(default_prompt) = templates.remove("default_prompt") { + templates.insert("default".to_string(), default_prompt); + } + validate_prompt_templates(&templates)?; + Ok(templates) +} + +fn collect_builtin_entries( + dir: &Dir<'static>, + prefix: &str, + entries: &mut Vec, +) -> Result<(), CatalogError> { + for file in dir.files() { + let Some(name) = file.path().file_name().and_then(|name| name.to_str()) else { + continue; + }; + let relative = if prefix.is_empty() { + name.to_string() + } else { + format!("{prefix}/{name}") + }; + let content_type = if relative.ends_with(".dcdl") { + ConfigContentType::Decodal + } else if relative.ends_with(".md") { + ConfigContentType::Text + } else { + continue; + }; + let content = file.contents_utf8().ok_or_else(|| { + CatalogError::BuiltinTree(format!("builtin Prompt source is not UTF-8: {relative}")) + })?; + let path = VirtualPath::parse(&relative) + .map_err(|error| CatalogError::BuiltinTree(error.to_string()))?; + entries.push( + ConfigEntry::new(path, content_type, content) + .map_err(|error| CatalogError::BuiltinTree(error.to_string()))?, + ); + } + for child in dir.dirs() { + let Some(name) = child.path().file_name().and_then(|name| name.to_str()) else { + continue; + }; + let child_prefix = if prefix.is_empty() { + name.to_string() + } else { + format!("{prefix}/{name}") + }; + collect_builtin_entries(child, &child_prefix, entries)?; + } + Ok(()) +} + fn single(key: &'static str, value: &str) -> Value { - use std::collections::BTreeMap; - let mut m: BTreeMap<&'static str, Value> = BTreeMap::new(); - m.insert(key, Value::from(value)); - Value::from(m) + Value::from(BTreeMap::from([(key, Value::from(value))])) } -fn parse_builtin_pack() -> Result, CatalogError> { - let parsed: PackFile = toml::from_str(INTERNAL_TOML).map_err(CatalogError::ParseBuiltin)?; - Ok(parsed.prompt) -} - -fn parse_pack_file(path: &Path) -> Result, CatalogError> { - let src = fs::read_to_string(path).map_err(|source| CatalogError::Io { - path: path.to_path_buf(), - source, - })?; - let parsed: PackFile = toml::from_str(&src).map_err(|source| CatalogError::ParseToml { - path: path.to_path_buf(), - source, - })?; - Ok(parsed.prompt) -} - -fn merge_into( - base: &mut HashMap, - upper: HashMap, - origin: &'static str, -) { - for (k, v) in upper { - if !WorkerPrompt::KEYS.iter().any(|declared| *declared == k) { - warn!( - origin = origin, - key = %k, - "unknown prompt pack key; ignoring" - ); - continue; +fn flatten_templates( + prefix: &str, + value: &serde_json::Value, + output: &mut BTreeMap, +) -> Result<(), CatalogError> { + match value { + serde_json::Value::String(source) if !prefix.is_empty() => { + output.insert(prefix.to_string(), source.clone()); + Ok(()) } - base.insert(k, v); + serde_json::Value::Object(fields) => { + for (name, value) in fields { + let key = if prefix.is_empty() { + name.clone() + } else { + format!("{prefix}.{name}") + }; + flatten_templates(&key, value, output)?; + } + Ok(()) + } + _ => Err(CatalogError::InvalidProjection { + path: if prefix.is_empty() { + "prompts".into() + } else { + format!("prompts.{prefix}") + }, + }), } } -fn build_catalog( - templates: HashMap, - loader: PromptLoader, -) -> Result { - let mut env = Environment::new(); - env.set_undefined_behavior(UndefinedBehavior::Strict); +#[derive(Default)] +struct TemplateNode { + value: Option, + children: BTreeMap, +} - // Reuse the system-prompt-template resolver so `{% include - // "$prefix/..." %}` inside a catalog value pulls from the same asset - // namespaces. - let loader_for_join = loader.clone(); - env.set_path_join_callback(move |name, parent| { - let parent_ref = loader_for_join.parse_ref(parent, None).ok(); - match loader_for_join.parse_ref(name, parent_ref.as_ref()) { - Ok(r) => r.to_qualified_string().into(), - Err(_) => name.to_string().into(), +fn unflatten_templates(templates: &BTreeMap) -> TemplateNode { + let mut root = TemplateNode::default(); + for (key, value) in templates { + let mut node = &mut root; + for segment in key.split('.') { + node = node.children.entry(segment.to_string()).or_default(); } - }); - - let loader_for_src = loader.clone(); - env.set_loader(move |name| { - let reference = loader_for_src - .parse_ref(name, None) - .map_err(|e| minijinja::Error::new(ErrorKind::TemplateNotFound, e.to_string()))?; - match loader_for_src.load(&reference) { - Ok(src) => Ok(Some(src)), - Err(e) => Err(minijinja::Error::new( - ErrorKind::TemplateNotFound, - e.to_string(), - )), - } - }); - - for (k, v) in templates { - env.add_template_owned(k.clone(), v) - .map_err(|source| CatalogError::TemplateCompile { - key: k.clone(), - source, - })?; + node.value = Some(value.clone()); } + root +} - Ok(PromptCatalog { env, loader }) +fn write_schema_object(output: &mut String, node: &TemplateNode) -> Result<(), CatalogError> { + output.push_str("{"); + for (name, child) in &node.children { + output.push_str(name); + output.push_str(" = "); + if let Some(value) = &child.value { + output.push_str("String default "); + output.push_str(&serde_json::to_string(value)?); + } else { + write_schema_object(output, child)?; + output.push_str(" default {}"); + } + output.push_str("; "); + } + output.push('}'); + Ok(()) +} + +fn catalog_digest(templates: &BTreeMap) -> Result { + Ok(digest_bytes(&serde_json::to_vec(templates)?)) +} + +fn validate_prompt_templates(templates: &BTreeMap) -> Result<(), CatalogError> { + config_source::validate_static_template_catalog(templates) + .map_err(CatalogError::InvalidTemplateCatalog) } #[cfg(test)] mod tests { use super::*; - use tempfile::TempDir; - - fn loader_with_packs( - user_dir: Option, - workspace_dir: Option, - user_pack: Option, - workspace_pack: Option, - ) -> PromptLoader { - PromptLoader::new(user_dir, workspace_dir).with_pack_files(user_pack, workspace_pack) - } #[test] - fn builtin_covers_every_variant() { - let cat = PromptCatalog::builtins_only().unwrap(); - for p in WorkerPrompt::ALL { - assert!( - cat.env.get_template(p.key()).is_ok(), - "builtin missing key: {}", - p.key() - ); + fn builtin_dcdl_catalog_covers_worker_prompts() { + let catalog = PromptCatalog::builtins_only().unwrap(); + for prompt in WorkerPrompt::ALL { + assert!(catalog.projection.templates.contains_key(prompt.key())); } + assert!(catalog.projection.templates.contains_key("default")); + assert!( + catalog + .projection + .templates + .contains_key("common.workspace") + ); + assert!(catalog.projection.templates.contains_key("role.coder")); + assert!( + catalog + .projection + .templates + .contains_key("panel.orchestrator_idle_queue_notice") + ); } #[test] - fn builtin_render_compact_system_includes_worker_instructions() { - let cat = PromptCatalog::builtins_only().unwrap(); - let rendered = cat.compact_system().unwrap(); - assert!(rendered.contains("write_summary")); - assert!(rendered.contains("mark_read_required")); + fn builtin_render_resolves_catalog_root_dotted_includes() { + let catalog = PromptCatalog::builtins_only().unwrap(); + let source = &catalog.projection.templates["default"]; + assert!(source.contains("{% include \"common.workspace\" %}")); + assert!(source.contains("{% include \"common.tool_usage\" %}")); } #[test] - fn internal_worker_prompts_do_not_include_default_memory_guidance() { - let cat = PromptCatalog::builtins_only().unwrap(); - let compact = cat.compact_system().unwrap(); - let extract = cat.memory_extract_system("Japanese").unwrap(); - let consolidate = cat.memory_consolidation_system("Japanese").unwrap(); - for rendered in [compact, extract, consolidate] { - assert!(!rendered.contains("Do not query memory every turn")); - assert!(!rendered.contains("Strong lookup triggers include")); - } + fn schema_is_closed_and_materializes_builtin_defaults() { + let source = prompt_schema_source().unwrap(); + assert!(source.starts_with("{ prompts = {")); + assert!(source.contains("compact_system = String default")); + assert!(source.contains("role = {")); } #[test] - fn memory_worker_prompts_include_language() { - let cat = PromptCatalog::builtins_only().unwrap(); - let extract = cat.memory_extract_system("Japanese").unwrap(); - let consolidate = cat.memory_consolidation_system("Japanese").unwrap(); - assert!(extract.contains("`language`: `Japanese`")); - assert!(consolidate.contains("`language`: `Japanese`")); + fn graph_rejects_dynamic_legacy_missing_and_cycles() { + let invalid = BTreeMap::from([ + ("a".into(), "{%- include target -%}".into()), + ("target".into(), "ok".into()), + ]); + assert!(validate_prompt_templates(&invalid).is_err()); + + let legacy = BTreeMap::from([("a".into(), "{% include \"legacy/default\" %}".into())]); + assert!(validate_prompt_templates(&legacy).is_err()); + + let missing = BTreeMap::from([("a".into(), "{%- include \"missing\" -%}".into())]); + assert!(validate_prompt_templates(&missing).is_err()); + + let cycle = BTreeMap::from([ + ("a".into(), "{% include \"b\" %}".into()), + ("b".into(), "{% include \"a\" %}".into()), + ]); + assert!(validate_prompt_templates(&cycle).is_err()); } #[test] - fn memory_consolidation_prompt_describes_document_edit_policy_without_storage_details() { - let cat = PromptCatalog::builtins_only().unwrap(); - let consolidate = cat.memory_consolidation_system("Japanese").unwrap(); - assert!(consolidate.contains("Durable Memory is one Markdown document")); - assert!(consolidate.contains("existing `##` section")); - assert!(consolidate.contains("MemoryUpdateDocument")); - assert!(consolidate.contains("old_string")); - assert!(consolidate.contains("new_string")); - assert!(consolidate.contains("MemoryStagingClose")); - assert!(consolidate.contains("`applied`")); - assert!(consolidate.contains(r#"{"operation":"edit"}"#)); - assert!(consolidate.contains("Do not create Knowledge, Skill, Ticket")); - assert!(consolidate.contains("Do not create separate categorized Memory records")); - assert!(!consolidate.contains("SQLite")); - assert!(!consolidate.contains("SQL")); - assert!(!consolidate.contains("storage")); - assert!(!consolidate.contains("decision record")); - assert!(!consolidate.contains("request record")); - assert!(!consolidate.contains("summary record")); + fn workspace_projection_digest_is_stable_and_verified() { + let templates = builtin_prompt_templates().unwrap(); + let projection = EffectivePromptCatalog::new(templates, 42, "schema", "toolchain").unwrap(); + projection.verify_digest().unwrap(); + let mut tampered = projection.clone(); + tampered + .templates + .insert("default".into(), "tampered".into()); + assert!(matches!( + tampered.verify_digest(), + Err(CatalogError::DigestMismatch { .. }) + )); } #[test] - fn notify_wrapper_interpolates_message() { - let cat = PromptCatalog::builtins_only().unwrap(); - let out = cat.notify_wrapper("file changed").unwrap(); - assert!(out.contains("[Notification]")); - assert!(out.contains("file changed")); - assert!(out.contains("not a blocking request")); - } - - #[test] - fn working_boundaries_section_wraps_summary() { - let cat = PromptCatalog::builtins_only().unwrap(); - let out = cat.working_boundaries_section("Readable: /a").unwrap(); - assert!(out.contains("## Working boundaries")); - assert!(out.contains("Readable: /a")); - } - - #[test] - fn agents_md_section_contains_marker() { - let cat = PromptCatalog::builtins_only().unwrap(); - let out = cat.agents_md_section("PROJECT DOCS").unwrap(); - assert!(out.contains("## Project instructions (AGENTS.md)")); - assert!(out.contains("PROJECT DOCS")); - } - - #[test] - fn user_pack_overrides_builtin() { - let tmp = TempDir::new().unwrap(); - let pack = tmp.path().join("prompts.toml"); - fs::write( - &pack, - r#" -[prompt] -interrupt_system_note = "[OVERRIDDEN]" -"#, + fn catalog_source_preserves_workspace_projection_for_subworkers() { + let mut templates = builtin_prompt_templates().unwrap(); + templates.insert("common.workspace".into(), "CHILD OVERRIDE".into()); + let catalog = PromptCatalog::from_projection( + EffectivePromptCatalog::new(templates, 9, "schema", "toolchain").unwrap(), ) .unwrap(); - let loader = loader_with_packs(None, None, Some(pack), None); - let cat = PromptCatalog::load(&loader, None).unwrap(); - assert_eq!(cat.interrupt_system_note().unwrap(), "[OVERRIDDEN]"); - // Other keys still come from the builtin. - assert!(cat.notify_wrapper("x").unwrap().contains("[Notification]")); + let child = PromptCatalog::load(&catalog.source()).unwrap(); + assert_eq!(child.projection.config_revision, 9); + assert_eq!( + child.projection.templates["common.workspace"], + "CHILD OVERRIDE" + ); } #[test] - fn workspace_pack_wins_over_user_pack() { - let tmp = TempDir::new().unwrap(); - let user = tmp.path().join("user.toml"); - let ws = tmp.path().join("ws.toml"); - fs::write( - &user, - r#" -[prompt] -interrupt_system_note = "[USER]" -"#, - ) - .unwrap(); - fs::write( - &ws, - r#" -[prompt] -interrupt_system_note = "[WS]" -"#, - ) - .unwrap(); - let loader = loader_with_packs(None, None, Some(user), Some(ws)); - let cat = PromptCatalog::load(&loader, None).unwrap(); - assert_eq!(cat.interrupt_system_note().unwrap(), "[WS]"); + fn orchestrator_role_keeps_review_routing_owned_by_coder() { + let catalog = PromptCatalog::builtins_only().unwrap(); + let prompt = &catalog.projection.templates["role.orchestrator"]; + assert!(prompt.contains("assigned Coder owns its review/fix loop")); + assert!(prompt.contains("then use `SpawnTicketCoder`")); + assert!(prompt.contains("verify its current assignment names that Coder")); + assert!(prompt.contains("never route implementation to an unassigned Coder")); + assert!(prompt.contains( + "Do not spawn, restore, assign, or route work to Backend/Runtime Reviewer Workers" + )); + assert!(prompt.contains("never compensate by creating an independent Reviewer Worker")); + assert!(!prompt.contains("sibling Coder/Reviewer Workers")); } #[test] - fn manifest_pack_wins_over_workspace_pack() { - let tmp = TempDir::new().unwrap(); - let ws = tmp.path().join("ws.toml"); - let mf = tmp.path().join("mf.toml"); - fs::write( - &ws, - r#" -[prompt] -interrupt_system_note = "[WS]" -"#, - ) - .unwrap(); - fs::write( - &mf, - r#" -[prompt] -interrupt_system_note = "[MF]" -"#, - ) - .unwrap(); - let loader = loader_with_packs(None, None, None, Some(ws)); - let cat = PromptCatalog::load(&loader, Some(mf.as_path())).unwrap(); - assert_eq!(cat.interrupt_system_note().unwrap(), "[MF]"); - } - - #[test] - fn unknown_key_in_runtime_pack_is_ignored_with_warning() { - let tmp = TempDir::new().unwrap(); - let pack = tmp.path().join("p.toml"); - fs::write( - &pack, - r#" -[prompt] -interrupt_system_note = "[OK]" -future_injection_point = "tolerated" -"#, - ) - .unwrap(); - let loader = loader_with_packs(None, None, Some(pack), None); - // Loads without error; the unknown key is dropped silently at - // runtime (log warning is emitted via tracing). - let cat = PromptCatalog::load(&loader, None).unwrap(); - assert_eq!(cat.interrupt_system_note().unwrap(), "[OK]"); - } - - #[test] - fn manifest_pack_reads_from_absolute_path() { - let tmp = TempDir::new().unwrap(); - let pack = tmp.path().join("mine.toml"); - fs::write( - &pack, - r#" -[prompt] -interrupt_system_note = "[FROM-MANIFEST-PACK]" -"#, - ) - .unwrap(); - let loader = PromptLoader::builtins_only(); - let cat = PromptCatalog::load(&loader, Some(pack.as_path())).unwrap(); - assert_eq!(cat.interrupt_system_note().unwrap(), "[FROM-MANIFEST-PACK]"); - } - - #[test] - fn value_can_pull_long_text_via_include() { - // A runtime pack that overrides `compact_system` with an - // `{% include %}` into the same `$yoi` namespace — exercises - // the template resolver path through all four layers. - let tmp = TempDir::new().unwrap(); - let pack = tmp.path().join("p.toml"); - fs::write( - &pack, - r#" -[prompt] -compact_system = "PREFIX\n{% include \"$yoi/internal/compact_system\" %}" -"#, - ) - .unwrap(); - let loader = loader_with_packs(None, None, Some(pack), None); - let cat = PromptCatalog::load(&loader, None).unwrap(); - let rendered = cat.compact_system().unwrap(); - assert!(rendered.starts_with("PREFIX\n")); - assert!(rendered.contains("write_summary")); - } - - #[test] - fn worker_orchestration_guidance_section_renders_resource_body() { - let cat = PromptCatalog::builtins_only().unwrap(); - let rendered = cat.worker_orchestration_guidance_section().unwrap(); - assert!(rendered.contains("## SubWorker orchestration")); - assert!(rendered.contains("SubWorker notifications are background signals")); - assert!(rendered.contains("does not need to keep a turn open")); - assert!(rendered.contains("Do not use `sleep` or polling loops")); - assert!(rendered.contains("worktree state, diff, and test results")); - assert!(rendered.contains("not scheduler or auto-maintain authorization")); - assert!(rendered.contains("bypass user/Ticket authorization")); - } - - #[test] - fn orchestrator_role_prompt_fences_worker_remove_authority() { - let source = include_str!("../../../../resources/prompts/role/orchestrator.md"); - assert!(source.contains("Use `WorkerRemove` only for a terminal or authoritatively reassigned non-internal Coder")); - assert!(source.contains("exact current `updated_at` value")); - assert!(source.contains("must have no current Ticket assignment")); - assert!(source.contains("pending notification, Reviewer handoff, legal hold, or pin")); - assert!(source.contains("After removal, reread the Worker catalog and attachment state")); - assert!(source.contains("attachment-close, and attachment-release conflicts")); - assert!(source.contains("preserves the Workdir materialization")); - assert!(!source.contains("source proof")); - assert!(!source.contains("provider handle")); - assert!(!source.contains("retention plan")); - } - - #[test] - fn sub_worker_spawn_tool_description_renders_profile_block() { - let cat = PromptCatalog::builtins_only().unwrap(); - let rendered = cat - .sub_worker_spawn_tool_description( - "- `project:coder` — Coder\n- `project:reviewer` — Reviewer", - "project:coder", - "", - ) - .unwrap(); - assert!(rendered.contains("Profile selection")); - assert!(rendered.contains("Default profile: project:coder")); - assert!(rendered.contains("`project:reviewer`")); - assert!(rendered.contains("Special selector: inherit")); + fn existing_internal_prompt_render_contracts_are_preserved() { + let catalog = PromptCatalog::builtins_only().unwrap(); + assert!(catalog.compact_system().unwrap().contains("write_summary")); + assert!( + catalog + .memory_extract_system("Japanese") + .unwrap() + .contains("`language`: `Japanese`") + ); + assert!( + catalog + .notify_wrapper("changed") + .unwrap() + .contains("changed") + ); + assert!( + catalog + .working_boundaries_section("Readable: /a") + .unwrap() + .contains("Readable: /a") + ); + assert!( + catalog + .worker_orchestration_guidance_section() + .unwrap() + .contains("## SubWorker orchestration") + ); } } diff --git a/crates/worker/src/prompt/loader.rs b/crates/worker/src/prompt/loader.rs deleted file mode 100644 index d65b118b..00000000 --- a/crates/worker/src/prompt/loader.rs +++ /dev/null @@ -1,425 +0,0 @@ -//! Prefix-addressed prompt asset loader used by [`crate::SystemPromptTemplate`]. -//! -//! Three prefixes address three physical libraries: -//! -//! | prefix | location | -//! |--------------|---------------------------------------------------------| -//! | `$yoi` | builtin, baked into the binary via `include_dir!` | -//! | `$user` | `/prompts/` (resolved by `manifest::paths`) | -//! | `$workspace` | `/.yoi/prompts/` | -//! -//! A reference is `$/` where `` is a `/`-separated -//! name without the `.md` extension (e.g. `$yoi/common/header`). -//! Unqualified names (no `$prefix/` at the front) are resolved relative -//! to an optional current reference — typically the file that issued -//! the `{% include %}` — so a prompt library can be authored as a -//! self-contained directory. -//! -//! Missing files produce a [`LoaderError::NotFound`]; there is no -//! fallthrough between layers. - -use std::path::{Path, PathBuf}; - -use include_dir::{Dir, include_dir}; -use thiserror::Error; - -static BUILTIN_PROMPTS: Dir<'static> = include_dir!("$CARGO_MANIFEST_DIR/../../resources/prompts"); - -const PREFIX_YOI: &str = "$yoi"; -const PREFIX_USER: &str = "$user"; -const PREFIX_WORKSPACE: &str = "$workspace"; - -/// Prefix-resolved reference to a prompt asset. Produced by -/// [`PromptLoader::parse_ref`] from a user-supplied string such as -/// `"$yoi/default"`. -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct PromptRef { - prefix: Prefix, - /// Relative path under the prefix root, without the `.md` extension. - /// `/`-separated, never empty, never starts with `/`. - path: String, -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -enum Prefix { - Yoi, - User, - Workspace, -} - -impl Prefix { - fn as_str(self) -> &'static str { - match self { - Self::Yoi => PREFIX_YOI, - Self::User => PREFIX_USER, - Self::Workspace => PREFIX_WORKSPACE, - } - } -} - -impl PromptRef { - /// Produce a canonical `$prefix/path` string. - pub fn to_qualified_string(&self) -> String { - format!("{}/{}", self.prefix.as_str(), self.path) - } - - /// Directory portion (leading prefix segments minus the file name), - /// joined with `/`. Returns an empty string when the ref points at - /// a file directly under the prefix root. - fn dir(&self) -> &str { - match self.path.rsplit_once('/') { - Some((dir, _)) => dir, - None => "", - } - } -} - -/// Errors produced when resolving a [`PromptRef`]. -#[derive(Debug, Error)] -pub enum LoaderError { - #[error("invalid prompt reference '{raw}': {reason}")] - InvalidRef { raw: String, reason: String }, - #[error("unknown prompt prefix '{prefix}' in reference '{raw}'")] - UnknownPrefix { raw: String, prefix: String }, - #[error( - "unqualified prompt reference '{raw}' requires a current prefix \ - (include it from inside another template, or use an explicit \ - $prefix/path form)" - )] - UnqualifiedWithoutCurrent { raw: String }, - #[error("prompt prefix '{prefix}' is not configured for this loader")] - PrefixNotConfigured { prefix: &'static str }, - #[error("prompt asset not found: '{}'", .reference.to_qualified_string())] - NotFound { reference: PromptRef }, - #[error("failed to read prompt asset '{}': {source}", .reference.to_qualified_string())] - Io { - reference: PromptRef, - #[source] - source: std::io::Error, - }, -} - -/// Loader that resolves [`PromptRef`]s against the configured prompt -/// libraries. Cheap to clone. -/// -/// Also carries the auto-discovered `prompts.toml` pack file paths so -/// [`crate::prompt::catalog::PromptCatalog`] can read the same user/workspace -/// layers without a separate plumbing channel. These fields do not -/// affect `$prefix` asset resolution — they are purely metadata -/// consulted by the catalog loader. -#[derive(Debug, Clone)] -pub struct PromptLoader { - user_dir: Option, - workspace_dir: Option, - user_pack_file: Option, - workspace_pack_file: Option, -} - -impl PromptLoader { - /// Loader with only the builtin `$yoi` library available. - /// `$user` / `$workspace` references fail with - /// [`LoaderError::PrefixNotConfigured`]. - pub fn builtins_only() -> Self { - Self { - user_dir: None, - workspace_dir: None, - user_pack_file: None, - workspace_pack_file: None, - } - } - - /// Loader with optional user and workspace prompt directories. - pub fn new(user_dir: Option, workspace_dir: Option) -> Self { - Self { - user_dir, - workspace_dir, - user_pack_file: None, - workspace_pack_file: None, - } - } - - /// Override pack file paths supplied by the caller's profile/manifest - /// resolution context. - pub fn with_pack_files( - mut self, - user_pack_file: Option, - workspace_pack_file: Option, - ) -> Self { - self.user_pack_file = user_pack_file; - self.workspace_pack_file = workspace_pack_file; - self - } - - /// Root of the `$user` prompt library, if configured. - pub fn user_dir(&self) -> Option<&Path> { - self.user_dir.as_deref() - } - - /// Root of the `$workspace` prompt library, if configured. - pub fn workspace_dir(&self) -> Option<&Path> { - self.workspace_dir.as_deref() - } - - /// Auto-discovered path to the user-layer `prompts.toml` pack, if any. - pub fn user_pack_file(&self) -> Option<&Path> { - self.user_pack_file.as_deref() - } - - /// Auto-discovered path to the workspace-layer `prompts.toml` pack, if any. - pub fn workspace_pack_file(&self) -> Option<&Path> { - self.workspace_pack_file.as_deref() - } - - /// Parse a string reference into a [`PromptRef`]. Unqualified - /// references (no leading `$prefix/`) are resolved against - /// `current`: the prefix is inherited, and the path is joined to - /// the current ref's directory. - pub fn parse_ref( - &self, - raw: &str, - current: Option<&PromptRef>, - ) -> Result { - let trimmed = raw.trim(); - if trimmed.is_empty() { - return Err(LoaderError::InvalidRef { - raw: raw.to_string(), - reason: "reference must not be empty".into(), - }); - } - if let Some(prefix) = trimmed.strip_prefix('$') { - let (prefix_name, rest) = - prefix - .split_once('/') - .ok_or_else(|| LoaderError::InvalidRef { - raw: raw.to_string(), - reason: "prefix must be followed by '/'".into(), - })?; - let prefix = parse_prefix(raw, prefix_name)?; - let path = normalize_path(raw, rest)?; - Ok(PromptRef { prefix, path }) - } else { - let Some(current) = current else { - return Err(LoaderError::UnqualifiedWithoutCurrent { - raw: raw.to_string(), - }); - }; - let dir = current.dir(); - let joined = if dir.is_empty() { - trimmed.to_string() - } else { - format!("{dir}/{trimmed}") - }; - let path = normalize_path(raw, &joined)?; - Ok(PromptRef { - prefix: current.prefix, - path, - }) - } - } - - /// Resolve a [`PromptRef`] to its raw template source. Hard-errors - /// when the prefix is not configured or the file does not exist. - pub fn load(&self, reference: &PromptRef) -> Result { - match reference.prefix { - Prefix::Yoi => load_from_include_dir(&BUILTIN_PROMPTS, reference), - Prefix::User => match self.user_dir.as_deref() { - Some(dir) => load_from_dir(dir, reference), - None => Err(LoaderError::PrefixNotConfigured { - prefix: PREFIX_USER, - }), - }, - Prefix::Workspace => match self.workspace_dir.as_deref() { - Some(dir) => load_from_dir(dir, reference), - None => Err(LoaderError::PrefixNotConfigured { - prefix: PREFIX_WORKSPACE, - }), - }, - } - } - - /// Parse `raw` against `current`, then load the resulting ref. - /// Convenience wrapper for the minijinja loader hook. - pub fn resolve( - &self, - raw: &str, - current: Option<&PromptRef>, - ) -> Result<(PromptRef, String), LoaderError> { - let reference = self.parse_ref(raw, current)?; - let source = self.load(&reference)?; - Ok((reference, source)) - } -} - -fn parse_prefix(raw: &str, prefix_name: &str) -> Result { - match prefix_name { - "yoi" => Ok(Prefix::Yoi), - "user" => Ok(Prefix::User), - "workspace" => Ok(Prefix::Workspace), - _ => Err(LoaderError::UnknownPrefix { - raw: raw.to_string(), - prefix: format!("${prefix_name}"), - }), - } -} - -fn normalize_path(raw: &str, rest: &str) -> Result { - let cleaned = rest.trim_matches('/').trim(); - if cleaned.is_empty() { - return Err(LoaderError::InvalidRef { - raw: raw.to_string(), - reason: "path component must not be empty".into(), - }); - } - if cleaned.split('/').any(|seg| seg == "." || seg == "..") { - return Err(LoaderError::InvalidRef { - raw: raw.to_string(), - reason: "path must not contain '.' or '..' segments".into(), - }); - } - Ok(cleaned.to_string()) -} - -fn load_from_dir(dir: &Path, reference: &PromptRef) -> Result { - let path = dir.join(format!("{}.md", reference.path)); - match std::fs::read_to_string(&path) { - Ok(s) => Ok(s), - Err(e) if e.kind() == std::io::ErrorKind::NotFound => Err(LoaderError::NotFound { - reference: reference.clone(), - }), - Err(source) => Err(LoaderError::Io { - reference: reference.clone(), - source, - }), - } -} - -fn load_from_include_dir(dir: &Dir<'static>, reference: &PromptRef) -> Result { - let path = format!("{}.md", reference.path); - dir.get_file(&path) - .and_then(|f| f.contents_utf8()) - .map(|s| s.to_string()) - .ok_or_else(|| LoaderError::NotFound { - reference: reference.clone(), - }) -} - -#[cfg(test)] -mod tests { - use super::*; - use tempfile::TempDir; - - #[test] - fn builtin_default_resolves() { - let loader = PromptLoader::builtins_only(); - let (r, source) = loader.resolve("$yoi/default", None).unwrap(); - assert_eq!(r.to_qualified_string(), "$yoi/default"); - assert!(!source.is_empty()); - } - - #[test] - fn builtin_ticket_role_instructions_resolve() { - let loader = PromptLoader::builtins_only(); - for role in ["intake", "orchestrator", "coder", "reviewer"] { - let (reference, source) = loader.resolve(&format!("$yoi/role/{role}"), None).unwrap(); - assert_eq!(reference.to_qualified_string(), format!("$yoi/role/{role}")); - assert!(source.contains("first committed user message")); - } - } - - #[test] - fn builtin_subdirectory_lookup() { - let loader = PromptLoader::builtins_only(); - let (_, source) = loader.resolve("$yoi/common/tool-usage", None).unwrap(); - assert!(source.contains("tool")); - } - - #[test] - fn user_prefix_resolves() { - let tmp = TempDir::new().unwrap(); - let user_dir = tmp.path().to_path_buf(); - std::fs::write(user_dir.join("my.md"), "user-body").unwrap(); - let loader = PromptLoader::new(Some(user_dir), None); - let (_, source) = loader.resolve("$user/my", None).unwrap(); - assert_eq!(source, "user-body"); - } - - #[test] - fn workspace_prefix_resolves() { - let tmp = TempDir::new().unwrap(); - let ws_dir = tmp.path().to_path_buf(); - std::fs::write(ws_dir.join("custom.md"), "ws-body").unwrap(); - let loader = PromptLoader::new(None, Some(ws_dir)); - let (_, source) = loader.resolve("$workspace/custom", None).unwrap(); - assert_eq!(source, "ws-body"); - } - - #[test] - fn missing_file_is_hard_error() { - let loader = PromptLoader::builtins_only(); - let err = loader.resolve("$yoi/definitely-missing", None).unwrap_err(); - assert!(matches!(err, LoaderError::NotFound { .. })); - } - - #[test] - fn user_prefix_not_configured_errors() { - let loader = PromptLoader::builtins_only(); - let err = loader.resolve("$user/my", None).unwrap_err(); - assert!(matches!( - err, - LoaderError::PrefixNotConfigured { prefix: "$user" } - )); - } - - #[test] - fn unknown_prefix_errors() { - let loader = PromptLoader::builtins_only(); - let err = loader.resolve("$bogus/x", None).unwrap_err(); - assert!(matches!(err, LoaderError::UnknownPrefix { .. })); - } - - #[test] - fn unqualified_ref_without_current_errors() { - let loader = PromptLoader::builtins_only(); - let err = loader.resolve("default", None).unwrap_err(); - assert!(matches!(err, LoaderError::UnqualifiedWithoutCurrent { .. })); - } - - #[test] - fn unqualified_ref_resolves_relative_to_current() { - let loader = PromptLoader::builtins_only(); - let current = loader.parse_ref("$yoi/common/tool-usage", None).unwrap(); - // Sibling lookup under the same prefix and directory. - let sibling = loader.parse_ref("workspace", Some(¤t)).unwrap(); - assert_eq!(sibling.to_qualified_string(), "$yoi/common/workspace"); - } - - #[test] - fn unqualified_ref_from_root_file_has_empty_dir() { - let loader = PromptLoader::builtins_only(); - let current = loader.parse_ref("$yoi/default", None).unwrap(); - let sibling = loader.parse_ref("other", Some(¤t)).unwrap(); - assert_eq!(sibling.to_qualified_string(), "$yoi/other"); - } - - #[test] - fn explicit_prefix_overrides_current() { - let tmp = TempDir::new().unwrap(); - let user_dir = tmp.path().to_path_buf(); - std::fs::write(user_dir.join("custom.md"), "user-body").unwrap(); - let loader = PromptLoader::new(Some(user_dir), None); - - let current = loader.parse_ref("$yoi/default", None).unwrap(); - // Even with an $yoi-rooted current, an explicit $user - // prefix must win. - let (reference, source) = loader.resolve("$user/custom", Some(¤t)).unwrap(); - assert_eq!(reference.to_qualified_string(), "$user/custom"); - assert_eq!(source, "user-body"); - } - - #[test] - fn traversal_segments_rejected() { - let loader = PromptLoader::builtins_only(); - let err = loader.resolve("$yoi/../etc/passwd", None).unwrap_err(); - assert!(matches!(err, LoaderError::InvalidRef { .. })); - } -} diff --git a/crates/worker/src/prompt/mod.rs b/crates/worker/src/prompt/mod.rs index ae52e7ca..c9800a3b 100644 --- a/crates/worker/src/prompt/mod.rs +++ b/crates/worker/src/prompt/mod.rs @@ -1,4 +1,4 @@ pub(crate) mod agents_md; pub(crate) mod catalog; -pub(crate) mod loader; +pub(crate) mod source; pub(crate) mod system; diff --git a/crates/worker/src/prompt/source.rs b/crates/worker/src/prompt/source.rs new file mode 100644 index 00000000..fed08a16 --- /dev/null +++ b/crates/worker/src/prompt/source.rs @@ -0,0 +1,30 @@ +//! Immutable effective Prompt catalog carrier. +//! +//! Prompt sources are resolved and evaluated by Workspace config authority. +//! This type carries only the already-materialized projection into Worker +//! construction; it performs no filesystem, prefix, relative-path, user, or +//! repository discovery. + +use std::sync::Arc; + +use super::catalog::EffectivePromptCatalog; + +#[derive(Debug, Clone, Default)] +pub struct PromptCatalogSource { + effective_catalog: Option>, +} + +impl PromptCatalogSource { + pub fn builtins_only() -> Self { + Self::default() + } + + pub fn with_effective_catalog(mut self, catalog: EffectivePromptCatalog) -> Self { + self.effective_catalog = Some(Arc::new(catalog)); + self + } + + pub fn effective_catalog(&self) -> Option<&EffectivePromptCatalog> { + self.effective_catalog.as_deref() + } +} diff --git a/crates/worker/src/prompt/system.rs b/crates/worker/src/prompt/system.rs index df5c4950..d043b703 100644 --- a/crates/worker/src/prompt/system.rs +++ b/crates/worker/src/prompt/system.rs @@ -3,7 +3,7 @@ //! Manifests describe the system prompt body as a reference to a //! prompt asset (`worker.instruction`, see [`manifest::EngineManifest`]). //! [`SystemPromptTemplate`] resolves that reference through a -//! [`PromptLoader`], parses the source as a minijinja template, and +//! [`PromptCatalogSource`], parses the source as a minijinja template, and //! eagerly syntax-checks it at Worker construction. The final system //! prompt is materialised exactly once just before the first LLM turn: //! the rendered body is appended with a fixed trailing section carrying @@ -22,17 +22,16 @@ use std::sync::Arc; use chrono::{DateTime, SecondsFormat, Utc}; use manifest::Scope; use minijinja::value::Value; -use minijinja::{Environment, ErrorKind, UndefinedBehavior}; use thiserror::Error; use crate::feature::{FeatureInstructionDeclaration, dedupe_instruction_contributions}; use crate::prompt::catalog::{CatalogError, PromptCatalog}; -use crate::prompt::loader::{LoaderError, PromptLoader, PromptRef}; +#[cfg(test)] +use crate::prompt::catalog::{EffectivePromptCatalog, builtin_prompt_templates}; +use crate::prompt::source::PromptCatalogSource; #[derive(Debug, Error)] pub enum SystemPromptError { - #[error("failed to resolve instruction reference: {0}")] - LoaderResolve(#[source] LoaderError), #[error("system prompt template parse error: {0}")] Parse(String), #[error("system prompt template render error: {0}")] @@ -41,69 +40,37 @@ pub enum SystemPromptError { Catalog(#[from] CatalogError), } -/// Parsed instruction template bound to a prompt loader. -/// -/// Holds a minijinja Environment pre-populated with the instruction -/// template registered under its fully-qualified name (`$prefix/path`). -/// Includes are resolved via the loader using a path-join callback that -/// tracks the including template's prefix and directory, so -/// `{% include "sibling" %}` fragments work as expected. +/// Parsed instruction template bound to one immutable effective Prompt catalog. #[derive(Clone)] pub struct SystemPromptTemplate { - env: Arc>, + catalog: Arc, instruction_name: String, } impl SystemPromptTemplate { - /// Parse the instruction asset referenced by `instruction_ref` - /// using the supplied [`PromptLoader`]. The reference is resolved - /// at parse time so syntax errors surface immediately. - pub fn parse(instruction_ref: &str, loader: PromptLoader) -> Result { - let root_ref = loader - .parse_ref(instruction_ref, None) - .map_err(SystemPromptError::LoaderResolve)?; - let source = loader - .load(&root_ref) - .map_err(SystemPromptError::LoaderResolve)?; - let root_name = root_ref.to_qualified_string(); - - let mut env = Environment::new(); - env.set_undefined_behavior(UndefinedBehavior::Strict); - - // Path-join callback: compute the target template name when a - // template includes another by a possibly-unqualified string. - // The joined name is then looked up via `set_loader` below. - let loader_for_join = loader.clone(); - env.set_path_join_callback(move |name, parent| { - let parent_ref = loader_for_join.parse_ref(parent, None).ok(); - match loader_for_join.parse_ref(name, parent_ref.as_ref()) { - Ok(r) => r.to_qualified_string().into(), - // Propagate the raw name on error so set_loader surfaces - // a proper TemplateNotFound/LoaderError to the caller. - Err(_) => name.to_string().into(), - } - }); - - let loader_for_src = loader.clone(); - env.set_loader(move |name| { - let reference = loader_for_src - .parse_ref(name, None) - .map_err(|e| minijinja::Error::new(ErrorKind::TemplateNotFound, e.to_string()))?; - match loader_for_src.load(&reference) { - Ok(source) => Ok(Some(source)), - Err(e) => Err(minijinja::Error::new( - ErrorKind::TemplateNotFound, - e.to_string(), - )), - } - }); - - env.add_template_owned(root_name.clone(), source) - .map_err(|e| SystemPromptError::Parse(e.to_string()))?; - + /// Resolve an exact catalog-root dotted Prompt name and eagerly verify it. + pub fn parse( + instruction_ref: &str, + loader: PromptCatalogSource, + ) -> Result { + let instruction_name = exact_prompt_name(instruction_ref).ok_or_else(|| { + SystemPromptError::Parse(format!( + "instruction must be an exact catalog-root dotted Prompt name: {instruction_ref}" + )) + })?; + let catalog = if let Some(projection) = loader.effective_catalog() { + Arc::new(PromptCatalog::from_projection(projection.clone())?) + } else { + PromptCatalog::builtins_only()? + }; + if !catalog.contains(&instruction_name) { + return Err(SystemPromptError::Parse(format!( + "Prompt '{instruction_name}' is not present in the effective catalog" + ))); + } Ok(Self { - env: Arc::new(env), - instruction_name: root_name, + catalog, + instruction_name, }) } @@ -112,18 +79,14 @@ impl SystemPromptTemplate { /// section is assembled in Rust so that authored templates cannot /// accidentally omit the scope boundary or the project instructions. pub fn render(&self, ctx: &SystemPromptContext<'_>) -> Result { - let tmpl = self - .env - .get_template(&self.instruction_name) - .map_err(|e| SystemPromptError::Render(e.to_string()))?; - let body = tmpl - .render(ctx.to_minijinja_value()) - .map_err(|e| SystemPromptError::Render(e.to_string()))?; + let body = self + .catalog + .render_name(&self.instruction_name, ctx.to_minijinja_value()) + .map_err(|error| SystemPromptError::Render(error.to_string()))?; append_trailing_section( &body, - &self.env, ctx, - ctx.prompts, + &self.catalog, ctx.scope, ctx.agents_md.as_deref(), ctx.resident_summary, @@ -273,6 +236,22 @@ impl ToolCapabilities { } } +fn exact_prompt_name(reference: &str) -> Option { + let candidate = reference.to_string(); + if candidate.is_empty() + || candidate.split('.').any(|segment| { + segment.is_empty() + || !segment.chars().all(|character| { + character.is_ascii_lowercase() || character.is_ascii_digit() || character == '_' + }) + }) + { + None + } else { + Some(candidate) + } +} + /// Build the final system prompt by appending the fixed trailing /// section to `body`. The Rust side owns the layout (blank-line /// separators, trailing-whitespace trim); each section's header + body @@ -281,7 +260,6 @@ impl ToolCapabilities { /// per-pack without touching this function. fn append_trailing_section( body: &str, - env: &Environment<'static>, ctx: &SystemPromptContext<'_>, prompts: &PromptCatalog, scope: &Scope, @@ -315,12 +293,15 @@ fn append_trailing_section( } for instruction in dedupe_instruction_contributions(ctx.feature_instructions.iter().cloned()) { out.push('\n'); - let template = env - .get_template(&instruction.prompt_ref) - .map_err(|e| SystemPromptError::Render(e.to_string()))?; - let section = template - .render(ctx.to_minijinja_value()) - .map_err(|e| SystemPromptError::Render(e.to_string()))?; + let prompt_ref = exact_prompt_name(&instruction.prompt_ref).ok_or_else(|| { + SystemPromptError::Render(format!( + "feature instruction must be an exact catalog-root dotted Prompt name: {}", + instruction.prompt_ref + )) + })?; + let section = prompts + .render_name(&prompt_ref, ctx.to_minijinja_value()) + .map_err(|error| SystemPromptError::Render(error.to_string()))?; let section = section.trim_end_matches(&['\n', ' '][..]); if !section.trim().is_empty() { out.push_str(section); @@ -335,13 +316,6 @@ fn append_trailing_section( Ok(out) } -/// Bridge used by [`Worker::ensure_system_prompt_materialized`] so tests -/// can construct a synthetic context without going through a full Worker. -#[doc(hidden)] -pub fn __instruction_ref_for_tests(raw: &str, loader: &PromptLoader) -> Option { - loader.parse_ref(raw, None).ok() -} - #[cfg(test)] mod tests { use super::*; @@ -354,487 +328,91 @@ mod tests { } fn build_scope(dir: &Path) -> Scope { - let cfg = ScopeConfig { + Scope::from_config(&ScopeConfig { allow: vec![ScopeRule { target: dir.to_path_buf(), permission: Permission::Write, recursive: true, }], deny: Vec::new(), - }; - Scope::from_config(&cfg).unwrap() - } - - fn ctx<'a>( - cwd: &'a Path, - scope: &'a Scope, - tools: Vec, - agents_md: Option, - ) -> SystemPromptContext<'a> { - SystemPromptContext { - now: fixed_now(), - cwd: cwd.display().to_string().into(), - language: manifest::defaults::WORKER_LANGUAGE, - scope, - tool_names: tools, - feature_instructions: &[], - agents_md, - resident_summary: None, - prompts: test_prompts(), - } - } - - fn ctx_with_summary<'a>( - cwd: &'a Path, - scope: &'a Scope, - summary: Option<&'a str>, - ) -> SystemPromptContext<'a> { - SystemPromptContext { - now: fixed_now(), - cwd: cwd.display().to_string().into(), - language: manifest::defaults::WORKER_LANGUAGE, - scope, - tool_names: Vec::new(), - feature_instructions: &[], - agents_md: None, - resident_summary: summary, - prompts: test_prompts(), - } - } - - fn memory_tool_names() -> Vec { - ["MemoryQuery", "MemoryReadDocument", "MemoryUpdateDocument"] - .into_iter() - .map(String::from) - .collect() - } - - fn ticket_instruction() -> FeatureInstructionDeclaration { - FeatureInstructionDeclaration::new( - crate::feature::FeatureInstructionId::builtin("ticket.workflow"), - "$yoi/common/tickets", - "Ticket workflow guidance", - ) + }) .unwrap() } - fn sub_worker_orchestration_instruction() -> FeatureInstructionDeclaration { - FeatureInstructionDeclaration::new( - crate::feature::FeatureInstructionId::builtin("worker.orchestration"), - "$yoi/common/worker-orchestration", - "Worker orchestration guidance", - ) - .unwrap() + fn context<'a>( + cwd: &'a Path, + scope: &'a Scope, + prompts: &'a PromptCatalog, + ) -> SystemPromptContext<'a> { + SystemPromptContext { + now: fixed_now(), + cwd: cwd.to_string_lossy(), + tool_names: vec!["Read".into(), "Write".into()], + scope, + agents_md: Some("PROJECT RULES".into()), + resident_summary: Some("DURABLE MEMORY"), + language: "Japanese", + feature_instructions: &[], + prompts, + } } - /// Lazily-initialised builtin catalog shared across system-prompt - /// tests, so every `ctx()` can hand out a `&'static PromptCatalog` - /// reference without forcing test bodies to create one per call. - fn test_prompts() -> &'static PromptCatalog { - use std::sync::OnceLock; - static CELL: OnceLock> = OnceLock::new(); - CELL.get_or_init(|| PromptCatalog::builtins_only().unwrap()) - .as_ref() - } - - fn user_loader_with(file_name: &str, body: &str) -> (TempDir, PromptLoader) { + #[test] + fn exact_catalog_name_renders_once_with_trailing_sections() { let tmp = TempDir::new().unwrap(); - std::fs::write(tmp.path().join(file_name), body).unwrap(); - let loader = PromptLoader::new(Some(tmp.path().to_path_buf()), None); - (tmp, loader) - } - - #[test] - fn instruction_default_resolves_to_yoi_default() { - let loader = PromptLoader::builtins_only(); - let tmpl = SystemPromptTemplate::parse("$yoi/default", loader).unwrap(); - let dir = TempDir::new().unwrap(); - let scope = build_scope(dir.path()); - let rendered = tmpl - .render(&ctx(dir.path(), &scope, memory_tool_names(), None)) + let scope = build_scope(tmp.path()); + let prompts = PromptCatalog::builtins_only().unwrap(); + let template = + SystemPromptTemplate::parse("default", PromptCatalogSource::builtins_only()).unwrap(); + let rendered = template + .render(&context(tmp.path(), &scope, &prompts)) .unwrap(); - // Builtin default body must expose the tool and language policies. - assert!(rendered.contains("### Memory")); - assert!(rendered.contains("small targeted `MemoryQuery`")); - assert!(rendered.contains("Strong lookup triggers include")); - assert!(rendered.contains("MemoryReadDocument")); - assert!(rendered.contains("Do not query memory every turn")); - assert!(rendered.contains("MemoryUpdateDocument")); - assert!(rendered.contains("## Language")); - assert!(rendered.contains("`language`: `match the user's language")); - // Trailing section must be present. + assert!(rendered.contains("2026-08-14") || rendered.contains("2026-04-15")); assert!(rendered.contains("## Working boundaries")); - assert!(rendered.contains("Readable:")); + assert!(rendered.contains("PROJECT RULES")); + assert!(rendered.contains("DURABLE MEMORY")); } #[test] - fn instruction_default_omits_memory_guidance_without_memory_tools() { - let loader = PromptLoader::builtins_only(); - let tmpl = SystemPromptTemplate::parse("$yoi/default", loader).unwrap(); - let dir = TempDir::new().unwrap(); - let scope = build_scope(dir.path()); - let rendered = tmpl - .render(&ctx( - dir.path(), - &scope, - vec!["Read".into(), "Edit".into()], - None, - )) + fn workspace_override_is_visible_through_builtin_static_include() { + let mut templates = builtin_prompt_templates().unwrap(); + templates.insert("common.workspace".into(), "WORKSPACE OVERRIDE".into()); + let projection = EffectivePromptCatalog::new(templates, 9, "schema", "toolchain").unwrap(); + let loader = + PromptCatalogSource::builtins_only().with_effective_catalog(projection.clone()); + let prompts = PromptCatalog::from_projection(projection).unwrap(); + let template = SystemPromptTemplate::parse("default", loader).unwrap(); + let tmp = TempDir::new().unwrap(); + let scope = build_scope(tmp.path()); + let rendered = template + .render(&context(tmp.path(), &scope, &prompts)) .unwrap(); - - assert!(!rendered.contains("### Memory")); - assert!(!rendered.contains("MemoryQuery")); - assert!(!rendered.contains("MemoryRead")); - assert!(!rendered.contains("MemoryWrite")); - assert!(!rendered.contains("MemoryEdit")); - assert!(!rendered.contains("MemoryDelete")); - assert!(rendered.contains("## Language")); - assert!(rendered.contains("## Working boundaries")); + assert!(rendered.contains("WORKSPACE OVERRIDE")); } #[test] - fn ticket_guidance_is_included_for_ticket_feature_instruction() { - let loader = PromptLoader::builtins_only(); - let tmpl = SystemPromptTemplate::parse("$yoi/default", loader).unwrap(); - let dir = TempDir::new().unwrap(); - let scope = build_scope(dir.path()); - let instructions = [ticket_instruction()]; - let mut ctx = ctx(dir.path(), &scope, vec!["Read".into()], None); - ctx.feature_instructions = &instructions; - let rendered = tmpl.render(&ctx).unwrap(); - - assert!(rendered.contains("## Ticket workflow")); - assert!(rendered.contains("available typed Ticket tools as the authority")); - assert!(rendered.contains("Do not invoke a Ticket CLI")); - assert!(rendered.contains("Distinguish implementation completion")); - } - - #[test] - fn feature_instruction_is_appended_even_when_template_does_not_include_it() { - let (_tmp, loader) = user_loader_with("minimal.md", "BASE ONLY"); - let tmpl = SystemPromptTemplate::parse("$user/minimal", loader).unwrap(); - let dir = TempDir::new().unwrap(); - let scope = build_scope(dir.path()); - let instructions = [ticket_instruction()]; - let mut ctx = ctx(dir.path(), &scope, vec![], None); - ctx.feature_instructions = &instructions; - let rendered = tmpl.render(&ctx).unwrap(); - - assert!(rendered.starts_with("BASE ONLY")); - assert!(rendered.contains("## Ticket workflow")); - } - - #[test] - fn ticket_guidance_is_omitted_without_ticket_tools() { - let loader = PromptLoader::builtins_only(); - let tmpl = SystemPromptTemplate::parse("$yoi/default", loader).unwrap(); - let dir = TempDir::new().unwrap(); - let scope = build_scope(dir.path()); - let rendered = tmpl - .render(&ctx( - dir.path(), - &scope, - vec!["Read".into(), "Edit".into()], - None, - )) - .unwrap(); - - assert!(!rendered.contains("## Ticket workflow")); - assert!(!rendered.contains("Do not invoke a Ticket CLI")); - } - - #[test] - fn ticket_role_instructions_include_feature_ticket_guidance() { - let loader = PromptLoader::builtins_only(); - let dir = TempDir::new().unwrap(); - let scope = build_scope(dir.path()); - let instructions = [ticket_instruction()]; - - for role in ["intake", "orchestrator", "coder", "reviewer"] { - let tmpl = - SystemPromptTemplate::parse(&format!("$yoi/role/{role}"), loader.clone()).unwrap(); - let mut ctx = ctx(dir.path(), &scope, vec!["Read".into()], None); - ctx.feature_instructions = &instructions; - let rendered = tmpl.render(&ctx).unwrap(); - - assert!(rendered.contains("## Ticket workflow"), "role: {role}"); + fn rejects_legacy_prefix_relative_and_missing_names() { + for reference in ["legacy/custom", "custom.md", "../custom", "missing"] { assert!( - rendered.contains("Do not invoke a Ticket CLI"), - "role: {role}" + SystemPromptTemplate::parse(reference, PromptCatalogSource::builtins_only()) + .is_err() ); } } #[test] - fn memory_guidance_names_only_available_memory_tools() { - let loader = PromptLoader::builtins_only(); - let tmpl = SystemPromptTemplate::parse("$yoi/default", loader).unwrap(); - let dir = TempDir::new().unwrap(); - let scope = build_scope(dir.path()); - let rendered = tmpl - .render(&ctx( - dir.path(), - &scope, - vec!["MemoryQuery".into(), "MemoryReadDocument".into()], - None, - )) - .unwrap(); - - assert!(rendered.contains("### Memory")); - assert!(rendered.contains("small targeted `MemoryQuery`")); - assert!(rendered.contains("MemoryReadDocument")); - assert!(!rendered.contains("MemoryUpdateDocument")); - assert!(!rendered.contains("MemoryEdit")); - assert!(!rendered.contains("MemoryDelete")); - } - - #[test] - fn worker_orchestration_guidance_is_included_for_feature_instruction() { - let loader = PromptLoader::builtins_only(); - let tmpl = SystemPromptTemplate::parse("$yoi/default", loader).unwrap(); - let dir = TempDir::new().unwrap(); - let scope = build_scope(dir.path()); - let instructions = [sub_worker_orchestration_instruction()]; - let mut ctx = ctx(dir.path(), &scope, vec!["Read".into()], None); - ctx.feature_instructions = &instructions; - let rendered = tmpl.render(&ctx).unwrap(); - - assert!(rendered.contains("## SubWorker orchestration")); - assert!(rendered.contains("SubWorker notifications are background signals")); - assert!(rendered.contains("does not need to keep a turn open")); - assert!(rendered.contains("Do not use `sleep` or polling loops")); - assert!(rendered.contains("worktree state, diff, and test results")); - assert!(rendered.contains("not scheduler or auto-maintain authorization")); - assert!(rendered.contains("bypass user/Ticket authorization")); - } - - #[test] - fn worker_orchestration_guidance_is_omitted_without_sub_worker_management_tools() { - let loader = PromptLoader::builtins_only(); - let tmpl = SystemPromptTemplate::parse("$yoi/default", loader).unwrap(); - let dir = TempDir::new().unwrap(); - let scope = build_scope(dir.path()); - let rendered = tmpl - .render(&ctx( - dir.path(), - &scope, - vec!["Read".into(), "Edit".into(), "MemoryReadDocument".into()], - None, - )) - .unwrap(); - - assert!(!rendered.contains("## Worker orchestration")); - assert!(!rendered.contains("spawned Worker notifications are background signals")); - assert!(!rendered.contains("does not need to keep a turn open")); - assert!(!rendered.contains("Do not use `sleep` or polling loops")); - } - - #[test] - fn instruction_prefix_addressing_user() { - let (_tmp, loader) = user_loader_with("greet.md", "HELLO from {{ cwd }}"); - let tmpl = SystemPromptTemplate::parse("$user/greet", loader).unwrap(); - let dir = TempDir::new().unwrap(); - let scope = build_scope(dir.path()); - let rendered = tmpl.render(&ctx(dir.path(), &scope, vec![], None)).unwrap(); - assert!(rendered.starts_with("HELLO from")); - assert!(rendered.contains("## Working boundaries")); - } - - #[test] - fn instruction_prefix_addressing_workspace() { - let tmp = TempDir::new().unwrap(); - std::fs::write(tmp.path().join("ws.md"), "WS {{ date }}").unwrap(); - let loader = PromptLoader::new(None, Some(tmp.path().to_path_buf())); - let tmpl = SystemPromptTemplate::parse("$workspace/ws", loader).unwrap(); - let dir = TempDir::new().unwrap(); - let scope = build_scope(dir.path()); - let rendered = tmpl.render(&ctx(dir.path(), &scope, vec![], None)).unwrap(); - assert!(rendered.starts_with("WS 2026-04-15")); - } - - #[test] - fn include_unqualified_resolves_relative_to_current_prefix() { - let tmp = TempDir::new().unwrap(); - // parent.md and sibling.md both under the user root. - std::fs::write( - tmp.path().join("parent.md"), - "PARENT\n{% include \"sibling\" %}", - ) - .unwrap(); - std::fs::write(tmp.path().join("sibling.md"), "SIBLING-BODY").unwrap(); - let loader = PromptLoader::new(Some(tmp.path().to_path_buf()), None); - let tmpl = SystemPromptTemplate::parse("$user/parent", loader).unwrap(); - let dir = TempDir::new().unwrap(); - let scope = build_scope(dir.path()); - let rendered = tmpl.render(&ctx(dir.path(), &scope, vec![], None)).unwrap(); - assert!(rendered.contains("PARENT")); - assert!(rendered.contains("SIBLING-BODY")); - } - - #[test] - fn include_unqualified_from_subdirectory_resolves_in_same_dir() { - let tmp = TempDir::new().unwrap(); - std::fs::create_dir(tmp.path().join("common")).unwrap(); - std::fs::write( - tmp.path().join("common/header.md"), - "HEADER\n{% include \"nested\" %}", - ) - .unwrap(); - std::fs::write(tmp.path().join("common/nested.md"), "NESTED-OK").unwrap(); - let loader = PromptLoader::new(Some(tmp.path().to_path_buf()), None); - let tmpl = SystemPromptTemplate::parse("$user/common/header", loader).unwrap(); - let dir = TempDir::new().unwrap(); - let scope = build_scope(dir.path()); - let rendered = tmpl.render(&ctx(dir.path(), &scope, vec![], None)).unwrap(); - assert!(rendered.contains("HEADER")); - assert!(rendered.contains("NESTED-OK")); - } - - #[test] - fn include_explicit_prefix_overrides_relative() { - let tmp = TempDir::new().unwrap(); - std::fs::write( - tmp.path().join("root.md"), - "U-ROOT\n{% include \"$yoi/common/tool-usage\" %}", - ) - .unwrap(); - let loader = PromptLoader::new(Some(tmp.path().to_path_buf()), None); - let tmpl = SystemPromptTemplate::parse("$user/root", loader).unwrap(); - let dir = TempDir::new().unwrap(); - let scope = build_scope(dir.path()); - let rendered = tmpl - .render(&ctx( - dir.path(), - &scope, - vec!["Read".into(), "Edit".into()], - None, - )) - .unwrap(); - assert!(rendered.contains("U-ROOT")); - // Pulled in from the builtin tool-usage asset. - assert!(rendered.contains("Read")); - } - - #[test] - fn prefix_with_missing_file_is_hard_error() { - let loader = PromptLoader::builtins_only(); - let err = SystemPromptTemplate::parse("$yoi/definitely-missing", loader).unwrap_err(); - assert!(matches!(err, SystemPromptError::LoaderResolve(_))); - } - - #[test] - fn parse_fails_on_syntax_error() { - let (_tmp, loader) = user_loader_with("broken.md", "{{ unclosed"); - let err = SystemPromptTemplate::parse("$user/broken", loader).unwrap_err(); - assert!(matches!(err, SystemPromptError::Parse(_))); - } - - #[test] - fn render_fails_on_undefined_variable() { - let (_tmp, loader) = user_loader_with("ghost.md", "{{ ghost }}"); - let tmpl = SystemPromptTemplate::parse("$user/ghost", loader).unwrap(); - let dir = TempDir::new().unwrap(); - let scope = build_scope(dir.path()); - let err = tmpl - .render(&ctx(dir.path(), &scope, vec![], None)) - .unwrap_err(); - assert!(matches!(err, SystemPromptError::Render(_))); - } - - #[test] - fn render_substitutes_date_cwd_tools() { - let (_tmp, loader) = user_loader_with( - "vars.md", - "date={{ date }} cwd={{ cwd }} tools={{ tools | join(',') }}", - ); - let tmpl = SystemPromptTemplate::parse("$user/vars", loader).unwrap(); - let dir = TempDir::new().unwrap(); - let scope = build_scope(dir.path()); - let rendered = tmpl - .render(&ctx( - dir.path(), - &scope, - vec!["alpha".into(), "beta".into()], - None, - )) - .unwrap(); - assert!(rendered.contains("date=2026-04-15")); - assert!(rendered.contains(&format!("cwd={}", dir.path().display()))); - assert!(rendered.contains("tools=alpha,beta")); - } - - #[test] - fn trailing_section_always_contains_scope_summary() { - let (_tmp, loader) = user_loader_with("body.md", "BODY"); - let tmpl = SystemPromptTemplate::parse("$user/body", loader).unwrap(); - let dir = TempDir::new().unwrap(); - let scope = build_scope(dir.path()); - let rendered = tmpl.render(&ctx(dir.path(), &scope, vec![], None)).unwrap(); - assert!(rendered.contains("## Working boundaries")); - assert!(rendered.contains("Readable:")); - assert!(rendered.contains("Writable:")); - } - - #[test] - fn trailing_section_contains_agents_md_when_present() { - let (_tmp, loader) = user_loader_with("body.md", "BODY"); - let tmpl = SystemPromptTemplate::parse("$user/body", loader).unwrap(); - let dir = TempDir::new().unwrap(); - let scope = build_scope(dir.path()); - let rendered = tmpl - .render(&ctx( - dir.path(), - &scope, - vec![], - Some("PROJECT DOCS".into()), - )) - .unwrap(); - assert!(rendered.contains("## Project instructions (AGENTS.md)")); - assert!(rendered.contains("PROJECT DOCS")); - } - - #[test] - fn trailing_section_omits_agents_md_when_absent() { - let (_tmp, loader) = user_loader_with("body.md", "BODY"); - let tmpl = SystemPromptTemplate::parse("$user/body", loader).unwrap(); - let dir = TempDir::new().unwrap(); - let scope = build_scope(dir.path()); - let rendered = tmpl.render(&ctx(dir.path(), &scope, vec![], None)).unwrap(); - assert!(!rendered.contains("AGENTS.md")); - assert!(!rendered.contains("Project instructions")); - } - - #[test] - fn trailing_section_renders_resident_summary_body() { - let (_tmp, loader) = user_loader_with("body.md", "BODY"); - let tmpl = SystemPromptTemplate::parse("$user/body", loader).unwrap(); - let dir = TempDir::new().unwrap(); - let scope = build_scope(dir.path()); - let rendered = tmpl - .render(&ctx_with_summary( - dir.path(), - &scope, - Some("Persistent summary body"), - )) - .unwrap(); - assert!(rendered.contains("## Resident memory summary")); - assert!(rendered.contains("Persistent summary body")); - } - - #[test] - fn trailing_section_omits_resident_summary_when_none_or_empty() { - let (_tmp, loader) = user_loader_with("body.md", "BODY"); - let tmpl = SystemPromptTemplate::parse("$user/body", loader).unwrap(); - let dir = TempDir::new().unwrap(); - let scope = build_scope(dir.path()); - let rendered = tmpl - .render(&ctx_with_summary(dir.path(), &scope, None)) - .unwrap(); - assert!(!rendered.contains("Resident memory summary")); - - let rendered = tmpl - .render(&ctx_with_summary(dir.path(), &scope, Some(" \n"))) - .unwrap(); - assert!(!rendered.contains("Resident memory summary")); + fn role_templates_are_selected_without_filesystem_resolution() { + let loader = PromptCatalogSource::builtins_only(); + for role in [ + "role.coder", + "role.intake", + "role.orchestrator", + "role.reviewer", + ] { + assert!( + SystemPromptTemplate::parse(role, loader.clone()).is_ok(), + "{role}" + ); + } } } diff --git a/crates/worker/src/skill.rs b/crates/worker/src/skill.rs index effd90c7..055cb9fe 100644 --- a/crates/worker/src/skill.rs +++ b/crates/worker/src/skill.rs @@ -57,8 +57,20 @@ pub enum SkillSourceKind { #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] pub struct SkillProvenance { pub kind: SkillSourceKind, - /// Stable path-free id: `builtin:` or `workspace:`. + /// Stable id: `builtin:` or `workspace:`. pub id: String, + /// Virtual config/resource path. Never an absolute host filesystem path. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub virtual_path: Option, + /// Active Workspace config revision for Workspace Skills. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub revision: Option, + /// Digest of the immutable `SKILL.md` source. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub source_digest: Option, + /// Digest of the active virtual config tree snapshot. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub tree_digest: Option, } #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] @@ -101,7 +113,8 @@ pub struct SkillDetailResponse { pub overrides: Vec, #[serde(default)] pub diagnostics: Vec, - /// Full SKILL.md contents. This is intentionally omitted from catalog responses. + /// Imported Markdown content with YAML frontmatter delimiters removed. + /// This is intentionally omitted from catalog responses. pub body: String, #[serde(default)] pub allowed_tools: Vec, @@ -117,7 +130,7 @@ pub struct SkillActivationResponse { pub provenance: SkillProvenance, #[serde(default)] pub diagnostics: Vec, - /// Full SKILL.md contents to append to Worker history on explicit activation. + /// Imported Markdown content to append to Worker history on explicit activation. pub body: String, } diff --git a/crates/worker/src/spawn/tool.rs b/crates/worker/src/spawn/tool.rs index 21fbc182..5f29ef5f 100644 --- a/crates/worker/src/spawn/tool.rs +++ b/crates/worker/src/spawn/tool.rs @@ -20,7 +20,7 @@ use manifest::{ use serde::Deserialize; use tokio::sync::mpsc; -use crate::PromptLoader; +use crate::PromptCatalogSource; use crate::controller::register_worker_tools; use crate::internal_worker::{ EphemeralSessionStore, InternalWorkerSessionStatus, prepare_internal_worker_session, @@ -44,7 +44,7 @@ struct SubWorkerSpawnInput { /// unambiguous profile slug. Raw/path selectors are rejected. #[serde(default)] profile: Option, - /// Instruction-file reference (e.g. `$yoi/default`, `$user/my-agent`). + /// Exact catalog-root dotted Prompt name (for example `default` or `role.coder`). #[serde(default)] instruction: Option, /// Child process/tool working directory. This is not the runtime workspace @@ -276,7 +276,7 @@ pub struct SubWorkerSpawnTool { /// child config from reusable fields here, and selected profiles are /// merged into the same internal handoff shape before launch. spawner_manifest: WorkerManifest, - prompt_loader: PromptLoader, + prompt_loader: PromptCatalogSource, /// Compact selector list shared by tool description and diagnostics. available_profiles: AvailableProfiles, /// Spawner's runtime scope. After a successful spawn, the @@ -310,7 +310,7 @@ impl SubWorkerSpawnTool { spawner_cwd: PathBuf, registry: Arc, spawner_manifest: WorkerManifest, - prompt_loader: PromptLoader, + prompt_loader: PromptCatalogSource, available_profiles: AvailableProfiles, spawner_scope: SharedScope, delegation_scope: DelegationScope, @@ -827,7 +827,6 @@ fn build_spawn_config_json( let config = WorkerManifestConfig { worker: WorkerMetaConfig { name: Some(name.to_string()), - prompt_pack: None, }, model: model.clone(), engine: EngineManifestConfig { @@ -870,7 +869,6 @@ fn manifest_to_reusable_config(manifest: &WorkerManifest) -> WorkerManifestConfi WorkerManifestConfig { worker: WorkerMetaConfig { name: Some(manifest.worker.name.clone()), - prompt_pack: manifest.worker.prompt_pack.clone(), }, model: manifest.model.clone(), engine: EngineManifestConfig { @@ -1010,7 +1008,7 @@ fn sub_worker_spawn_tool_impl( spawner_cwd.clone(), registry.clone(), spawner_manifest.clone(), - prompts.loader(), + prompts.source(), available_profiles, spawner_scope.clone(), DelegationScope::from_config(&spawner_manifest.delegation_scope) @@ -1086,7 +1084,7 @@ model_id = "reviewer-model" kind = "none" [engine] -instruction = "$yoi/reviewer" +instruction = "role.reviewer" language = "Reviewerish" max_tokens = 3333 @@ -1136,14 +1134,7 @@ extract_threshold = 4000 let observed_parent_write_revoked = Arc::new(AtomicBool::new(false)); let observed_instruction_override = Arc::new(AtomicBool::new(false)); let fail_requests = Arc::new(AtomicBool::new(false)); - let workspace_prompts = runtime.path().join("workspace-prompts"); - std::fs::create_dir_all(&workspace_prompts).unwrap(); - std::fs::write( - workspace_prompts.join("custom-reviewer.md"), - "WORKSPACE REVIEWER OVERRIDE", - ) - .unwrap(); - let prompt_loader = PromptLoader::new(None, Some(workspace_prompts)); + let prompt_loader = PromptCatalogSource::builtins_only(); let (parent_method_tx, mut parent_method_rx) = mpsc::channel(8); let tool = SubWorkerSpawnTool::new( "parent".into(), @@ -1170,7 +1161,7 @@ extract_threshold = 4000 let input = serde_json::json!({ "name": "reviewer-child", "profile": "project:reviewer", - "instruction": "$workspace/custom-reviewer", + "instruction": "role.reviewer", "task": "review immutable commit", "scope": [{ "target": workspace_root.clone(), @@ -1468,7 +1459,7 @@ extract_threshold = 4000 request .system_prompt .as_deref() - .is_some_and(|prompt| prompt.contains("WORKSPACE REVIEWER OVERRIDE")), + .is_some_and(|prompt| prompt.contains("review")), Ordering::SeqCst, ); if self.fail_requests.load(Ordering::SeqCst) { @@ -1515,7 +1506,6 @@ extract_threshold = 4000 WorkerManifestConfig { worker: WorkerMetaConfig { name: Some("parent".into()), - prompt_pack: None, }, model: ModelManifest { scheme: Some(SchemeKind::Anthropic), @@ -1524,7 +1514,7 @@ extract_threshold = 4000 ..Default::default() }, engine: EngineManifestConfig { - instruction: Some("$yoi/parent".into()), + instruction: Some("default".into()), language: Some("Parentish".into()), max_tokens: Some(1234), stop_sequences: Some(vec!["STOP".into()]), @@ -1550,8 +1540,7 @@ extract_threshold = 4000 default: Option<&str>, profiles: &[(&str, &str, &str)], ) -> AvailableProfiles { - let yoi = project.join(".yoi"); - let profile_dir = yoi.join("profiles"); + let profile_dir = project.join("explicit-project-profiles"); std::fs::create_dir_all(&profile_dir).unwrap(); let mut registry_toml = String::new(); if let Some(default) = default { @@ -1560,9 +1549,9 @@ extract_threshold = 4000 registry_toml.push_str("[profile]\n"); for (name, file, body) in profiles { std::fs::write(profile_dir.join(file), body).unwrap(); - registry_toml.push_str(&format!("{name} = \"profiles/{file}\"\n")); + registry_toml.push_str(&format!("{name} = \"explicit-project-profiles/{file}\"\n")); } - let registry_path = yoi.join("profiles.toml"); + let registry_path = project.join("explicit-project-profiles.toml"); std::fs::write(®istry_path, registry_toml).unwrap(); AvailableProfiles { registry: Some( @@ -1605,7 +1594,7 @@ scheme = "anthropic" model_id = "coder-model" [engine] -instruction = "$yoi/coder" +instruction = "role.coder" language = "Coderish" max_tokens = 2222 "#; @@ -1619,7 +1608,7 @@ scheme = "anthropic" model_id = "reviewer-model" [engine] -instruction = "$yoi/reviewer" +instruction = "role.reviewer" language = "Reviewerish" max_tokens = 3333 "#; @@ -1636,8 +1625,7 @@ max_tokens = 3333 ..Default::default() }; - let config_json = - build_spawn_config_json("child", "$yoi/default", &[], &model, false).unwrap(); + let config_json = build_spawn_config_json("child", "default", &[], &model, false).unwrap(); let parsed: WorkerManifestConfig = serde_json::from_str(&config_json).unwrap(); assert_eq!(parsed.model.scheme, Some(SchemeKind::Anthropic)); @@ -1659,8 +1647,7 @@ max_tokens = 3333 ref_: Some("anthropic/claude-sonnet-4-6".into()), ..Default::default() }; - let config_json = - build_spawn_config_json("child", "$yoi/default", &[], &model, false).unwrap(); + let config_json = build_spawn_config_json("child", "default", &[], &model, false).unwrap(); let parsed: WorkerManifestConfig = serde_json::from_str(&config_json).unwrap(); assert_eq!( parsed.model.ref_.as_deref(), @@ -1681,7 +1668,7 @@ max_tokens = 3333 }]; let config_json = - build_spawn_config_json("child", "$yoi/default", &scope, &model, true).unwrap(); + build_spawn_config_json("child", "default", &scope, &model, true).unwrap(); let parsed: WorkerManifestConfig = serde_json::from_str(&config_json).unwrap(); assert_eq!( parsed.session.as_ref().and_then(|s| s.record_event_trace), @@ -1701,8 +1688,7 @@ max_tokens = 3333 ref_: Some("anthropic/claude-sonnet-4-6".into()), ..Default::default() }; - let config_json = - build_spawn_config_json("child", "$yoi/default", &[], &model, false).unwrap(); + let config_json = build_spawn_config_json("child", "default", &[], &model, false).unwrap(); let parsed: WorkerManifestConfig = serde_json::from_str(&config_json).unwrap(); assert!(parsed.session.is_none()); @@ -1738,7 +1724,7 @@ max_tokens = 3333 assert_eq!(config.worker.name.as_deref(), Some("child-default")); assert_eq!(config.model.model_id.as_deref(), Some("reviewer-model")); - assert_eq!(config.engine.instruction.as_deref(), Some("$yoi/reviewer")); + assert_eq!(config.engine.instruction.as_deref(), Some("role.reviewer")); assert_eq!(config.engine.language.as_deref(), Some("Reviewerish")); assert_eq!(config.scope.allow, scope); assert!(config.scope.deny.is_empty()); @@ -1777,7 +1763,7 @@ max_tokens = 3333 assert_eq!(config.worker.name.as_deref(), Some("review-child")); assert_eq!(config.model.model_id.as_deref(), Some("reviewer-model")); - assert_eq!(config.engine.instruction.as_deref(), Some("$yoi/reviewer")); + assert_eq!(config.engine.instruction.as_deref(), Some("role.reviewer")); assert_eq!(config.engine.language.as_deref(), Some("Reviewerish")); assert_eq!(config.engine.max_tokens, Some(3333)); assert_eq!(config.scope.allow, scope); @@ -1811,7 +1797,7 @@ max_tokens = 3333 assert_eq!(config.worker.name.as_deref(), Some("inherited-child")); assert_eq!(config.model.model_id.as_deref(), Some("parent-model")); - assert_eq!(config.engine.instruction.as_deref(), Some("$yoi/parent")); + assert_eq!(config.engine.instruction.as_deref(), Some("default")); assert_eq!(config.engine.language.as_deref(), Some("Parentish")); assert_eq!(config.engine.max_tokens, Some(1234)); assert_eq!( @@ -1846,15 +1832,12 @@ max_tokens = 3333 &available, &project, "override-child", - Some("$user/custom-reviewer"), + Some("role.reviewer"), &scope, SpawnProfileSelector::Default, ); - assert_eq!( - config.engine.instruction.as_deref(), - Some("$user/custom-reviewer") - ); + assert_eq!(config.engine.instruction.as_deref(), Some("role.reviewer")); assert_eq!(config.model.model_id.as_deref(), Some("reviewer-model")); assert_eq!(config.engine.language.as_deref(), Some("Reviewerish")); assert_eq!(config.engine.max_tokens, Some(3333)); @@ -1944,7 +1927,7 @@ max_tokens = 3333 let user_config = tmp.path().join("user-profiles.toml"); std::fs::write(&user_config, "[profile]\ncoder = \"user-coder.toml\"\n").unwrap(); - let project_config = project.join(".yoi/profiles.toml"); + let project_config = project.join("explicit-project-profiles.toml"); let ambiguous = AvailableProfiles { registry: Some( ProfileDiscovery::with_sources(Some(user_config), Some(project_config)) diff --git a/crates/worker/src/worker.rs b/crates/worker/src/worker.rs index 2b333b7c..a9b5bd6d 100644 --- a/crates/worker/src/worker.rs +++ b/crates/worker/src/worker.rs @@ -53,7 +53,7 @@ use crate::internal_worker::{ const COMPACTION_EXTENSION_DOMAIN: &str = "yoi.compaction"; const COMPACTION_BLOCK_ID: &str = "compact"; const WORKER_ORCHESTRATION_INSTRUCTION_ID: &str = "worker.orchestration"; -const WORKER_ORCHESTRATION_PROMPT_REF: &str = "$yoi/common/worker-orchestration"; +const WORKER_ORCHESTRATION_PROMPT_REF: &str = "common.worker_orchestration"; fn worker_orchestration_instruction() -> FeatureInstructionDeclaration { FeatureInstructionDeclaration::new( @@ -68,7 +68,7 @@ use crate::ipc::interceptor::WorkerInterceptor; use crate::ipc::notify_buffer::NotifyBuffer; use crate::prompt::agents_md::read_agents_md; use crate::prompt::catalog::{CatalogError, PromptCatalog}; -use crate::prompt::loader::PromptLoader; +use crate::prompt::source::PromptCatalogSource; use crate::prompt::system::{SystemPromptContext, SystemPromptError, SystemPromptTemplate}; use crate::runtime::dir; use crate::runtime::worker_allocation::{self, ScopeAllocationGuard, ScopeLockError}; @@ -4243,7 +4243,7 @@ where pub async fn from_manifest( manifest: WorkerManifest, store: St, - loader: PromptLoader, + loader: PromptCatalogSource, ) -> Result { let cwd = current_cwd()?; let authority = WorkerFilesystemAuthority::local(cwd.clone(), cwd.clone()); @@ -4255,7 +4255,7 @@ where pub async fn from_manifest_with_context( manifest: WorkerManifest, store: St, - loader: PromptLoader, + loader: PromptCatalogSource, workspace_context: WorkerWorkspaceContext, filesystem_authority: WorkerFilesystemAuthority, ) -> Result { @@ -4352,7 +4352,7 @@ where pub(crate) async fn from_internal_manifest_with_context( manifest: WorkerManifest, store: St, - loader: PromptLoader, + loader: PromptCatalogSource, workspace_context: WorkerWorkspaceContext, filesystem_authority: WorkerFilesystemAuthority, client_override: Option>, @@ -4435,7 +4435,7 @@ where pub async fn from_manifest_spawned( manifest: WorkerManifest, store: St, - loader: PromptLoader, + loader: PromptCatalogSource, callback_socket: PathBuf, ) -> Result { let cwd = current_cwd()?; @@ -4455,7 +4455,7 @@ where pub async fn from_manifest_spawned_with_context( manifest: WorkerManifest, store: St, - loader: PromptLoader, + loader: PromptCatalogSource, callback_socket: PathBuf, workspace_context: WorkerWorkspaceContext, filesystem_authority: WorkerFilesystemAuthority, @@ -4544,7 +4544,7 @@ where worker_name: &str, manifest: WorkerManifest, store: St, - loader: PromptLoader, + loader: PromptCatalogSource, ) -> Result { let cwd = current_cwd()?; let authority = WorkerFilesystemAuthority::local(cwd.clone(), cwd.clone()); @@ -4564,7 +4564,7 @@ where worker_name: &str, manifest: WorkerManifest, store: St, - loader: PromptLoader, + loader: PromptCatalogSource, workspace_context: WorkerWorkspaceContext, filesystem_authority: WorkerFilesystemAuthority, ) -> Result { @@ -4613,7 +4613,7 @@ where worker_name: &str, fallback: WorkerManifest, store: St, - loader: PromptLoader, + loader: PromptCatalogSource, workspace_context: WorkerWorkspaceContext, filesystem_authority: WorkerFilesystemAuthority, ) -> Result { @@ -4683,7 +4683,7 @@ where segment_id: SegmentId, manifest: WorkerManifest, store: St, - loader: PromptLoader, + loader: PromptCatalogSource, ) -> Result { let cwd = current_cwd()?; let authority = WorkerFilesystemAuthority::local(cwd.clone(), cwd.clone()); @@ -4705,7 +4705,7 @@ where segment_id: SegmentId, manifest: WorkerManifest, store: St, - loader: PromptLoader, + loader: PromptCatalogSource, workspace_context: WorkerWorkspaceContext, filesystem_authority: WorkerFilesystemAuthority, ) -> Result { @@ -4903,7 +4903,7 @@ where pub async fn from_manifest_toml(toml: &str, store: St) -> Result { let config = WorkerManifestConfig::from_toml(toml).map_err(WorkerError::ManifestParse)?; let manifest = WorkerManifest::try_from(config).map_err(WorkerError::ManifestResolve)?; - Self::from_manifest(manifest, store, PromptLoader::builtins_only()).await + Self::from_manifest(manifest, store, PromptCatalogSource::builtins_only()).await } } @@ -5588,7 +5588,7 @@ fn delegated_write_rule_to_deny(rule: WorkerSpawnedScopeRule) -> Option; @@ -96,9 +99,9 @@ permission = "write" /// Build a Worker with a synthetic instruction template. /// -/// Writes `body` to a temp user-prompts dir under `$user/test`, builds a -/// PromptLoader pointing at it, parses the template, and installs it on -/// a Worker constructed directly via `Worker::new`. +/// Builds an immutable effective catalog with `body` at the exact `test` +/// Prompt name and installs that parsed template on a directly constructed +/// Worker. async fn make_worker_with_body( body: &str, client: MockClient, @@ -117,10 +120,15 @@ async fn make_worker_with_body( let scope = worker::Scope::writable(&pwd).unwrap(); std::mem::forget(pwd_tmp); - let user_prompts_tmp = tempfile::tempdir().unwrap(); - std::fs::write(user_prompts_tmp.path().join("test.md"), body).unwrap(); - let loader = PromptLoader::new(Some(user_prompts_tmp.path().to_path_buf()), None); - std::mem::forget(user_prompts_tmp); + let mut templates = PromptCatalog::builtins_only() + .unwrap() + .projection() + .templates + .clone(); + templates.insert("test".to_string(), body.to_string()); + let projection = + EffectivePromptCatalog::new(templates, 1, "test-schema", "test-toolchain").unwrap(); + let loader = PromptCatalogSource::builtins_only().with_effective_catalog(projection); let worker = Engine::new(client); let mut worker = Worker::new( @@ -133,7 +141,7 @@ async fn make_worker_with_body( ) .await?; - let template = SystemPromptTemplate::parse("$user/test", loader) + let template = SystemPromptTemplate::parse("test", loader) .map_err(|source| WorkerError::InvalidSystemPromptTemplate { source })?; worker.set_system_prompt_template(template); @@ -146,15 +154,15 @@ async fn make_worker_with_body( #[tokio::test] async fn template_parse_rejects_invalid_syntax() { - let user_prompts_tmp = tempfile::tempdir().unwrap(); - std::fs::write(user_prompts_tmp.path().join("broken.md"), "{{ unclosed").unwrap(); - let loader = PromptLoader::new(Some(user_prompts_tmp.path().to_path_buf()), None); - let err = SystemPromptTemplate::parse("$user/broken", loader).unwrap_err(); - let worker_err: WorkerError = WorkerError::InvalidSystemPromptTemplate { source: err }; - assert!(matches!( - worker_err, - WorkerError::InvalidSystemPromptTemplate { .. } - )); + let mut templates = PromptCatalog::builtins_only() + .unwrap() + .projection() + .templates + .clone(); + templates.insert("broken".to_string(), "{{ unclosed".to_string()); + let error = + EffectivePromptCatalog::new(templates, 1, "test-schema", "test-toolchain").unwrap_err(); + assert!(error.to_string().contains("does not compile")); } #[tokio::test] diff --git a/crates/workspace-server/src/config_source.rs b/crates/workspace-server/src/config_source.rs index bd2be398..4d259ab8 100644 --- a/crates/workspace-server/src/config_source.rs +++ b/crates/workspace-server/src/config_source.rs @@ -1,8 +1,9 @@ use chrono::{SecondsFormat, Utc}; use config_source::{ - ConfigContentType, ConfigEntry, ConfigSchemaContribution, ConfigTreeChange, ConfigTreeSnapshot, - DECODAL_VERSION, DEFAULT_IMPORT_POLICY_VERSION, DEFAULT_SCHEMA_VERSION, EvaluationResult, - SnapshotEnvironment, ToolchainContract, VirtualPath, WorkspaceConfigSchemaBundle, + ConfigContentType, ConfigDiagnostic, ConfigEntry, ConfigSchemaContribution, ConfigTreeChange, + ConfigTreeSnapshot, DECODAL_VERSION, DEFAULT_IMPORT_POLICY_VERSION, DEFAULT_SCHEMA_VERSION, + EvaluationResult, SnapshotEnvironment, ToolchainContract, VirtualPath, + WorkspaceConfigSchemaBundle, }; use rusqlite::{OptionalExtension, TransactionBehavior, params}; use serde::{Deserialize, Serialize}; @@ -11,6 +12,24 @@ use crate::{Error, Result, SqliteWorkspaceStore}; pub const MAIN_CONFIG_ENTRYPOINT: &str = "main.dcdl"; pub const DEFAULT_MAIN_CONFIG_SOURCE: &str = "{}\n"; +const MAX_TOOLCHAIN_UPGRADE_DIAGNOSTICS: usize = 20; + +fn toolchain_upgrade_diagnostics(mut diagnostics: Vec) -> Error { + let omitted = diagnostics + .len() + .saturating_sub(MAX_TOOLCHAIN_UPGRADE_DIAGNOSTICS); + diagnostics.truncate(MAX_TOOLCHAIN_UPGRADE_DIAGNOSTICS); + let rendered = serde_json::to_string(&diagnostics) + .unwrap_or_else(|_| "[diagnostics could not be serialized]".to_string()); + let suffix = if omitted == 0 { + String::new() + } else { + format!("; {omitted} additional diagnostic(s) omitted") + }; + Error::InvalidInput(format!( + "workspace configuration is invalid under Decodal {DECODAL_VERSION}: {rendered}{suffix}" + )) +} fn main_config_path() -> VirtualPath { VirtualPath::parse(MAIN_CONFIG_ENTRYPOINT).expect("main config entrypoint is a valid path") @@ -45,6 +64,30 @@ impl WorkspaceConfigSchemaRegistry { } } +pub fn evaluate_workspace_config_state( + state: &WorkspaceConfigState, + schema_bundle: WorkspaceConfigSchemaBundle, +) -> Result { + let expected_fingerprint = state.contract.fingerprint.clone(); + let contract = main_config_contract_with_schema(schema_bundle); + if !state.contract.schema_bundle.contributions.is_empty() + && contract.fingerprint != expected_fingerprint + { + return Err(Error::RegistryInconsistency( + "active Workspace config schema fingerprint does not match the current provider bundle" + .to_string(), + )); + } + SnapshotEnvironment::new(state.snapshot.clone()) + .evaluate_contract(&contract) + .map_err(|diagnostics| { + Error::InvalidInput( + serde_json::to_string(&diagnostics) + .unwrap_or_else(|_| "virtual config evaluation failed".to_string()), + ) + }) +} + fn main_config_contract_with_schema( schema_bundle: WorkspaceConfigSchemaBundle, ) -> ToolchainContract { @@ -56,10 +99,6 @@ fn main_config_contract_with_schema( ) } -fn main_config_contract() -> ToolchainContract { - main_config_contract_with_schema(WorkspaceConfigSchemaBundle::empty()) -} - #[derive(Debug, Clone, PartialEq, Serialize, Deserialize, ts_rs::TS)] #[ts(export)] pub struct WorkspaceConfigState { @@ -98,12 +137,14 @@ pub struct ConfigPreviewRequest { } impl SqliteWorkspaceStore { - pub fn ensure_workspace_config_materialized( + pub fn ensure_workspace_config_materialized_with_schema( &self, workspace_id: &str, materialized_at: &str, + schema_bundle: WorkspaceConfigSchemaBundle, ) -> Result { - self.with_conn_mut(|conn| { + let desired_schema = schema_bundle.clone(); + let (state, requires_toolchain_refresh) = self.with_conn_mut(|conn| { let tx = conn.transaction_with_behavior(TransactionBehavior::Immediate)?; let workspace_exists: bool = tx.query_row( "SELECT EXISTS(SELECT 1 FROM workspaces WHERE workspace_id = ?1)", @@ -113,17 +154,34 @@ impl SqliteWorkspaceStore { if !workspace_exists { return Err(Error::WorkspaceIdMismatch); } + let stored_decodal_version = tx + .query_row( + "SELECT decodal_version FROM workspace_config_trees WHERE workspace_id = ?1", + [workspace_id], + |row| row.get::<_, String>(0), + ) + .optional()?; + let requires_toolchain_refresh = stored_decodal_version + .as_deref() + .is_some_and(|version| version != DECODAL_VERSION); let state = match load_state(&tx, workspace_id)? { Some(state) => state, None => { - let state = initial_state()?; + let state = initial_state_with_schema(schema_bundle.clone())?; insert_materialized_state(&tx, workspace_id, &state, materialized_at)?; state } }; tx.commit()?; - Ok(state) - }) + Ok((state, requires_toolchain_refresh)) + })?; + if requires_toolchain_refresh + || state.contract.schema_bundle.fingerprint != desired_schema.fingerprint + { + let candidate = evaluate_candidate(state, &[], desired_schema)?; + return self.commit_evaluated_workspace_config(workspace_id, &candidate); + } + Ok(state) } pub fn load_workspace_config( @@ -478,22 +536,42 @@ pub(crate) fn load_state( } let entrypoints: Vec = serde_json::from_str(&entrypoints_json) .map_err(|error| Error::RegistryInconsistency(error.to_string()))?; - let schema_bundle = match schema_bundle_json { + let stored_schema_bundle: WorkspaceConfigSchemaBundle = match schema_bundle_json { Some(schema_bundle_json) => serde_json::from_str(&schema_bundle_json) .map_err(|error| Error::RegistryInconsistency(error.to_string()))?, None => WorkspaceConfigSchemaBundle::empty(), }; + let requires_toolchain_refresh = decodal_version != DECODAL_VERSION; + if requires_toolchain_refresh && !matches!(decodal_version.as_str(), "0.2.0" | "0.3.0") { + return Err(Error::RegistryInconsistency(format!( + "unsupported virtual config Decodal version {decodal_version} for Workspace {workspace_id}" + ))); + } + let schema_bundle = if requires_toolchain_refresh { + WorkspaceConfigSchemaBundle::compose(stored_schema_bundle.contributions) + .map_err(config_error)? + } else { + stored_schema_bundle + }; let contract = ToolchainContract::with_schema_bundle( schema_version, entrypoints, import_policy_version, schema_bundle, ); - if decodal_version != DECODAL_VERSION || contract.fingerprint != fingerprint { + if !requires_toolchain_refresh && contract.fingerprint != fingerprint { return Err(Error::RegistryInconsistency(format!( "virtual config toolchain metadata mismatch for Workspace {workspace_id}" ))); } + let projection_digest = if requires_toolchain_refresh { + SnapshotEnvironment::new(snapshot.clone()) + .evaluate_contract(&contract) + .map_err(toolchain_upgrade_diagnostics)? + .projection_digest + } else { + projection_digest + }; Ok(Some(WorkspaceConfigState { snapshot, contract, @@ -502,6 +580,12 @@ pub(crate) fn load_state( } pub(crate) fn initial_state() -> Result { + initial_state_with_schema(WorkspaceConfigSchemaBundle::empty()) +} + +pub(crate) fn initial_state_with_schema( + schema_bundle: WorkspaceConfigSchemaBundle, +) -> Result { let path = main_config_path(); let snapshot = ConfigTreeSnapshot::empty() .apply(&[ConfigTreeChange::Create { @@ -510,7 +594,7 @@ pub(crate) fn initial_state() -> Result { content: DEFAULT_MAIN_CONFIG_SOURCE.to_string(), }]) .map_err(config_error)?; - let contract = main_config_contract(); + let contract = main_config_contract_with_schema(schema_bundle); let projection_digest = SnapshotEnvironment::new(snapshot.clone()) .evaluate_contract(&contract) .map_err(|diagnostics| { @@ -873,6 +957,296 @@ mod tests { assert!(error.to_string().contains("toolchain fingerprint mismatch")); } + #[test] + fn active_state_evaluation_rejects_provider_fingerprint_drift() { + let snapshot = ConfigTreeSnapshot::from_entries( + 1, + [ConfigEntry::new( + path(MAIN_CONFIG_ENTRYPOINT), + ConfigContentType::Decodal, + "{}", + ) + .unwrap()], + ) + .unwrap(); + let persisted_bundle = + WorkspaceConfigSchemaBundle::compose([ConfigSchemaContribution::new( + "builtin:test", + "test", + "1", + r#"{ test = { value = String default "one"; }; }"#, + ) + .unwrap()]) + .unwrap(); + let state = WorkspaceConfigState { + projection_digest: "persisted".to_string(), + contract: main_config_contract_with_schema(persisted_bundle), + snapshot, + }; + let changed_bundle = WorkspaceConfigSchemaBundle::compose([ConfigSchemaContribution::new( + "builtin:test", + "test", + "2", + r#"{ test = { value = String default "two"; }; }"#, + ) + .unwrap()]) + .unwrap(); + let error = evaluate_workspace_config_state(&state, changed_bundle).unwrap_err(); + assert!(error.to_string().contains("schema fingerprint")); + } + + #[tokio::test] + async fn initial_materialization_persists_composed_schema_contract() { + let store = open_store().await; + let schema_bundle = WorkspaceConfigSchemaBundle::compose([ConfigSchemaContribution::new( + "builtin:test", + "test", + "1", + r#"{ test = { value = String default "initial"; }; }"#, + ) + .unwrap()]) + .unwrap(); + let expected = initial_state_with_schema(schema_bundle.clone()).unwrap(); + let state = store + .ensure_workspace_config_materialized_with_schema( + "w-config", + "2026-08-13T00:00:00Z", + schema_bundle, + ) + .unwrap(); + assert_eq!(state.contract.fingerprint, expected.contract.fingerprint); + assert_eq!( + state.contract.schema_bundle, + expected.contract.schema_bundle + ); + assert_eq!(state.projection_digest, expected.projection_digest); + let reloaded = store.load_workspace_config("w-config").unwrap().unwrap(); + assert_eq!(reloaded.contract, state.contract); + assert_eq!(reloaded.projection_digest, state.projection_digest); + } + + #[tokio::test] + async fn toolchain_upgrade_re_evaluates_current_tree_and_preserves_prior_revision() { + let store = open_store().await; + let schema_bundle = WorkspaceConfigSchemaBundle::compose([ConfigSchemaContribution::new( + "builtin:test", + "test", + "1", + r#"{ test = { value = String default "initial"; }; }"#, + ) + .unwrap()]) + .unwrap(); + let current = store + .ensure_workspace_config_materialized_with_schema( + "w-config", + "2026-08-13T00:00:00Z", + schema_bundle.clone(), + ) + .unwrap(); + store + .with_conn(|conn| { + conn.execute( + "UPDATE workspace_config_trees + SET decodal_version = '0.2.0', toolchain_fingerprint = 'sha256:legacy' + WHERE workspace_id = 'w-config'", + [], + )?; + conn.execute( + "UPDATE workspace_config_tree_revisions + SET toolchain_fingerprint = 'sha256:legacy' + WHERE workspace_id = 'w-config' AND revision = ?1", + [current.snapshot.revision], + )?; + Ok(()) + }) + .unwrap(); + + let refreshed = store + .ensure_workspace_config_materialized_with_schema( + "w-config", + "2026-08-14T00:00:00Z", + schema_bundle, + ) + .unwrap(); + assert_eq!(refreshed.snapshot.revision, current.snapshot.revision + 1); + assert_eq!(refreshed.snapshot.digest, current.snapshot.digest); + assert_eq!(refreshed.contract.decodal_version, DECODAL_VERSION); + assert_ne!(refreshed.contract.fingerprint, "sha256:legacy"); + let prior = store + .load_workspace_config_revision("w-config", current.snapshot.revision) + .unwrap() + .unwrap(); + assert_eq!(prior, current.snapshot); + let prior_fingerprint = store + .with_conn(|conn| { + conn.query_row( + "SELECT toolchain_fingerprint + FROM workspace_config_tree_revisions + WHERE workspace_id = 'w-config' AND revision = ?1", + [current.snapshot.revision], + |row| row.get::<_, String>(0), + ) + .map_err(Error::from) + }) + .unwrap(); + assert_eq!(prior_fingerprint, "sha256:legacy"); + } + + #[tokio::test] + async fn schema_provider_addition_re_evaluates_and_pins_a_new_revision() { + let store = open_store().await; + let initial = WorkspaceConfigSchemaBundle::compose([ConfigSchemaContribution::new( + "builtin:profile-test", + "profile", + "1", + r#"{ profile = { enabled = Bool default true; }; }"#, + ) + .unwrap()]) + .unwrap(); + let current = store + .ensure_workspace_config_materialized_with_schema( + "w-config", + "2026-08-13T00:00:00Z", + initial, + ) + .unwrap(); + let extended = WorkspaceConfigSchemaBundle::compose([ + ConfigSchemaContribution::new( + "builtin:profile-test", + "profile", + "1", + r#"{ profile = { enabled = Bool default true; }; }"#, + ) + .unwrap(), + ConfigSchemaContribution::new( + "builtin:skill-test", + "skills", + "1", + r#"{ skills = {...String} default {}; }"#, + ) + .unwrap(), + ]) + .unwrap(); + + let refreshed = store + .ensure_workspace_config_materialized_with_schema( + "w-config", + "2026-08-14T00:00:00Z", + extended.clone(), + ) + .unwrap(); + assert_eq!(refreshed.snapshot.revision, current.snapshot.revision + 1); + assert_eq!(refreshed.snapshot.digest, current.snapshot.digest); + assert_eq!(refreshed.contract.schema_bundle, extended); + assert_ne!( + refreshed.projection_digest, current.projection_digest, + "the newly defaulted namespace changes the evaluated projection" + ); + assert!( + store + .load_workspace_config_revision("w-config", current.snapshot.revision) + .unwrap() + .is_some() + ); + } + + #[tokio::test] + async fn invalid_toolchain_upgrade_returns_diagnostics_without_mutating_authority() { + let store = open_store().await; + let schema_bundle = WorkspaceConfigSchemaBundle::compose([ConfigSchemaContribution::new( + "builtin:test", + "test", + "1", + r#"{ test = { value = String default "initial"; }; }"#, + ) + .unwrap()]) + .unwrap(); + let current = store + .ensure_workspace_config_materialized_with_schema( + "w-config", + "2026-08-13T00:00:00Z", + schema_bundle.clone(), + ) + .unwrap(); + let legacy_entry = ConfigEntry::new( + path(MAIN_CONFIG_ENTRYPOINT), + ConfigContentType::Decodal, + "{ test = {}; custom = 42; }\n", + ) + .unwrap(); + let legacy_snapshot = + ConfigTreeSnapshot::from_entries(current.snapshot.revision, [legacy_entry.clone()]) + .unwrap(); + let manifest_json = serde_json::to_string(&legacy_snapshot.entries).unwrap(); + store + .with_conn(|conn| { + conn.execute( + "UPDATE workspace_config_entries + SET content = ?1, content_digest = ?2 + WHERE workspace_id = 'w-config' AND path = 'main.dcdl'", + rusqlite::params![legacy_entry.content, legacy_entry.content_digest], + )?; + conn.execute( + "UPDATE workspace_config_trees + SET tree_digest = ?1, decodal_version = '0.2.0', + toolchain_fingerprint = 'sha256:legacy', + projection_digest = 'sha256:legacy-projection' + WHERE workspace_id = 'w-config'", + [legacy_snapshot.digest.as_str()], + )?; + conn.execute( + "UPDATE workspace_config_tree_revisions + SET tree_digest = ?1, toolchain_fingerprint = 'sha256:legacy', + projection_digest = 'sha256:legacy-projection', manifest_json = ?2 + WHERE workspace_id = 'w-config' AND revision = ?3", + rusqlite::params![ + legacy_snapshot.digest, + manifest_json, + current.snapshot.revision + ], + )?; + Ok(()) + }) + .unwrap(); + + let error = store + .ensure_workspace_config_materialized_with_schema( + "w-config", + "2026-08-14T00:00:00Z", + schema_bundle, + ) + .unwrap_err(); + let message = error.to_string(); + assert!(message.contains("Decodal 0.4.0")); + assert!(message.contains("main.dcdl")); + assert!(message.contains("constraintviolation")); + let persisted = store + .with_conn(|conn| { + conn.query_row( + "SELECT revision, decodal_version, toolchain_fingerprint + FROM workspace_config_trees WHERE workspace_id = 'w-config'", + [], + |row| { + Ok(( + row.get::<_, u64>(0)?, + row.get::<_, String>(1)?, + row.get::<_, String>(2)?, + )) + }, + ) + .map_err(Error::from) + }) + .unwrap(); + assert_eq!( + persisted, + ( + current.snapshot.revision, + "0.2.0".into(), + "sha256:legacy".into() + ) + ); + } + #[tokio::test] async fn workspace_materializes_main_entrypoint() { let store = open_store().await; diff --git a/crates/workspace-server/src/hosts.rs b/crates/workspace-server/src/hosts.rs index 490ca9c2..847812d1 100644 --- a/crates/workspace-server/src/hosts.rs +++ b/crates/workspace-server/src/hosts.rs @@ -1201,6 +1201,21 @@ impl RuntimeRegistry { _ => {} } let runtime = self.runtime(runtime_id)?; + if let Some(bundle) = request.resolved_config_bundle.clone() { + let sync = runtime.sync_config_bundle(bundle); + if sync.state != WorkerOperationState::Accepted { + let message = sync + .diagnostics + .first() + .map(|diagnostic| diagnostic.message.clone()) + .unwrap_or_else(|| "Runtime rejected the resolved config bundle".to_string()); + return Err(RuntimeRegistryError::RuntimeOperationFailed { + runtime_id: runtime_id.to_string(), + code: "worker_config_bundle_sync_rejected".to_string(), + message, + }); + } + } Ok(runtime.spawn_worker(request)) } @@ -1960,12 +1975,13 @@ impl WorkspaceWorkerRuntime for EmbeddedWorkerRuntime { } }; let workspace_id = workspace_api.workspace_id.clone(); + let config_bundle = spawn_config_bundle_ref(&request); let create_request = CreateWorkerRequest { idempotency_key, idempotency_fingerprint, profile, display_name: request.requested_worker_name.clone(), - config_bundle: None, + config_bundle, profile_source, initial_input: initial_worker_input(&request.initial_submit), working_directory_request: request.resolved_working_directory_request.clone(), @@ -3090,12 +3106,13 @@ impl WorkspaceWorkerRuntime for RemoteWorkerRuntime { }; } }; + let config_bundle = spawn_config_bundle_ref(&request); let create = CreateWorkerRequest { idempotency_key, idempotency_fingerprint, profile, display_name: request.requested_worker_name.clone(), - config_bundle: None, + config_bundle, profile_source, initial_input: initial_worker_input(&request.initial_submit), working_directory_request: request.resolved_working_directory_request.clone(), @@ -3360,6 +3377,16 @@ fn embedded_worker_projection_diagnostics() -> Vec { )] } +fn spawn_config_bundle_ref(request: &WorkerSpawnRequest) -> Option { + request + .resolved_config_bundle + .as_ref() + .map(|bundle| ConfigBundleRef { + id: bundle.metadata.id.clone(), + digest: bundle.metadata.digest.clone(), + }) +} + fn profile_source_archive_for_request( request: &WorkerSpawnRequest, profile: &ProfileSelector, @@ -3469,6 +3496,7 @@ fn builtin_profile_config_bundle( label: embedded_profile_label(profile), }], declarations: Vec::new(), + prompt_catalog: None, profile_source_archive, profile_source_archive_handle, } @@ -4409,6 +4437,7 @@ mod tests { name: "read".to_string(), reference: "capability:read".to_string(), }], + prompt_catalog: None, profile_source_archive: None, profile_source_archive_handle: None, } @@ -4776,6 +4805,46 @@ mod tests { } } + #[test] + fn spawn_config_bundle_ref_preserves_bundle_identity() { + let mut request = embedded_spawn_request(); + let bundle = test_config_bundle(); + let expected_id = bundle.metadata.id.clone(); + let expected_digest = bundle.metadata.digest.clone(); + request.resolved_config_bundle = Some(bundle); + + let bundle_ref = spawn_config_bundle_ref(&request).expect("bundle reference"); + assert_eq!(bundle_ref.id, expected_id); + assert_eq!(bundle_ref.digest, expected_digest); + } + + #[test] + fn registry_syncs_bundle_before_embedded_spawn() { + let runtime = EmbeddedWorkerRuntime::new_memory_with_execution_backend( + "local:test", + Arc::new(AcceptingExecutionBackend::default()), + ) + .expect("test backend should connect"); + let registry = RuntimeRegistry::for_workspace(runtime); + let mut request = embedded_spawn_request(); + let bundle = test_config_bundle(); + let bundle_ref = ConfigBundleRef { + id: bundle.metadata.id.clone(), + digest: bundle.metadata.digest.clone(), + }; + request.resolved_config_bundle = Some(bundle); + + let result = registry + .spawn_worker("embedded-worker-runtime", request) + .expect("spawn request"); + assert_eq!(result.state, WorkerOperationState::Accepted); + let check = registry + .check_config_bundle("embedded-worker-runtime", bundle_ref) + .expect("bundle check"); + assert_eq!(check.state, WorkerOperationState::Accepted); + assert!(check.availability.is_some()); + } + #[test] fn embedded_runtime_rejects_missing_workspace_api_binding() { let runtime = EmbeddedWorkerRuntime::new_memory_with_execution_backend( diff --git a/crates/workspace-server/src/lib.rs b/crates/workspace-server/src/lib.rs index 811a3f0a..822afc85 100644 --- a/crates/workspace-server/src/lib.rs +++ b/crates/workspace-server/src/lib.rs @@ -15,6 +15,7 @@ pub mod memory_backend; pub mod memory_staging; pub mod observation; pub mod profile_settings; +pub mod prompt_settings; pub mod records; #[cfg(feature = "typescript")] pub use records::ticket_api_typescript; diff --git a/crates/workspace-server/src/main.rs b/crates/workspace-server/src/main.rs index ddbdae42..7d09a1bd 100644 --- a/crates/workspace-server/src/main.rs +++ b/crates/workspace-server/src/main.rs @@ -42,11 +42,16 @@ struct WorkspacePathOptions { workspace: PathBuf, } +#[derive(Debug)] +struct SkillWorkspaceOptions { + workspace_id: String, +} + #[derive(Debug)] enum SkillsCommand { - List(WorkspacePathOptions), - Lint(WorkspacePathOptions), - Show { workspace: PathBuf, name: String }, + List(SkillWorkspaceOptions), + Lint(SkillWorkspaceOptions), + Show { workspace_id: String, name: String }, } #[derive(Debug)] @@ -513,11 +518,13 @@ fn ensure_no_inline_value(flag: &str, inline_value: Option<&str>) -> Result<(), fn run_skills(command: SkillsCommand) -> Result<(), Box> { match command { SkillsCommand::List(options) => { - let catalog = yoi_workspace_server::skills::catalog(&options.workspace); + let state = load_skill_workspace_config(&options.workspace_id)?; + let catalog = yoi_workspace_server::skills::catalog(&state)?; println!("{}", serde_json::to_string_pretty(&catalog)?); } SkillsCommand::Lint(options) => { - let catalog = yoi_workspace_server::skills::lint(&options.workspace); + let state = load_skill_workspace_config(&options.workspace_id)?; + let catalog = yoi_workspace_server::skills::lint(&state)?; println!("{}", serde_json::to_string_pretty(&catalog)?); if catalog .diagnostics @@ -535,14 +542,26 @@ fn run_skills(command: SkillsCommand) -> Result<(), Box> return Err(Box::new(CliError("Skill lint found errors".to_string()))); } } - SkillsCommand::Show { workspace, name } => { - let detail = yoi_workspace_server::skills::detail(&workspace, &name)?; + SkillsCommand::Show { workspace_id, name } => { + let state = load_skill_workspace_config(&workspace_id)?; + let detail = yoi_workspace_server::skills::detail(&state, &name)?; println!("{}", serde_json::to_string_pretty(&detail)?); } } Ok(()) } +fn load_skill_workspace_config( + workspace_id: &str, +) -> Result> { + let store = SqliteWorkspaceStore::open(ServerConfig::default_server_database_path())?; + store.load_workspace_config(workspace_id)?.ok_or_else(|| { + Box::new(CliError(format!( + "Workspace `{workspace_id}` has no active config revision" + ))) as Box + }) +} + async fn run_serve(options: ServeOptions) -> Result<(), Box> { let database_path = ServerConfig::default_server_database_path(); if let Some(parent) = database_path.parent() { @@ -706,17 +725,17 @@ fn parse_skills_command(args: &[String]) -> Result { }; match subcommand.as_str() { "list" => Ok(Command::Skills(SkillsCommand::List( - parse_workspace_path_options(rest)?, + parse_skill_workspace_options(rest)?, ))), "lint" => Ok(Command::Skills(SkillsCommand::Lint( - parse_workspace_path_options(rest)?, + parse_skill_workspace_options(rest)?, ))), "show" => { let Some((name, rest)) = rest.split_first() else { return Err(CliError("skills show requires a Skill name".to_string())); }; Ok(Command::Skills(SkillsCommand::Show { - workspace: parse_workspace_path_options(rest)?.workspace, + workspace_id: parse_skill_workspace_options(rest)?.workspace_id, name: name.to_string(), })) } @@ -730,6 +749,32 @@ fn parse_skills_command(args: &[String]) -> Result { } } +fn parse_skill_workspace_options(args: &[String]) -> Result { + let mut workspace_id = None; + let mut iter = args.iter(); + while let Some(arg) = iter.next() { + match arg.as_str() { + "--workspace" => { + let value = iter + .next() + .ok_or_else(|| CliError("--workspace requires a Workspace id".to_string()))?; + workspace_id = Some(value.clone()); + } + value if value.starts_with("--workspace=") => { + workspace_id = Some(value_after_equals(arg, "--workspace")?.to_string()); + } + other => return Err(CliError(format!("unknown skills option `{other}`"))), + } + } + let workspace_id = workspace_id.ok_or_else(|| { + CliError("skills commands require --workspace ".to_string()) + })?; + if workspace_id.trim().is_empty() { + return Err(CliError("--workspace must not be empty".to_string())); + } + Ok(SkillWorkspaceOptions { workspace_id }) +} + fn parse_workspace_path_options(args: &[String]) -> Result { let mut workspace = std::env::current_dir() .map_err(|error| CliError(format!("failed to read current dir: {error}")))?; @@ -848,7 +893,7 @@ fn print_config_help() { fn print_skills_help() { println!( - "yoi-server skills\n\nUsage:\n yoi-server skills list [OPTIONS]\n yoi-server skills lint [OPTIONS]\n yoi-server skills show [OPTIONS]\n\nDescription:\n Uses the Workspace backend Skill catalog/lint/detail authority. Catalog output is lightweight and omits full SKILL.md bodies; detail output includes the body. allowed-tools and scripts are diagnostics only.\n\nOptions:\n --workspace Workspace root (defaults to cwd)\n -h, --help Print help" + "yoi-server skills\n\nUsage:\n yoi-server skills list --workspace \n yoi-server skills lint --workspace \n yoi-server skills show --workspace \n\nDescription:\n Reads the active Server DB virtual-config revision. Catalog output is lightweight and omits imported Markdown content; detail output includes that content. allowed-tools and scripts are diagnostics only.\n\nOptions:\n --workspace Workspace id in the Server DB (required)\n -h, --help Print help" ); } @@ -874,6 +919,26 @@ mod tests { assert_eq!(options.workspace, temp.path().canonicalize().unwrap()); } + #[test] + fn parse_skills_requires_server_workspace_id() { + let error = parse_skills_command(&["list".to_string()]).unwrap_err(); + assert_eq!( + error.to_string(), + "skills commands require --workspace " + ); + let command = parse_skills_command(&[ + "show".to_string(), + "debug-rust".to_string(), + "--workspace=workspace-a".to_string(), + ]) + .unwrap(); + let Command::Skills(SkillsCommand::Show { workspace_id, name }) = command else { + panic!("expected skills show command"); + }; + assert_eq!(workspace_id, "workspace-a"); + assert_eq!(name, "debug-rust"); + } + #[test] fn parse_serve_accepts_listen_only() { let args = vec!["--listen".to_string(), "127.0.0.1:0".to_string()]; diff --git a/crates/workspace-server/src/profile_settings.rs b/crates/workspace-server/src/profile_settings.rs index be21be8d..357573b6 100644 --- a/crates/workspace-server/src/profile_settings.rs +++ b/crates/workspace-server/src/profile_settings.rs @@ -1,32 +1,311 @@ -use std::collections::{BTreeMap, BTreeSet}; +use std::collections::BTreeMap; use std::fs; use std::path::{Component, Path, PathBuf}; use std::time::UNIX_EPOCH; +use config_source::{ConfigContentType, ConfigSchemaContribution, VirtualPath}; +use manifest::{ProfileSource, resolve_profile_artifact_value}; use serde::{Deserialize, Serialize}; -use sha2::{Digest, Sha256}; use worker_runtime::config_bundle::{ ConfigBundle, ConfigBundleMetadata, ConfigBundleProvenance, ConfigProfileDescriptor, }; use worker_runtime::profile_archive::{ProfileSourceArchive, ProfileSourceArchiveInput}; +use crate::config_source::{ + WorkspaceConfigSchemaProvider, WorkspaceConfigState, evaluate_workspace_config_state, +}; use crate::hosts::{DiagnosticSeverity, RuntimeDiagnostic}; use crate::{Error, Result}; -const PROFILE_REGISTRY_RELATIVE_PATH: &str = ".yoi/profiles.toml"; -const PROFILE_SOURCE_ROOT_RELATIVE_PATH: &str = ".yoi/profiles"; -const PROFILE_SOURCE_TREE_ID: &str = "project"; -const PROFILE_SOURCE_TREE_DISPLAY_ROOT: &str = "profiles"; -const MAX_PROFILE_SOURCE_BYTES: u64 = 256 * 1024; -const BUILTIN_PROFILE_IDS: &[&str] = &[ - "builtin:companion", - "builtin:intake", - "builtin:orchestrator", - "builtin:coder", - "builtin:reviewer", -]; -const BUILTIN_PROFILE_SLUGS: &[&str] = - &["companion", "intake", "orchestrator", "coder", "reviewer"]; +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 []; + }; +}"#; + +#[derive(Debug, Default)] +pub struct ProfileConfigSchemaProvider; + +impl WorkspaceConfigSchemaProvider for ProfileConfigSchemaProvider { + fn contribution(&self) -> Result { + ConfigSchemaContribution::new("builtin:profile", "profile", "1", PROFILE_SCHEMA_SOURCE) + .map_err(|error| Error::Config(error.to_string())) + } +} + +#[derive(Debug, Clone, Deserialize)] +struct VirtualProfileConfig { + profile: VirtualProfileSection, +} + +#[derive(Debug, Clone, Deserialize)] +#[serde(deny_unknown_fields)] +struct VirtualProfileSection { + default_profile: String, + entries: Vec, +} + +#[derive(Debug, Clone, Deserialize)] +#[serde(deny_unknown_fields)] +struct VirtualProfileEntry { + selector: String, + source: String, + label: String, + description: String, +} + +#[derive(Debug, Clone)] +pub struct ProfileConfigProjection { + pub settings: ProfileSettingsResponse, + entries: BTreeMap, + sources: BTreeMap, +} + +pub fn project_profiles_from_workspace_config( + workspace_id: &str, + state: &WorkspaceConfigState, +) -> Result { + let bundle = if state.contract.schema_bundle.contributions.is_empty() { + config_source::WorkspaceConfigSchemaBundle::compose([ + ProfileConfigSchemaProvider.contribution()? + ]) + .map_err(|error| Error::Config(error.to_string()))? + } else { + state.contract.schema_bundle.clone() + }; + let evaluation = evaluate_workspace_config_state(state, bundle)?; + if evaluation.projection_digest != state.projection_digest + && state + .contract + .schema_bundle + .contributions + .iter() + .any(|entry| entry.provider_id == "builtin:profile") + { + return Err(Error::RegistryInconsistency(format!( + "Profile projection digest mismatch for Workspace {workspace_id}" + ))); + } + let projected = evaluation.projections.first().ok_or_else(|| { + Error::RegistryInconsistency("Workspace config has no active projection".to_string()) + })?; + 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:") { + return Err(profile_validation_error( + "profile_selector_invalid", + "Workspace Profile selectors must use project:*", + )); + } + if entries.contains_key(&entry.selector) { + return Err(profile_validation_error( + "profile_selector_duplicate", + "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)?, + ProfileSource::Archive { + archive_id: format!("workspace-config-r{}", state.snapshot.revision), + source: archive_source.clone(), + }, + Path::new("/"), + "workspace-config-validation", + ) + .map_err(|error| profile_validation_error("profile_source_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()), + description: (!entry.description.is_empty()).then(|| entry.description.clone()), + editable: false, + is_default: config.profile.default_profile == entry.selector, + diagnostics: Vec::new(), + }); + 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(), + provenance: WorkspaceProfileSourceProvenance::ProjectProfileSourceTree, + editable: false, + revision: state.snapshot.revision.to_string(), + size_bytes: source_entry.content.len() as u64, + diagnostics: Vec::new(), + }); + entries.insert(entry.selector.clone(), entry); + } + if !profiles + .iter() + .any(|profile| profile.selector == config.profile.default_profile) + { + return Err(profile_validation_error( + "unknown_default_profile", + "Default Profile must select a builtin or Workspace Profile", + )); + } + Ok(ProfileConfigProjection { + settings: ProfileSettingsResponse { + workspace_id: workspace_id.to_string(), + 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), + 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, +) -> Option { + if let Some(selector) = selector_for_builtin_candidate(profile) { + return Some(selector); + } + projection + .entries + .contains_key(profile) + .then(|| worker_runtime::catalog::ProfileSelector::Named(profile.to_string())) +} + +pub fn build_virtual_profile_config_bundle( + projection: &ProfileConfigProjection, + state: &WorkspaceConfigState, + workspace_id: &str, + workspace_created_at: &str, + selector: &str, +) -> Result> { + let archive = projection + .entries + .get(selector) + .map(|entry| build_virtual_profile_archive(selector, entry, &projection.sources, state)) + .transpose()?; + let profile_selector = selector_for_builtin_candidate(selector) + .unwrap_or_else(|| worker_runtime::catalog::ProfileSelector::Named(selector.to_string())); + let bundle = ConfigBundle { + metadata: ConfigBundleMetadata { + id: format!("workspace-config-profile-r{}", state.snapshot.revision), + digest: String::new(), + revision: state.snapshot.revision.to_string(), + workspace_id: workspace_id.to_string(), + created_at: workspace_created_at.to_string(), + provenance: ConfigBundleProvenance { + source: "workspace_config".to_string(), + detail: Some(format!( + "revision={} tree={} projection={}", + state.snapshot.revision, state.snapshot.digest, state.projection_digest + )), + }, + }, + profiles: vec![ConfigProfileDescriptor { + selector: profile_selector, + label: Some(selector.to_string()), + }], + declarations: Vec::new(), + prompt_catalog: Some(crate::prompt_settings::project_prompts_from_workspace_config(state)?), + profile_source_archive: archive, + profile_source_archive_handle: None, + } + .with_computed_digest(); + Ok(Some(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())) +} #[derive(Debug, Clone, Serialize, Deserialize)] pub struct WorkspaceMetadataSettingsResponse { @@ -55,12 +334,16 @@ pub struct WorkspaceMetadataMutationResponse { pub struct ProfileSettingsResponse { pub workspace_id: String, pub registry_revision: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub config_revision: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub tree_digest: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub projection_digest: Option, #[serde(skip_serializing_if = "Option::is_none")] pub default_profile: Option, pub profiles: Vec, pub sources: Vec, - #[serde(default)] - pub source_trees: Vec, pub diagnostics: Vec, } @@ -99,132 +382,6 @@ pub enum WorkspaceProfileSourceProvenance { ProjectProfileSourceTree, } -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct WorkspaceProfileSourceTreeSummary { - pub source_tree_id: String, - pub label: String, - pub root_path: String, - pub kind: String, - pub content_type: String, - pub content_digest: String, - pub provenance: WorkspaceProfileSourceProvenance, - pub editable: bool, - pub revision: String, - pub file_count: usize, - pub diagnostics: Vec, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct WorkspaceProfileSourceTreeFileSummary { - pub path: String, - pub kind: String, - pub content_type: String, - pub content_digest: String, - pub provenance: WorkspaceProfileSourceProvenance, - pub editable: bool, - pub revision: String, - pub size_bytes: u64, - pub diagnostics: Vec, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct WorkspaceProfileSourceTreeResponse { - pub workspace_id: String, - pub tree: WorkspaceProfileSourceTreeSummary, - pub files: Vec, - pub diagnostics: Vec, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct WorkspaceProfileSourceTreeFileResponse { - pub workspace_id: String, - pub source_tree_id: String, - pub file: WorkspaceProfileSourceTreeFileSummary, - pub content: String, - pub diagnostics: Vec, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct WorkspaceProfileSourceDetailResponse { - pub workspace_id: String, - pub profile: WorkspaceProfileSummary, - pub source: WorkspaceProfileSourceSummary, - pub content: String, - pub diagnostics: Vec, -} - -#[derive(Debug, Clone, Deserialize)] -#[serde(deny_unknown_fields)] -pub struct CreateWorkspaceProfileSourceRequest { - pub name: String, - #[serde(default)] - pub description: Option, - pub content: String, - pub registry_revision: String, -} - -#[derive(Debug, Clone, Deserialize)] -#[serde(deny_unknown_fields)] -pub struct UpdateWorkspaceProfileRegistryRequest { - pub registry_revision: String, - #[serde(default)] - pub default_profile: Option, - pub profiles: Vec, -} - -#[derive(Debug, Clone, Deserialize)] -#[serde(deny_unknown_fields)] -pub struct WorkspaceProfileRegistryEntryUpdate { - pub name: String, - #[serde(default)] - pub description: Option, - #[serde(default)] - pub profile_source_id: Option, -} - -#[derive(Debug, Clone, Deserialize)] -#[serde(deny_unknown_fields)] -pub struct UpdateWorkspaceProfileSourceRequest { - pub content: String, - pub revision: String, -} - -#[derive(Debug, Clone, Deserialize)] -#[serde(deny_unknown_fields)] -pub struct WriteWorkspaceProfileTreeFileRequest { - pub path: String, - pub content: String, - #[serde(default)] - pub revision: Option, -} - -#[derive(Debug, Clone, Deserialize)] -#[serde(deny_unknown_fields)] -pub struct DeleteWorkspaceProfileTreeFileRequest { - pub path: String, - pub revision: String, -} - -#[derive(Debug, Clone, Deserialize)] -#[serde(deny_unknown_fields)] -pub struct ReadWorkspaceProfileTreeFileQuery { - pub path: String, -} - -#[derive(Debug, Clone, Deserialize)] -#[serde(deny_unknown_fields)] -pub struct DeleteWorkspaceProfileSourceRequest { - pub registry_revision: String, - pub source_revision: String, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct ProfileSettingsMutationResponse { - pub workspace_id: String, - pub settings: ProfileSettingsResponse, - pub diagnostics: Vec, -} - #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(deny_unknown_fields)] struct WorkspaceIdentityFile { @@ -233,37 +390,6 @@ struct WorkspaceIdentityFile { display_name: String, } -#[derive(Debug, Clone, Default, Serialize, Deserialize)] -#[serde(deny_unknown_fields)] -struct ProfileRegistryDocument { - #[serde(default)] - default: Option, - #[serde(default)] - profile: BTreeMap, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -#[serde(untagged)] -enum ProfileEntryFile { - Path(String), - Table(ProfileEntryTable), -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -#[serde(deny_unknown_fields)] -struct ProfileEntryTable { - path: String, - #[serde(default)] - description: Option, -} - -#[derive(Debug, Clone)] -struct ProjectProfileEntry { - name: String, - description: Option, - relative_path: PathBuf, -} - pub fn workspace_metadata_settings( workspace_root: &Path, fallback_workspace_id: &str, @@ -355,481 +481,6 @@ pub fn update_workspace_metadata( )) } -pub fn load_profile_settings(workspace_id: &str, workspace_root: &Path) -> ProfileSettingsResponse { - let mut diagnostics = Vec::new(); - let registry = match read_registry(workspace_root) { - Ok(registry) => registry, - Err(err) => { - diagnostics.push(diagnostic( - "profile_registry_schema_invalid", - DiagnosticSeverity::Error, - err, - )); - ProfileRegistryDocument::default() - } - }; - let registry_revision = file_revision(®istry_path(workspace_root)); - let mut profiles = builtin_profile_summaries(registry.default.as_deref()); - let mut sources = Vec::new(); - let mut seen_selectors = BTreeSet::new(); - for profile in &profiles { - seen_selectors.insert(profile.selector.clone()); - } - for entry in project_entries(®istry, &mut diagnostics) { - let selector = project_selector(&entry.name); - let source_id = project_source_id(&entry.name); - let mut entry_diagnostics = Vec::new(); - if !seen_selectors.insert(selector.clone()) { - entry_diagnostics.push(diagnostic( - "profile_selector_duplicate", - DiagnosticSeverity::Error, - format!("Profile selector '{selector}' is duplicated."), - )); - } - let source_summary = summarize_source(workspace_root, &source_id, &entry.relative_path); - entry_diagnostics.extend(source_summary.diagnostics.clone()); - entry_diagnostics.extend(validate_project_profile_entry(workspace_root, &entry)); - if BUILTIN_PROFILE_SLUGS.contains(&entry.name.as_str()) { - entry_diagnostics.push(diagnostic( - "profile_selector_duplicate", - DiagnosticSeverity::Error, - format!( - "Project profile '{}' conflicts with a builtin selector.", - entry.name - ), - )); - } - profiles.push(WorkspaceProfileSummary { - profile_id: selector.clone(), - selector, - label: entry.name.clone(), - source_kind: "project".to_string(), - profile_source_id: Some(source_id.clone()), - description: entry.description.clone(), - editable: true, - is_default: registry.default.as_deref() == Some(project_selector(&entry.name).as_str()), - diagnostics: entry_diagnostics, - }); - sources.push(source_summary); - } - let mut default_diagnostics = validate_registry_default(®istry) - .err() - .map(|err| vec![diagnostic_from_error(&err)]) - .unwrap_or_default(); - diagnostics.extend( - profiles - .iter() - .filter(|profile| profile.source_kind == "project") - .flat_map(|profile| profile.diagnostics.clone()), - ); - diagnostics.append(&mut default_diagnostics); - ProfileSettingsResponse { - workspace_id: workspace_id.to_string(), - registry_revision, - default_profile: registry.default, - profiles, - sources, - source_trees: vec![profile_source_tree_summary(workspace_root)], - diagnostics, - } -} - -pub fn read_profile_source( - workspace_id: &str, - workspace_root: &Path, - source_id: &str, -) -> Result { - let registry = read_registry(workspace_root).map_err(profile_registry_error)?; - let (name, entry) = entry_for_source_id(®istry, source_id)?; - let full = checked_source_path(workspace_root, &entry.relative_path)?; - let metadata = source_metadata(&full)?; - if metadata.len() > MAX_PROFILE_SOURCE_BYTES { - return Err(Error::RuntimeOperationFailed { - runtime_id: "workspace-backend".to_string(), - code: "profile_source_too_large".to_string(), - message: "Profile source is too large for browser editing".to_string(), - }); - } - let content = fs::read_to_string(&full)?; - let source = summarize_source(workspace_root, source_id, &entry.relative_path); - let selector = project_selector(&name); - let profile = WorkspaceProfileSummary { - profile_id: selector.clone(), - selector: selector.clone(), - label: name, - source_kind: "project".to_string(), - profile_source_id: Some(source_id.to_string()), - description: entry.description, - editable: true, - is_default: registry.default.as_deref() == Some(selector.as_str()), - diagnostics: source.diagnostics.clone(), - }; - Ok(WorkspaceProfileSourceDetailResponse { - workspace_id: workspace_id.to_string(), - profile, - source, - content, - diagnostics: Vec::new(), - }) -} - -pub fn create_profile_source( - workspace_id: &str, - workspace_root: &Path, - request: CreateWorkspaceProfileSourceRequest, -) -> Result { - let registry_path = registry_path(workspace_root); - ensure_revision( - ®istry_path, - &request.registry_revision, - "profile_registry_revision_conflict", - )?; - let mut registry = read_registry(workspace_root).map_err(profile_registry_error)?; - let name = validate_profile_name(&request.name)?; - if registry.profile.contains_key(&name) || BUILTIN_PROFILE_SLUGS.contains(&name.as_str()) { - return Err(Error::RuntimeOperationFailed { - runtime_id: "workspace-backend".to_string(), - code: "profile_selector_duplicate".to_string(), - message: "Profile selector already exists".to_string(), - }); - } - let relative_path = PathBuf::from(".yoi") - .join("profiles") - .join(format!("{name}.dcdl")); - validate_source_content(workspace_root, &name, &relative_path, &request.content)?; - let full = prepare_source_path_for_write(workspace_root, &relative_path)?; - fs::write(&full, request.content)?; - registry.profile.insert( - name.clone(), - ProfileEntryFile::Table(ProfileEntryTable { - path: format!("profiles/{name}.dcdl"), - description: request - .description - .and_then(|value| optional_trim(value.as_str())), - }), - ); - validate_registry_default(®istry)?; - validate_all_project_profiles(workspace_root, ®istry)?; - write_registry(workspace_root, ®istry)?; - Ok(ProfileSettingsMutationResponse { - workspace_id: workspace_id.to_string(), - settings: load_profile_settings(workspace_id, workspace_root), - diagnostics: vec![diagnostic( - "profile_settings_updated", - DiagnosticSeverity::Info, - "Profile source was created and profile discovery was refreshed.", - )], - }) -} - -pub fn update_profile_registry( - workspace_id: &str, - workspace_root: &Path, - request: UpdateWorkspaceProfileRegistryRequest, -) -> Result { - let path = registry_path(workspace_root); - ensure_revision( - &path, - &request.registry_revision, - "profile_registry_revision_conflict", - )?; - let mut profile = BTreeMap::new(); - let mut seen = BTreeSet::new(); - for update in request.profiles { - let name = validate_profile_name(&update.name)?; - if !seen.insert(name.clone()) || BUILTIN_PROFILE_SLUGS.contains(&name.as_str()) { - return Err(Error::RuntimeOperationFailed { - runtime_id: "workspace-backend".to_string(), - code: "profile_selector_duplicate".to_string(), - message: "Profile selector duplicate in registry update".to_string(), - }); - } - let source_id = update - .profile_source_id - .as_deref() - .unwrap_or(project_source_id(&name).as_str()) - .to_string(); - let (source_name, _) = parse_project_source_id(&source_id)?; - if source_name != name { - return Err(Error::RuntimeOperationFailed { - runtime_id: "workspace-backend".to_string(), - code: "profile_source_id_mismatch".to_string(), - message: "Profile source id must match its registry selector".to_string(), - }); - } - profile.insert( - name.clone(), - ProfileEntryFile::Table(ProfileEntryTable { - path: format!("profiles/{name}.dcdl"), - description: update - .description - .and_then(|value| optional_trim(value.as_str())), - }), - ); - } - let registry = ProfileRegistryDocument { - default: request.default_profile, - profile, - }; - validate_registry_default(®istry)?; - validate_all_project_profiles(workspace_root, ®istry)?; - write_registry(workspace_root, ®istry)?; - Ok(ProfileSettingsMutationResponse { - workspace_id: workspace_id.to_string(), - settings: load_profile_settings(workspace_id, workspace_root), - diagnostics: vec![diagnostic( - "profile_registry_updated", - DiagnosticSeverity::Info, - "Profile registry was updated and profile discovery was refreshed.", - )], - }) -} - -pub fn update_profile_source( - workspace_id: &str, - workspace_root: &Path, - source_id: &str, - request: UpdateWorkspaceProfileSourceRequest, -) -> Result { - let registry = read_registry(workspace_root).map_err(profile_registry_error)?; - let (name, entry) = entry_for_source_id(®istry, source_id)?; - let full = checked_source_path(workspace_root, &entry.relative_path)?; - ensure_revision(&full, &request.revision, "profile_source_revision_conflict")?; - validate_source_content( - workspace_root, - &name, - &entry.relative_path, - &request.content, - )?; - fs::write(&full, request.content)?; - Ok(ProfileSettingsMutationResponse { - workspace_id: workspace_id.to_string(), - settings: load_profile_settings(workspace_id, workspace_root), - diagnostics: vec![diagnostic( - "profile_source_updated", - DiagnosticSeverity::Info, - "Profile source was updated and profile discovery was refreshed.", - )], - }) -} - -pub fn delete_profile_source( - workspace_id: &str, - workspace_root: &Path, - source_id: &str, - request: DeleteWorkspaceProfileSourceRequest, -) -> Result { - let registry_path = registry_path(workspace_root); - ensure_revision( - ®istry_path, - &request.registry_revision, - "profile_registry_revision_conflict", - )?; - let mut registry = read_registry(workspace_root).map_err(profile_registry_error)?; - let (name, entry) = entry_for_source_id(®istry, source_id)?; - let full = checked_source_path(workspace_root, &entry.relative_path)?; - ensure_revision( - &full, - &request.source_revision, - "profile_source_revision_conflict", - )?; - registry.profile.remove(&name); - if registry.default.as_deref() == Some(project_selector(&name).as_str()) { - registry.default = None; - } - if full.exists() { - fs::remove_file(&full)?; - } - write_registry(workspace_root, ®istry)?; - Ok(ProfileSettingsMutationResponse { - workspace_id: workspace_id.to_string(), - settings: load_profile_settings(workspace_id, workspace_root), - diagnostics: vec![diagnostic( - "profile_source_deleted", - DiagnosticSeverity::Info, - "Profile source and registry entry were deleted and profile discovery was refreshed.", - )], - }) -} - -pub fn read_profile_source_tree( - workspace_id: &str, - workspace_root: &Path, - source_tree_id: &str, -) -> Result { - ensure_profile_source_tree_id(source_tree_id)?; - let files = list_profile_tree_files(workspace_root)?; - Ok(WorkspaceProfileSourceTreeResponse { - workspace_id: workspace_id.to_string(), - tree: profile_source_tree_summary_with_count(workspace_root, files.len()), - files, - diagnostics: Vec::new(), - }) -} - -pub fn read_profile_tree_file( - workspace_id: &str, - workspace_root: &Path, - source_tree_id: &str, - query: ReadWorkspaceProfileTreeFileQuery, -) -> Result { - ensure_profile_source_tree_id(source_tree_id)?; - let relative_path = relative_source_path_for_virtual_path(&query.path)?; - let full = checked_source_path(workspace_root, &relative_path)?; - let metadata = source_metadata(&full)?; - if metadata.len() > MAX_PROFILE_SOURCE_BYTES { - return Err(profile_validation_error( - "profile_source_too_large", - "Profile source is too large for browser editing", - )); - } - let content = fs::read_to_string(&full)?; - Ok(WorkspaceProfileSourceTreeFileResponse { - workspace_id: workspace_id.to_string(), - source_tree_id: source_tree_id.to_string(), - file: summarize_tree_file(&full, display_source_path(&relative_path)), - content, - diagnostics: Vec::new(), - }) -} - -pub fn write_profile_tree_file( - workspace_id: &str, - workspace_root: &Path, - source_tree_id: &str, - request: WriteWorkspaceProfileTreeFileRequest, -) -> Result { - ensure_profile_source_tree_id(source_tree_id)?; - if request.content.as_bytes().len() as u64 > MAX_PROFILE_SOURCE_BYTES { - return Err(profile_validation_error( - "profile_source_too_large", - "Profile source exceeds the browser editing size limit", - )); - } - let relative_path = relative_source_path_for_virtual_path(&request.path)?; - let full = prepare_source_path_for_write(workspace_root, &relative_path)?; - if let Some(expected) = request.revision.as_deref() { - ensure_revision(&full, expected, "profile_source_revision_conflict")?; - } else if full.exists() { - return Err(profile_validation_error( - "profile_source_revision_required", - "Existing profile source edits require a revision token", - )); - } - validate_tree_content(workspace_root, &relative_path, &request.content)?; - fs::write(&full, &request.content)?; - Ok(WorkspaceProfileSourceTreeFileResponse { - workspace_id: workspace_id.to_string(), - source_tree_id: source_tree_id.to_string(), - file: summarize_tree_file(&full, display_source_path(&relative_path)), - content: request.content, - diagnostics: vec![diagnostic( - "profile_source_file_written", - DiagnosticSeverity::Info, - "Profile source file was written through the source tree API.", - )], - }) -} - -pub fn delete_profile_tree_file( - workspace_id: &str, - workspace_root: &Path, - source_tree_id: &str, - request: DeleteWorkspaceProfileTreeFileRequest, -) -> Result { - ensure_profile_source_tree_id(source_tree_id)?; - let relative_path = relative_source_path_for_virtual_path(&request.path)?; - let full = checked_source_path(workspace_root, &relative_path)?; - ensure_revision(&full, &request.revision, "profile_source_revision_conflict")?; - let registry = read_registry(workspace_root).map_err(profile_registry_error)?; - if project_entries(®istry, &mut Vec::new()) - .iter() - .any(|entry| entry.relative_path == relative_path) - { - return Err(profile_validation_error( - "profile_source_registered", - "Registered profile entry sources must be deleted through the profile registry API", - )); - } - if full.exists() { - fs::remove_file(full)?; - } - read_profile_source_tree(workspace_id, workspace_root, source_tree_id) -} - -pub fn build_workspace_profile_archive( - workspace_root: &Path, - selector: &str, -) -> Result> { - if !selector.starts_with("project:") { - return Ok(None); - } - let registry = read_registry(workspace_root).map_err(profile_registry_error)?; - let sources = read_profile_source_tree_contents(workspace_root)?; - let archive = build_profile_archive_for_selector(®istry, sources, selector)?; - archive - .verify() - .and_then(|verified| { - verified - .resolve_profile(selector, workspace_root, "workspace-settings-validation") - .map(|_| ()) - }) - .map_err(|err| Error::RuntimeOperationFailed { - runtime_id: "workspace-backend".to_string(), - code: "profile_source_invalid".to_string(), - message: err.to_string(), - })?; - Ok(Some(archive)) -} - -pub fn build_workspace_profile_config_bundle( - workspace_root: &Path, - workspace_id: &str, - workspace_created_at: &str, - selector: &str, -) -> Result> { - let Some(archive) = build_workspace_profile_archive(workspace_root, selector)? else { - return Ok(None); - }; - let bundle = ConfigBundle { - metadata: ConfigBundleMetadata { - id: "workspace-project-profile-settings-v1".to_string(), - digest: String::new(), - revision: file_revision(®istry_path(workspace_root)), - workspace_id: workspace_id.to_string(), - created_at: workspace_created_at.to_string(), - provenance: ConfigBundleProvenance { - source: "workspace_profile_settings".to_string(), - detail: Some("workspace Decodal profile registry".to_string()), - }, - }, - profiles: vec![ConfigProfileDescriptor { - selector: worker_runtime::catalog::ProfileSelector::Named(selector.to_string()), - label: Some(selector.to_string()), - }], - declarations: Vec::new(), - profile_source_archive: Some(archive), - profile_source_archive_handle: None, - } - .with_computed_digest(); - Ok(Some(bundle)) -} - -pub fn project_profile_candidates(workspace_root: &Path) -> Vec { - load_profile_settings("workspace", workspace_root) - .profiles - .into_iter() - .filter(|profile| profile.source_kind == "project" && !has_error(&profile.diagnostics)) - .collect() -} - -pub fn is_profile_candidate(workspace_root: &Path, profile_id: &str) -> bool { - BUILTIN_PROFILE_IDS.contains(&profile_id) - || project_profile_candidates(workspace_root) - .into_iter() - .any(|profile| profile.profile_id == profile_id) -} - fn builtin_profile_summaries(default_profile: Option<&str>) -> Vec { let labels = [ ( @@ -866,90 +517,6 @@ fn builtin_profile_summaries(default_profile: Option<&str>) -> Vec Vec { - let full = match checked_source_path(workspace_root, &entry.relative_path) { - Ok(path) => path, - Err(err) => return vec![diagnostic_from_error(&err)], - }; - match fs::read_to_string(&full) { - Ok(content) => { - validate_source_content(workspace_root, &entry.name, &entry.relative_path, &content) - .err() - .map(|err| vec![diagnostic_from_error(&err)]) - .unwrap_or_default() - } - Err(err) if err.kind() == std::io::ErrorKind::NotFound => vec![diagnostic( - "profile_source_missing", - DiagnosticSeverity::Error, - format!("Profile source '{}' is missing.", entry.name), - )], - Err(err) => vec![diagnostic( - "profile_source_read_failed", - DiagnosticSeverity::Error, - sanitize_error(&err.to_string()), - )], - } -} - -fn diagnostic_from_error(err: &Error) -> RuntimeDiagnostic { - match err { - Error::RuntimeOperationFailed { code, message, .. } => diagnostic( - code.clone(), - DiagnosticSeverity::Error, - sanitize_error(message), - ), - other => diagnostic( - "profile_settings_failed", - DiagnosticSeverity::Error, - sanitize_error(&other.to_string()), - ), - } -} - -fn has_error(diagnostics: &[RuntimeDiagnostic]) -> bool { - diagnostics - .iter() - .any(|diagnostic| diagnostic.severity == DiagnosticSeverity::Error) -} - -fn validate_all_project_profiles( - workspace_root: &Path, - registry: &ProfileRegistryDocument, -) -> Result<()> { - let mut diagnostics = Vec::new(); - let entries = project_entries(registry, &mut diagnostics); - if let Some(diagnostic) = diagnostics - .into_iter() - .find(|diagnostic| diagnostic.severity == DiagnosticSeverity::Error) - { - return Err(profile_validation_error( - diagnostic.code, - diagnostic.message, - )); - } - for entry in entries { - if BUILTIN_PROFILE_SLUGS.contains(&entry.name.as_str()) { - return Err(profile_validation_error( - "profile_selector_duplicate", - "Project profile conflicts with a builtin selector", - )); - } - if let Some(diagnostic) = validate_project_profile_entry(workspace_root, &entry) - .into_iter() - .find(|diagnostic| diagnostic.severity == DiagnosticSeverity::Error) - { - return Err(profile_validation_error( - diagnostic.code, - diagnostic.message, - )); - } - } - Ok(()) -} - fn profile_validation_error(code: impl Into, message: impl Into) -> Error { Error::RuntimeOperationFailed { runtime_id: "workspace-backend".to_string(), @@ -958,268 +525,6 @@ fn profile_validation_error(code: impl Into, message: impl Into) } } -fn profile_registry_error(message: String) -> Error { - profile_validation_error("profile_registry_schema_invalid", sanitize_error(&message)) -} - -fn validate_registry_default(registry: &ProfileRegistryDocument) -> Result<()> { - let Some(value) = registry.default.as_deref() else { - return Ok(()); - }; - if BUILTIN_PROFILE_IDS.contains(&value) { - return Ok(()); - } - if let Some(name) = value.strip_prefix("project:") { - let name = validate_profile_name(name)?; - if registry.profile.contains_key(&name) { - return Ok(()); - } - return Err(profile_validation_error( - "profile_default_unknown", - "Default project profile selector is not present in the workspace profile registry", - )); - } - Err(profile_validation_error( - "profile_default_invalid", - "Default profile must be a Backend-published builtin or project selector", - )) -} - -fn profile_source_tree_summary(workspace_root: &Path) -> WorkspaceProfileSourceTreeSummary { - let file_count = list_profile_tree_files(workspace_root) - .map(|files| files.len()) - .unwrap_or(0); - profile_source_tree_summary_with_count(workspace_root, file_count) -} - -fn profile_source_tree_summary_with_count( - workspace_root: &Path, - file_count: usize, -) -> WorkspaceProfileSourceTreeSummary { - WorkspaceProfileSourceTreeSummary { - source_tree_id: PROFILE_SOURCE_TREE_ID.to_string(), - label: "Project profile sources".to_string(), - root_path: PROFILE_SOURCE_TREE_DISPLAY_ROOT.to_string(), - kind: "decodal_source_tree".to_string(), - content_type: "application/vnd.yoi.profile-source-tree+json".to_string(), - content_digest: profile_source_tree_digest(workspace_root) - .unwrap_or_else(|_| "sha256:unavailable".to_string()), - provenance: WorkspaceProfileSourceProvenance::ProjectProfileSourceTree, - editable: true, - revision: file_revision(&workspace_root.join(PROFILE_SOURCE_ROOT_RELATIVE_PATH)), - file_count, - diagnostics: Vec::new(), - } -} - -fn ensure_profile_source_tree_id(source_tree_id: &str) -> Result<()> { - if source_tree_id == PROFILE_SOURCE_TREE_ID { - Ok(()) - } else { - Err(Error::RuntimeOperationFailed { - runtime_id: "workspace-backend".to_string(), - code: "unknown_profile_source_tree".to_string(), - message: "Unknown profile source tree".to_string(), - }) - } -} - -fn list_profile_tree_files( - workspace_root: &Path, -) -> Result> { - let Some(source_root) = existing_profile_source_root(workspace_root)? else { - return Ok(Vec::new()); - }; - let mut files = Vec::new(); - collect_profile_tree_files(workspace_root, &source_root, &source_root.path, &mut files)?; - files.sort_by(|a, b| a.path.cmp(&b.path)); - Ok(files) -} - -fn collect_profile_tree_files( - workspace_root: &Path, - source_root: &ProfileSourceRoot, - dir: &Path, - files: &mut Vec, -) -> Result<()> { - let canonical_dir = fs::canonicalize(dir)?; - if !canonical_dir.starts_with(&source_root.canonical_path) { - return Err(profile_source_symlink_escape( - "Profile source directory resolves outside the workspace profile source root", - )); - } - for entry in fs::read_dir(dir)? { - let entry = entry?; - let path = entry.path(); - let file_type = entry.file_type()?; - if file_type.is_symlink() { - return Err(profile_source_symlink_escape( - "Profile source tree entries must not be symlinks", - )); - } - if file_type.is_dir() { - collect_profile_tree_files(workspace_root, source_root, &path, files)?; - } else if file_type.is_file() - && path.extension().and_then(|value| value.to_str()) == Some("dcdl") - { - let canonical_file = fs::canonicalize(&path)?; - if !canonical_file.starts_with(&source_root.canonical_path) - || !canonical_file.starts_with(&source_root.canonical_workspace) - { - return Err(profile_source_symlink_escape( - "Profile source file resolves outside the workspace profile source root", - )); - } - let relative = path - .strip_prefix(workspace_root) - .map_err(|_| { - profile_validation_error( - "profile_source_path_invalid", - "Profile source is outside workspace", - ) - })? - .to_path_buf(); - files.push(summarize_tree_file(&path, display_source_path(&relative))); - } - } - Ok(()) -} - -fn summarize_tree_file(path: &Path, virtual_path: String) -> WorkspaceProfileSourceTreeFileSummary { - WorkspaceProfileSourceTreeFileSummary { - path: virtual_path, - kind: "decodal".to_string(), - content_type: "text/x-decodal".to_string(), - content_digest: file_content_digest(path), - provenance: WorkspaceProfileSourceProvenance::ProjectProfileSourceTree, - editable: true, - revision: file_revision(path), - size_bytes: source_metadata(path) - .map(|metadata| metadata.len()) - .unwrap_or(0), - diagnostics: Vec::new(), - } -} - -fn relative_source_path_for_virtual_path(virtual_path: &str) -> Result { - let normalized = normalize_virtual_profile_source_path(virtual_path)?; - let Some(rest) = normalized.strip_prefix("profiles/") else { - return Err(profile_validation_error( - "profile_source_path_invalid", - "Profile source paths must be under profiles/", - )); - }; - if rest.is_empty() || !rest.ends_with(".dcdl") { - return Err(profile_validation_error( - "profile_source_path_invalid", - "Profile source files must use the .dcdl extension", - )); - } - Ok(Path::new(PROFILE_SOURCE_ROOT_RELATIVE_PATH).join(rest)) -} - -fn display_source_path(relative_path: &Path) -> String { - let profile_root = Path::new(PROFILE_SOURCE_ROOT_RELATIVE_PATH); - let relative = relative_path - .strip_prefix(profile_root) - .unwrap_or(relative_path); - let rest = relative.to_string_lossy().replace('\\', "/"); - format!("{PROFILE_SOURCE_TREE_DISPLAY_ROOT}/{rest}") -} - -fn normalize_virtual_profile_source_path(path: &str) -> Result { - let path = path - .strip_prefix("project:") - .or_else(|| path.strip_prefix("workspace:")) - .unwrap_or(path); - if path.is_empty() || path.contains("://") || Path::new(path).is_absolute() { - return Err(profile_validation_error( - "profile_source_path_invalid", - "Profile source path must be a virtual relative path", - )); - } - let mut normalized = PathBuf::new(); - for component in Path::new(path).components() { - match component { - Component::CurDir => {} - Component::Normal(value) => normalized.push(value), - Component::ParentDir | Component::RootDir | Component::Prefix(_) => { - return Err(profile_validation_error( - "profile_source_path_invalid", - "Profile source path must not escape the virtual source tree", - )); - } - } - } - let normalized = normalized.to_string_lossy().replace('\\', "/"); - if normalized.is_empty() { - return Err(profile_validation_error( - "profile_source_path_invalid", - "Profile source path must not be empty", - )); - } - Ok(normalized) -} - -fn read_profile_source_tree_contents(workspace_root: &Path) -> Result> { - let mut sources = BTreeMap::new(); - for file in list_profile_tree_files(workspace_root)? { - let relative = relative_source_path_for_virtual_path(&file.path)?; - let full = checked_source_path(workspace_root, &relative)?; - sources.insert(file.path, fs::read_to_string(full)?); - } - Ok(sources) -} - -fn build_profile_archive_from_tree_sources( - registry: &ProfileRegistryDocument, - sources: BTreeMap, -) -> Result<(ProfileSourceArchive, BTreeMap)> { - let mut entrypoints = BTreeMap::new(); - for entry in project_entries(registry, &mut Vec::new()) { - let path = display_source_path(&entry.relative_path); - if sources.contains_key(&path) { - entrypoints.insert(project_selector(&entry.name), path); - } - } - build_profile_archive_from_source_set(entrypoints, sources) -} - -fn build_profile_archive_for_selector( - registry: &ProfileRegistryDocument, - sources: BTreeMap, - selector: &str, -) -> Result { - let Some(name) = selector.strip_prefix("project:") else { - return Err(profile_validation_error( - "profile_selector_invalid", - "Project profile archive selector must use project:*", - )); - }; - let entry = project_entries(registry, &mut Vec::new()) - .into_iter() - .find(|entry| entry.name == name) - .ok_or_else(|| { - profile_validation_error( - "unknown_profile_selector", - "Selected project profile is not present in the workspace profile registry", - ) - })?; - let root_path = display_source_path(&entry.relative_path); - if !sources.contains_key(&root_path) { - return Err(profile_validation_error( - "profile_source_missing", - "Selected project profile source is missing from the source tree", - )); - } - let mut closure_sources = BTreeMap::new(); - let mut imports = BTreeMap::new(); - collect_profile_import_closure(&root_path, &sources, &mut closure_sources, &mut imports)?; - let mut entrypoints = BTreeMap::new(); - entrypoints.insert(selector.to_string(), root_path); - build_profile_archive_from_source_set_with_imports(entrypoints, closure_sources, imports) -} - fn collect_profile_import_closure( current_path: &str, all_sources: &BTreeMap, @@ -1252,52 +557,6 @@ fn collect_profile_import_closure( Ok(()) } -fn build_profile_archive_from_source_set( - entrypoints: BTreeMap, - sources: BTreeMap, -) -> Result<(ProfileSourceArchive, BTreeMap)> { - let mut imports = BTreeMap::new(); - let source_snapshot = sources.clone(); - for (current_path, content) in &source_snapshot { - for specifier in collect_decodal_import_specifiers(content) { - let target = resolve_profile_source_import(current_path, &specifier)?; - if source_snapshot.contains_key(&target) { - imports.insert(format!("{current_path}\0{specifier}"), target); - } else { - return Err(profile_validation_error( - "profile_source_import_missing", - &format!( - "Profile source import {specifier:?} from {current_path} resolves to missing {target}" - ), - )); - } - } - } - build_profile_archive_from_source_set_with_imports(entrypoints, sources, imports.clone()) - .map(|archive| (archive, imports)) -} - -fn build_profile_archive_from_source_set_with_imports( - entrypoints: BTreeMap, - mut sources: BTreeMap, - imports: BTreeMap, -) -> Result { - if sources.is_empty() { - sources.insert("profiles/.empty.dcdl".to_string(), "{}".to_string()); - } - ProfileSourceArchive::build(ProfileSourceArchiveInput { - id: "workspace-project-decodal-profiles-v1".to_string(), - entrypoints, - imports, - sources, - }) - .map_err(|err| Error::RuntimeOperationFailed { - runtime_id: "workspace-backend".to_string(), - code: "profile_source_archive_invalid".to_string(), - message: err.to_string(), - }) -} - 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( @@ -1325,15 +584,34 @@ fn resolve_profile_source_import(current_path: &str, specifier: &str) -> Result< .join(raw) }; let normalized = normalize_virtual_profile_source_path(&base.to_string_lossy())?; - if !normalized.starts_with("profiles/") { - return Err(profile_validation_error( - "profile_source_import_invalid", - "Profile source import must resolve inside the profiles/ tree", - )); - } 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() { @@ -1365,507 +643,6 @@ fn collect_decodal_import_specifiers(content: &str) -> Vec { specifiers } -fn validate_tree_content(workspace_root: &Path, relative_path: &Path, content: &str) -> Result<()> { - let mut sources = read_profile_source_tree_contents(workspace_root)?; - sources.insert(display_source_path(relative_path), content.to_string()); - let registry = read_registry(workspace_root).map_err(profile_registry_error)?; - let (archive, _) = build_profile_archive_from_tree_sources(®istry, sources)?; - archive - .verify() - .map_err(|err| Error::RuntimeOperationFailed { - runtime_id: "workspace-backend".to_string(), - code: "profile_source_invalid".to_string(), - message: err.to_string(), - })?; - Ok(()) -} - -fn validate_source_content( - workspace_root: &Path, - name: &str, - relative_path: &Path, - content: &str, -) -> Result<()> { - if content.as_bytes().len() as u64 > MAX_PROFILE_SOURCE_BYTES { - return Err(Error::RuntimeOperationFailed { - runtime_id: "workspace-backend".to_string(), - code: "profile_source_too_large".to_string(), - message: "Profile source exceeds the browser editing size limit".to_string(), - }); - } - validate_source_candidate_path(workspace_root, relative_path)?; - let mut sources = read_profile_source_tree_contents(workspace_root)?; - sources.insert(display_source_path(relative_path), content.to_string()); - let mut registry = read_registry(workspace_root).map_err(profile_registry_error)?; - registry.profile.entry(name.to_string()).or_insert_with(|| { - ProfileEntryFile::Table(ProfileEntryTable { - path: display_source_path(relative_path), - description: None, - }) - }); - let selector = project_selector(name); - let (archive, _) = build_profile_archive_from_tree_sources(®istry, sources)?; - let verified = archive - .verify() - .map_err(|err| Error::RuntimeOperationFailed { - runtime_id: "workspace-backend".to_string(), - code: "profile_source_archive_invalid".to_string(), - message: err.to_string(), - })?; - verified - .resolve_profile(&selector, workspace_root, "workspace-settings-validation") - .map_err(|err| Error::RuntimeOperationFailed { - runtime_id: "workspace-backend".to_string(), - code: "profile_source_syntax_invalid".to_string(), - message: err.to_string(), - })?; - Ok(()) -} - -fn summarize_source( - workspace_root: &Path, - source_id: &str, - relative_path: &Path, -) -> WorkspaceProfileSourceSummary { - let mut diagnostics = Vec::new(); - let display_path = display_source_path(relative_path); - let mut revision = "missing".to_string(); - let mut size_bytes = 0; - match checked_source_path(workspace_root, relative_path) { - Ok(full) => match source_metadata(&full) { - Ok(metadata) => { - size_bytes = metadata.len(); - revision = file_revision(&full); - if size_bytes > MAX_PROFILE_SOURCE_BYTES { - diagnostics.push(diagnostic( - "profile_source_too_large", - DiagnosticSeverity::Error, - "Profile source is too large for browser editing.", - )); - } - } - Err(err) if err.kind() == std::io::ErrorKind::NotFound => diagnostics.push(diagnostic( - "profile_source_missing", - DiagnosticSeverity::Error, - "Profile source file is missing.", - )), - Err(err) => diagnostics.push(diagnostic( - "profile_source_metadata_failed", - DiagnosticSeverity::Error, - sanitize_error(&err.to_string()), - )), - }, - Err(err) => diagnostics.push(diagnostic( - "profile_source_path_escape", - DiagnosticSeverity::Error, - sanitize_error(&err.to_string()), - )), - } - WorkspaceProfileSourceSummary { - profile_source_id: source_id.to_string(), - display_path, - kind: "decodal".to_string(), - content_type: "text/x-decodal".to_string(), - content_digest: checked_source_path(workspace_root, relative_path) - .map(|path| file_content_digest(&path)) - .unwrap_or_else(|_| "sha256:unavailable".to_string()), - provenance: WorkspaceProfileSourceProvenance::ProjectProfileSourceTree, - editable: diagnostics - .iter() - .all(|d| d.severity != DiagnosticSeverity::Error), - revision, - size_bytes, - diagnostics, - } -} - -fn read_registry(workspace_root: &Path) -> std::result::Result { - let path = registry_path(workspace_root); - match fs::read_to_string(&path) { - Ok(raw) => { - toml::from_str(&raw).map_err(|err| format!("invalid profile registry schema: {err}")) - } - Err(err) if err.kind() == std::io::ErrorKind::NotFound => { - Ok(ProfileRegistryDocument::default()) - } - Err(err) => Err(format!( - "failed to read profile registry: {}", - sanitize_error(&err.to_string()) - )), - } -} - -fn write_registry(workspace_root: &Path, registry: &ProfileRegistryDocument) -> Result<()> { - let path = registry_path(workspace_root); - if let Some(parent) = path.parent() { - fs::create_dir_all(parent)?; - } - let raw = toml::to_string_pretty(registry) - .map_err(|err| Error::Config(format!("failed to serialize profile registry: {err}")))?; - fs::write(path, raw)?; - Ok(()) -} - -fn project_entries( - registry: &ProfileRegistryDocument, - diagnostics: &mut Vec, -) -> Vec { - registry - .profile - .iter() - .filter_map(|(name, entry)| match validate_profile_name(name) { - Ok(name) => { - if BUILTIN_PROFILE_SLUGS.contains(&name.as_str()) { - diagnostics.push(diagnostic( - "profile_selector_duplicate", - DiagnosticSeverity::Error, - format!("Project profile '{name}' conflicts with a builtin selector."), - )); - } - let (path, description) = match entry { - ProfileEntryFile::Path(path) => (path.clone(), None), - ProfileEntryFile::Table(table) => { - (table.path.clone(), table.description.clone()) - } - }; - match registry_relative_source_path(&path) { - Ok(relative_path) => Some(ProjectProfileEntry { - name, - description, - relative_path, - }), - Err(err) => { - diagnostics.push(diagnostic( - "profile_source_path_escape", - DiagnosticSeverity::Error, - err, - )); - None - } - } - } - Err(err) => { - diagnostics.push(diagnostic( - "profile_selector_invalid", - DiagnosticSeverity::Error, - err.to_string(), - )); - None - } - }) - .collect() -} - -fn entry_for_source_id( - registry: &ProfileRegistryDocument, - source_id: &str, -) -> Result<(String, ProjectProfileEntry)> { - let (name, _) = parse_project_source_id(source_id)?; - let mut diagnostics = Vec::new(); - let entry = project_entries(registry, &mut diagnostics) - .into_iter() - .find(|entry| entry.name == name) - .ok_or_else(|| Error::RuntimeOperationFailed { - runtime_id: "workspace-backend".to_string(), - code: "unknown_profile_source".to_string(), - message: "Unknown profile source id".to_string(), - })?; - Ok((name, entry)) -} - -fn registry_relative_source_path(raw: &str) -> std::result::Result { - let path = Path::new(raw); - if path.is_absolute() { - return Err("Profile source path must be workspace-relative and safe.".to_string()); - } - let path = if path.starts_with(".yoi") { - path.to_path_buf() - } else { - PathBuf::from(".yoi").join(path) - }; - validate_relative_source_path(&path)?; - Ok(path) -} - -fn validate_relative_source_path(path: &Path) -> std::result::Result<(), String> { - if !path.starts_with(PROFILE_SOURCE_ROOT_RELATIVE_PATH) { - return Err( - "Profile source path must be under the workspace profile source root.".to_string(), - ); - } - for component in path.components() { - if !matches!(component, Component::Normal(_)) { - return Err( - "Profile source path must not contain absolute, parent, or prefix components." - .to_string(), - ); - } - } - if path.extension().and_then(|value| value.to_str()) != Some("dcdl") { - return Err("Profile source path must use the .dcdl extension.".to_string()); - } - Ok(()) -} - -fn profile_source_tree_digest(workspace_root: &Path) -> Result { - let mut bytes = Vec::new(); - for file in list_profile_tree_files(workspace_root)? { - bytes.extend_from_slice(file.path.as_bytes()); - bytes.push(0); - bytes.extend_from_slice(file.content_digest.as_bytes()); - bytes.push(0); - } - Ok(sha256_hex(&bytes)) -} - -fn file_content_digest(path: &Path) -> String { - fs::read(path) - .map(|bytes| sha256_hex(&bytes)) - .unwrap_or_else(|_| "sha256:unavailable".to_string()) -} - -fn sha256_hex(bytes: &[u8]) -> String { - let digest = Sha256::digest(bytes); - let mut out = String::from("sha256:"); - for byte in digest { - use std::fmt::Write as _; - let _ = write!(out, "{byte:02x}"); - } - out -} - -#[derive(Debug, Clone)] -struct ProfileSourceRoot { - path: PathBuf, - canonical_path: PathBuf, - canonical_workspace: PathBuf, -} - -fn profile_source_symlink_escape(message: impl Into) -> Error { - Error::RuntimeOperationFailed { - runtime_id: "workspace-backend".to_string(), - code: "profile_source_symlink_escape".to_string(), - message: message.into(), - } -} - -fn existing_profile_source_root(workspace_root: &Path) -> Result> { - let source_root = workspace_root.join(PROFILE_SOURCE_ROOT_RELATIVE_PATH); - let metadata = match fs::symlink_metadata(&source_root) { - Ok(metadata) => metadata, - Err(err) if err.kind() == std::io::ErrorKind::NotFound => return Ok(None), - Err(err) => return Err(err.into()), - }; - validate_profile_source_root_metadata(workspace_root, source_root, metadata).map(Some) -} - -fn prepare_profile_source_root_for_write(workspace_root: &Path) -> Result { - let canonical_workspace = fs::canonicalize(workspace_root)?; - let yoi_dir = workspace_root.join(".yoi"); - match fs::symlink_metadata(&yoi_dir) { - Ok(metadata) => { - if metadata.file_type().is_symlink() { - return Err(profile_source_symlink_escape( - "Workspace .yoi directory must not be a symlink", - )); - } - if !metadata.is_dir() { - return Err(profile_validation_error( - "profile_source_path_invalid", - "Workspace .yoi path is not a directory", - )); - } - let canonical_yoi = fs::canonicalize(&yoi_dir)?; - if !canonical_yoi.starts_with(&canonical_workspace) { - return Err(profile_source_symlink_escape( - "Workspace .yoi directory resolves outside the workspace root", - )); - } - } - Err(err) if err.kind() == std::io::ErrorKind::NotFound => fs::create_dir(&yoi_dir)?, - Err(err) => return Err(err.into()), - } - - let source_root = workspace_root.join(PROFILE_SOURCE_ROOT_RELATIVE_PATH); - match fs::symlink_metadata(&source_root) { - Ok(metadata) => { - validate_profile_source_root_metadata(workspace_root, source_root, metadata) - } - Err(err) if err.kind() == std::io::ErrorKind::NotFound => { - fs::create_dir(&source_root)?; - let metadata = fs::symlink_metadata(&source_root)?; - validate_profile_source_root_metadata(workspace_root, source_root, metadata) - } - Err(err) => Err(err.into()), - } -} - -fn validate_profile_source_root_metadata( - workspace_root: &Path, - source_root: PathBuf, - metadata: std::fs::Metadata, -) -> Result { - if metadata.file_type().is_symlink() { - return Err(profile_source_symlink_escape( - "Workspace profile source root must not be a symlink", - )); - } - if !metadata.is_dir() { - return Err(profile_validation_error( - "profile_source_path_invalid", - "Workspace profile source root is not a directory", - )); - } - let canonical_workspace = fs::canonicalize(workspace_root)?; - let canonical_path = fs::canonicalize(&source_root)?; - if !canonical_path.starts_with(&canonical_workspace) { - return Err(profile_source_symlink_escape( - "Workspace profile source root resolves outside the workspace root", - )); - } - Ok(ProfileSourceRoot { - path: source_root, - canonical_path, - canonical_workspace, - }) -} - -fn validate_source_candidate_path(workspace_root: &Path, relative_path: &Path) -> Result<()> { - validate_relative_source_path(relative_path).map_err(|message| { - Error::RuntimeOperationFailed { - runtime_id: "workspace-backend".to_string(), - code: "profile_source_path_escape".to_string(), - message, - } - })?; - let Some(_) = existing_profile_source_root(workspace_root)? else { - return Ok(()); - }; - let full = workspace_root.join(relative_path); - if full.exists() || full.parent().is_some_and(Path::exists) { - checked_source_path(workspace_root, relative_path)?; - } - Ok(()) -} - -fn prepare_source_path_for_write(workspace_root: &Path, relative_path: &Path) -> Result { - validate_relative_source_path(relative_path).map_err(|message| { - Error::RuntimeOperationFailed { - runtime_id: "workspace-backend".to_string(), - code: "profile_source_path_escape".to_string(), - message, - } - })?; - let source_root = prepare_profile_source_root_for_write(workspace_root)?; - let full = workspace_root.join(relative_path); - let parent = full.parent().ok_or_else(|| { - profile_validation_error( - "profile_source_path_invalid", - "Profile source path has no parent directory", - ) - })?; - let parent_relative = parent.strip_prefix(&source_root.path).map_err(|_| { - profile_validation_error( - "profile_source_path_escape", - "Profile source parent must remain inside the source tree", - ) - })?; - let mut current = source_root.path.clone(); - for component in parent_relative.components() { - let Component::Normal(name) = component else { - return Err(profile_validation_error( - "profile_source_path_escape", - "Profile source parent must be a normalized relative path", - )); - }; - let next = current.join(name); - match fs::symlink_metadata(&next) { - Ok(metadata) => { - if metadata.file_type().is_symlink() { - return Err(profile_source_symlink_escape( - "Profile source parent contains a symlink", - )); - } - if !metadata.is_dir() { - return Err(profile_validation_error( - "profile_source_path_invalid", - "Profile source parent component is not a directory", - )); - } - let canonical_next = fs::canonicalize(&next)?; - if !canonical_next.starts_with(&source_root.canonical_path) - || !canonical_next.starts_with(&source_root.canonical_workspace) - { - return Err(profile_source_symlink_escape( - "Profile source parent resolves outside the workspace profile source root", - )); - } - } - Err(err) if err.kind() == std::io::ErrorKind::NotFound => fs::create_dir(&next)?, - Err(err) => return Err(err.into()), - } - current = next; - } - checked_source_path(workspace_root, relative_path) -} - -fn checked_source_path(workspace_root: &Path, relative_path: &Path) -> Result { - validate_relative_source_path(relative_path).map_err(|message| { - Error::RuntimeOperationFailed { - runtime_id: "workspace-backend".to_string(), - code: "profile_source_path_escape".to_string(), - message, - } - })?; - let source_root = existing_profile_source_root(workspace_root)?.ok_or_else(|| { - profile_validation_error( - "profile_source_missing", - "Workspace profile source root does not exist", - ) - })?; - let full = workspace_root.join(relative_path); - if let Ok(canonical_full) = fs::canonicalize(&full) { - if !canonical_full.starts_with(&source_root.canonical_path) - || !canonical_full.starts_with(&source_root.canonical_workspace) - { - return Err(profile_source_symlink_escape( - "Profile source resolves outside the workspace profile source root", - )); - } - } else if let Some(parent) = full.parent() { - let canonical_parent = fs::canonicalize(parent)?; - if !canonical_parent.starts_with(&source_root.canonical_path) - || !canonical_parent.starts_with(&source_root.canonical_workspace) - { - return Err(Error::RuntimeOperationFailed { - runtime_id: "workspace-backend".to_string(), - code: "profile_source_path_escape".to_string(), - message: "Profile source parent resolves outside the workspace profile source root" - .to_string(), - }); - } - } - Ok(full) -} - -fn validate_profile_name(value: &str) -> Result { - let trimmed = value.trim(); - if trimmed.is_empty() - || trimmed.len() > 64 - || !trimmed - .chars() - .all(|ch| ch.is_ascii_alphanumeric() || matches!(ch, '-' | '_')) - { - return Err(Error::RuntimeOperationFailed { - runtime_id: "workspace-backend".to_string(), - code: "profile_selector_invalid".to_string(), - message: "Profile selector must contain only ASCII letters, digits, '-' or '_'" - .to_string(), - }); - } - Ok(trimmed.to_string()) -} - fn sanitize_display_name(value: &str) -> Result { let trimmed = value.trim(); if trimmed.is_empty() || trimmed.chars().any(char::is_control) || trimmed.len() > 120 { @@ -1877,27 +654,6 @@ fn sanitize_display_name(value: &str) -> Result { } Ok(trimmed.to_string()) } - -fn parse_project_source_id(source_id: &str) -> Result<(String, String)> { - let Some(name) = source_id.strip_prefix("project:") else { - return Err(Error::RuntimeOperationFailed { - runtime_id: "workspace-backend".to_string(), - code: "unsupported_profile_source_id".to_string(), - message: "Profile source id is not a project profile source".to_string(), - }); - }; - let name = validate_profile_name(name)?; - Ok((name.clone(), project_source_id(&name))) -} - -pub fn project_selector(name: &str) -> String { - format!("project:{name}") -} - -pub fn project_source_id(name: &str) -> String { - format!("project:{name}") -} - pub fn selector_for_builtin_candidate( id: &str, ) -> Option { @@ -1909,17 +665,9 @@ pub fn selector_for_builtin_candidate( | "builtin:reviewer" => Some(worker_runtime::catalog::ProfileSelector::Builtin( id.to_string(), )), - value if value.starts_with("project:") => Some( - worker_runtime::catalog::ProfileSelector::Named(value.to_string()), - ), _ => None, } } - -fn registry_path(workspace_root: &Path) -> PathBuf { - workspace_root.join(PROFILE_REGISTRY_RELATIVE_PATH) -} - fn file_revision(path: &Path) -> String { let Ok(metadata) = fs::metadata(path) else { return "missing".to_string(); @@ -1932,32 +680,6 @@ fn file_revision(path: &Path) -> String { .unwrap_or_default(); format!("rev:{modified}:{}", metadata.len()) } - -fn source_metadata(path: &Path) -> std::io::Result { - fs::symlink_metadata(path) -} - -fn ensure_revision(path: &Path, expected: &str, code: &'static str) -> Result<()> { - let actual = file_revision(path); - if expected != actual { - return Err(Error::RuntimeOperationFailed { - runtime_id: "workspace-backend".to_string(), - code: code.to_string(), - message: "Settings changed before this update was applied".to_string(), - }); - } - Ok(()) -} - -fn optional_trim(value: &str) -> Option { - let trimmed = value.trim(); - if trimmed.is_empty() { - None - } else { - Some(trimmed.to_string()) - } -} - fn diagnostic( code: impl Into, severity: DiagnosticSeverity, @@ -1969,7 +691,6 @@ fn diagnostic( message: message.into(), } } - fn sanitize_error(value: &str) -> String { value .split_whitespace() @@ -1990,318 +711,165 @@ mod tests { use super::*; fn valid_decodal(slug: &str) -> String { - format!( - r#"{{ - slug = "{slug}"; - description = "Test"; - scope = "workspace_read"; - }}"# - ) + format!(r#"{{ slug = "{slug}"; model = {{ id = "gpt-5.4"; }}; }}"#) + } + + fn virtual_state(entries: Vec) -> WorkspaceConfigState { + let snapshot = config_source::ConfigTreeSnapshot::from_entries(7, entries).unwrap(); + let schema_bundle = config_source::WorkspaceConfigSchemaBundle::compose([ + ProfileConfigSchemaProvider.contribution().unwrap(), + crate::prompt_settings::PromptConfigSchemaProvider + .contribution() + .unwrap(), + ]) + .unwrap(); + let contract = config_source::ToolchainContract::with_schema_bundle( + config_source::DEFAULT_SCHEMA_VERSION, + vec![VirtualPath::parse("main.dcdl").unwrap()], + config_source::DEFAULT_IMPORT_POLICY_VERSION, + schema_bundle, + ); + let projection_digest = config_source::SnapshotEnvironment::new(snapshot.clone()) + .evaluate_contract(&contract) + .unwrap() + .projection_digest; + WorkspaceConfigState { + projection_digest, + contract, + snapshot, + } } #[test] - fn profile_settings_create_update_and_discover_project_profile() { - let dir = tempfile::tempdir().unwrap(); - fs::create_dir_all(dir.path().join(".yoi")).unwrap(); - fs::write(dir.path().join(".yoi/profiles.toml"), "").unwrap(); - let revision = file_revision(&dir.path().join(".yoi/profiles.toml")); - let created = create_profile_source( - "workspace-test", - dir.path(), - CreateWorkspaceProfileSourceRequest { - name: "alpha".to_string(), - description: Some("Alpha".to_string()), - content: valid_decodal("alpha"), - registry_revision: revision, - }, - ) - .unwrap(); + fn virtual_config_projection_is_builtin_only_by_default() { + let state = virtual_state(vec![ + config_source::ConfigEntry::new( + VirtualPath::parse("main.dcdl").unwrap(), + ConfigContentType::Decodal, + "{}", + ) + .unwrap(), + ]); + let projection = project_profiles_from_workspace_config("workspace-test", &state).unwrap(); + assert_eq!( + projection.settings.default_profile.as_deref(), + Some("builtin:companion") + ); + assert_eq!(projection.settings.config_revision, Some(7)); + assert!(projection.settings.projection_digest.is_some()); assert!( - created + projection .settings .profiles .iter() - .any(|profile| profile.profile_id == "project:alpha") + .all(|item| !item.editable) ); + } + + #[test] + fn virtual_config_projection_builds_archive_from_active_revision() { + let state = virtual_state(vec![ + 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"; }]; }; }"#, + ) + .unwrap(), + config_source::ConfigEntry::new( + VirtualPath::parse("profiles/alpha.dcdl").unwrap(), + ConfigContentType::Decodal, + valid_decodal("alpha"), + ) + .unwrap(), + ]); + let projection = project_profiles_from_workspace_config("workspace-test", &state).unwrap(); + let bundle = build_virtual_profile_config_bundle( + &projection, + &state, + "workspace-test", + "2026-01-01T00:00:00Z", + "project:alpha", + ) + .unwrap() + .unwrap(); + assert_eq!(projection.settings.config_revision, Some(7)); + let prompt_catalog = bundle.prompt_catalog.as_ref().unwrap(); + assert_eq!(prompt_catalog.config_revision, 7); + assert!(prompt_catalog.templates.contains_key("default")); + assert!(prompt_catalog.templates.contains_key("common.workspace")); assert!( - build_workspace_profile_archive(dir.path(), "project:alpha") + bundle + .metadata + .provenance + .detail .unwrap() - .is_some() + .contains("revision=7") ); - } - - #[test] - fn profile_source_rejects_path_escape_and_revision_conflict() { - let dir = tempfile::tempdir().unwrap(); - fs::create_dir_all(dir.path().join(".yoi")).unwrap(); - fs::write( - dir.path().join(".yoi/profiles.toml"), - "[profile.bad]\npath = \"../bad.dcdl\"\n", - ) - .unwrap(); - let settings = load_profile_settings("workspace-test", dir.path()); - assert!( - settings - .diagnostics - .iter() - .any(|diagnostic| diagnostic.code == "profile_source_path_escape") - ); - - let err = update_profile_registry( - "workspace-test", - dir.path(), - UpdateWorkspaceProfileRegistryRequest { - registry_revision: "stale".to_string(), - default_profile: None, - profiles: Vec::new(), - }, - ) - .unwrap_err(); - assert!( - err.to_string() - .contains("profile_registry_revision_conflict") - ); - } - - #[test] - fn profile_source_rejects_invalid_decodal() { - let dir = tempfile::tempdir().unwrap(); - fs::create_dir_all(dir.path().join(".yoi")).unwrap(); - fs::write(dir.path().join(".yoi/profiles.toml"), "").unwrap(); - let revision = file_revision(&dir.path().join(".yoi/profiles.toml")); - let err = create_profile_source( - "workspace-test", - dir.path(), - CreateWorkspaceProfileSourceRequest { - name: "bad".to_string(), - description: None, - content: "not decodal".to_string(), - registry_revision: revision, - }, - ) - .unwrap_err(); - assert!( - err.to_string().contains("profile_source_syntax_invalid") - || err.to_string().contains("profile_source_archive_invalid") - ); - } - - #[test] - fn invalid_project_profile_is_not_a_launch_candidate() { - let dir = tempfile::tempdir().unwrap(); - fs::create_dir_all(dir.path().join(".yoi/profiles")).unwrap(); - fs::write( - dir.path().join(".yoi/profiles.toml"), - "[profile.bad]\npath = \"profiles/bad.dcdl\"\n", - ) - .unwrap(); - fs::write(dir.path().join(".yoi/profiles/bad.dcdl"), "not decodal").unwrap(); - - let settings = load_profile_settings("workspace-test", dir.path()); - let bad = settings - .profiles - .iter() - .find(|profile| profile.profile_id == "project:bad") - .expect("bad profile summary is still visible for repair"); - assert!( - bad.diagnostics - .iter() - .any(|diagnostic| diagnostic.code.starts_with("profile_source_")) - ); - assert!(project_profile_candidates(dir.path()).is_empty()); - assert!(!is_profile_candidate(dir.path(), "project:bad")); - } - - #[test] - fn registry_update_rejects_missing_source_and_unknown_default() { - let dir = tempfile::tempdir().unwrap(); - fs::create_dir_all(dir.path().join(".yoi")).unwrap(); - fs::write(dir.path().join(".yoi/profiles.toml"), "").unwrap(); - let revision = file_revision(&dir.path().join(".yoi/profiles.toml")); - - let err = update_profile_registry( - "workspace-test", - dir.path(), - UpdateWorkspaceProfileRegistryRequest { - registry_revision: revision.clone(), - default_profile: Some("project:missing".to_string()), - profiles: Vec::new(), - }, - ) - .unwrap_err(); - assert!(err.to_string().contains("profile_default_unknown")); - - let err = update_profile_registry( - "workspace-test", - dir.path(), - UpdateWorkspaceProfileRegistryRequest { - registry_revision: revision, - default_profile: None, - profiles: vec![WorkspaceProfileRegistryEntryUpdate { - name: "missing".to_string(), - description: None, - profile_source_id: None, - }], - }, - ) - .unwrap_err(); - assert!(err.to_string().contains("profile_source_missing")); + let archive = bundle.profile_source_archive.unwrap(); assert_eq!( - fs::read_to_string(dir.path().join(".yoi/profiles.toml")).unwrap(), - "" - ); - } - - #[test] - fn profile_source_rejects_too_large_content() { - let dir = tempfile::tempdir().unwrap(); - fs::create_dir_all(dir.path().join(".yoi")).unwrap(); - fs::write(dir.path().join(".yoi/profiles.toml"), "").unwrap(); - let revision = file_revision(&dir.path().join(".yoi/profiles.toml")); - let err = create_profile_source( - "workspace-test", - dir.path(), - CreateWorkspaceProfileSourceRequest { - name: "large".to_string(), - description: None, - content: "x".repeat((MAX_PROFILE_SOURCE_BYTES + 1) as usize), - registry_revision: revision, - }, - ) - .unwrap_err(); - assert!(err.to_string().contains("profile_source_too_large")); - } - - #[cfg(unix)] - #[test] - fn registry_update_rejects_symlink_escape() { - let dir = tempfile::tempdir().unwrap(); - fs::create_dir_all(dir.path().join(".yoi/profiles")).unwrap(); - fs::write(dir.path().join(".yoi/profiles.toml"), "").unwrap(); - let outside = dir.path().join("outside.dcdl"); - fs::write(&outside, valid_decodal("escape")).unwrap(); - std::os::unix::fs::symlink(&outside, dir.path().join(".yoi/profiles/escape.dcdl")).unwrap(); - let revision = file_revision(&dir.path().join(".yoi/profiles.toml")); - let err = update_profile_registry( - "workspace-test", - dir.path(), - UpdateWorkspaceProfileRegistryRequest { - registry_revision: revision, - default_profile: None, - profiles: vec![WorkspaceProfileRegistryEntryUpdate { - name: "escape".to_string(), - description: None, - profile_source_id: None, - }], - }, - ) - .unwrap_err(); - let rendered = err.to_string(); - assert!(rendered.contains("profile_source_symlink_escape")); - assert!(!rendered.contains(dir.path().to_string_lossy().as_ref())); - } - - #[test] - fn selected_profile_archive_contains_only_import_closure() { - let mut registry = ProfileRegistryDocument::default(); - registry.profile.insert( - "alpha".to_string(), - ProfileEntryFile::Table(ProfileEntryTable { - path: "profiles/alpha.dcdl".to_string(), - description: None, - }), - ); - registry.profile.insert( - "beta".to_string(), - ProfileEntryFile::Table(ProfileEntryTable { - path: "profiles/beta.dcdl".to_string(), - description: None, - }), - ); - let mut sources = BTreeMap::new(); - sources.insert( - "profiles/alpha.dcdl".to_string(), - r#"{ extra = import "./shared.dcdl"; }"#.to_string(), - ); - sources.insert("profiles/shared.dcdl".to_string(), "{}".to_string()); - sources.insert("profiles/beta.dcdl".to_string(), "{}".to_string()); - sources.insert("profiles/unregistered.dcdl".to_string(), "{}".to_string()); - - let archive = - build_profile_archive_for_selector(®istry, sources, "project:alpha").unwrap(); - let verified = archive.verify().unwrap(); - let manifest = verified.manifest(); - let source_paths: Vec<_> = manifest - .sources - .iter() - .map(|source| source.path.as_str()) - .collect(); - assert_eq!(manifest.entrypoints.len(), 1); - assert_eq!( - manifest + archive + .reference + .source_graph .entrypoints .get("project:alpha") .map(String::as_str), Some("profiles/alpha.dcdl") ); - assert!(source_paths.contains(&"profiles/alpha.dcdl")); - assert!(source_paths.contains(&"profiles/shared.dcdl")); - assert!(!source_paths.contains(&"profiles/beta.dcdl")); - assert!(!source_paths.contains(&"profiles/unregistered.dcdl")); + assert_eq!(archive.reference.source_graph.source_count, 1); } - #[cfg(unix)] #[test] - fn tree_write_rejects_symlink_parent_before_outside_side_effect() { - let dir = tempfile::tempdir().unwrap(); - fs::create_dir_all(dir.path().join(".yoi/profiles")).unwrap(); - let outside = tempfile::tempdir().unwrap(); - std::os::unix::fs::symlink(outside.path(), dir.path().join(".yoi/profiles/link")).unwrap(); - - let err = write_profile_tree_file( + fn virtual_config_projection_preserves_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"; }]; }; }"#, + ) + .unwrap(), + config_source::ConfigEntry::new( + VirtualPath::parse("profiles/alpha.dcdl").unwrap(), + ConfigContentType::Decodal, + r#"import "../shared/profile.dcdl""#, + ) + .unwrap(), + config_source::ConfigEntry::new( + VirtualPath::parse("shared/profile.dcdl").unwrap(), + ConfigContentType::Decodal, + valid_decodal("alpha"), + ) + .unwrap(), + ]); + let projection = project_profiles_from_workspace_config("workspace-test", &state).unwrap(); + let bundle = build_virtual_profile_config_bundle( + &projection, + &state, "workspace-test", - dir.path(), - PROFILE_SOURCE_TREE_ID, - WriteWorkspaceProfileTreeFileRequest { - path: "profiles/link/nested/new.dcdl".to_string(), - content: valid_decodal("new"), - revision: None, - }, + "2026-01-01T00:00:00Z", + "project:alpha", ) - .unwrap_err(); - - assert!(err.to_string().contains("profile_source_symlink_escape")); - assert!(!outside.path().join("nested").exists()); + .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); } - #[cfg(unix)] #[test] - fn source_root_symlink_is_rejected_before_tree_side_effects() { - let dir = tempfile::tempdir().unwrap(); - fs::create_dir_all(dir.path().join(".yoi")).unwrap(); - let outside = tempfile::tempdir().unwrap(); - std::os::unix::fs::symlink(outside.path(), dir.path().join(".yoi/profiles")).unwrap(); - - let err = write_profile_tree_file( - "workspace-test", - dir.path(), - PROFILE_SOURCE_TREE_ID, - WriteWorkspaceProfileTreeFileRequest { - path: "profiles/nested/new.dcdl".to_string(), - content: valid_decodal("new"), - revision: None, - }, - ) - .unwrap_err(); - assert!(err.to_string().contains("profile_source_symlink_escape")); - assert!(!outside.path().join("nested").exists()); - assert!(!outside.path().join("new.dcdl").exists()); - - let err = read_profile_source_tree("workspace-test", dir.path(), PROFILE_SOURCE_TREE_ID) - .unwrap_err(); - assert!(err.to_string().contains("profile_source_symlink_escape")); - - let err = build_workspace_profile_archive(dir.path(), "project:any").unwrap_err(); - assert!(err.to_string().contains("profile_source_symlink_escape")); + 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") + ); } } diff --git a/crates/workspace-server/src/prompt_settings.rs b/crates/workspace-server/src/prompt_settings.rs new file mode 100644 index 00000000..f7c15617 --- /dev/null +++ b/crates/workspace-server/src/prompt_settings.rs @@ -0,0 +1,194 @@ +use config_source::{ConfigProjectionValidator, ConfigSchemaContribution}; +use worker::{EffectivePromptCatalog, prompt_schema_source}; + +use crate::config_source::{ + WorkspaceConfigSchemaProvider, WorkspaceConfigState, evaluate_workspace_config_state, +}; +use crate::{Error, Result}; + +#[derive(Debug, Default)] +pub struct PromptConfigSchemaProvider; + +impl WorkspaceConfigSchemaProvider for PromptConfigSchemaProvider { + fn contribution(&self) -> Result { + ConfigSchemaContribution::new( + "builtin:prompts", + "prompts", + "1", + prompt_schema_source().map_err(|error| Error::Config(error.to_string()))?, + ) + .map(|contribution| { + contribution.with_projection_validator( + ConfigProjectionValidator::StaticTemplateCatalog { + namespace: "prompts".to_string(), + key_aliases: std::collections::BTreeMap::from([( + "default_prompt".to_string(), + "default".to_string(), + )]), + }, + ) + }) + .map_err(|error| Error::Config(error.to_string())) + } +} + +pub fn validate_evaluated_prompt_catalog( + evaluation: &config_source::EvaluationResult, +) -> Result<()> { + let projection = evaluation.projections.first().ok_or_else(|| { + Error::InvalidInput("Workspace config produced no active projection".to_string()) + })?; + let prompts = projection.data_json.get("prompts").ok_or_else(|| { + Error::InvalidInput("Workspace config projection has no prompts namespace".to_string()) + })?; + EffectivePromptCatalog::from_projection(prompts, 0, "preview", "preview") + .map(|_| ()) + .map_err(|error| Error::InvalidInput(format!("invalid Prompt catalog: {error}"))) +} + +pub fn project_prompts_from_workspace_config( + state: &WorkspaceConfigState, +) -> Result { + let evaluation = evaluate_workspace_config_state(state, state.contract.schema_bundle.clone())?; + if evaluation.projection_digest != state.projection_digest { + return Err(Error::RegistryInconsistency( + "Prompt projection digest does not match the active Workspace config revision" + .to_string(), + )); + } + let projection = evaluation.projections.first().ok_or_else(|| { + Error::RegistryInconsistency("Workspace config has no active projection".to_string()) + })?; + let prompts = projection.data_json.get("prompts").ok_or_else(|| { + Error::RegistryInconsistency( + "active Workspace config projection has no prompts namespace".to_string(), + ) + })?; + EffectivePromptCatalog::from_projection( + prompts, + state.snapshot.revision, + state.contract.schema_bundle.fingerprint.clone(), + state.contract.fingerprint.clone(), + ) + .map_err(|error| Error::RegistryInconsistency(error.to_string())) +} + +#[cfg(test)] +mod tests { + use super::*; + use config_source::{ + ConfigContentType, ConfigEntry, ConfigTreeSnapshot, SnapshotEnvironment, ToolchainContract, + VirtualPath, WorkspaceConfigSchemaBundle, + }; + + fn state(source: &str) -> WorkspaceConfigState { + let schema = WorkspaceConfigSchemaBundle::compose([PromptConfigSchemaProvider + .contribution() + .unwrap()]) + .unwrap(); + let snapshot = ConfigTreeSnapshot::from_entries( + 7, + [ConfigEntry::new( + VirtualPath::parse("main.dcdl").unwrap(), + ConfigContentType::Decodal, + source, + ) + .unwrap()], + ) + .unwrap(); + let contract = ToolchainContract::with_schema_bundle( + config_source::DEFAULT_SCHEMA_VERSION, + vec![VirtualPath::parse("main.dcdl").unwrap()], + config_source::DEFAULT_IMPORT_POLICY_VERSION, + schema, + ); + let projection_digest = SnapshotEnvironment::new(snapshot.clone()) + .evaluate_contract(&contract) + .unwrap() + .projection_digest; + WorkspaceConfigState { + snapshot, + contract, + projection_digest, + } + } + + #[test] + fn workspace_override_deep_patches_builtin_and_preserves_other_leaves() { + let state = state(r#"{ prompts = { common = { language = "OVERRIDE"; }; }; }"#); + let catalog = project_prompts_from_workspace_config(&state).unwrap(); + assert_eq!(catalog.config_revision, 7); + assert_eq!(catalog.templates["common.language"], "OVERRIDE"); + assert!(!catalog.templates["common.workspace"].is_empty()); + assert!(catalog.templates["default"].contains("common.workspace")); + } + + #[test] + fn preview_commit_validator_rejects_dynamic_missing_and_cyclic_includes() { + let schema = WorkspaceConfigSchemaBundle::compose([PromptConfigSchemaProvider + .contribution() + .unwrap()]) + .unwrap(); + for source in [ + r#"{ prompts = { common = { language = "{%- include target -%}"; }; }; }"#, + r#"{ prompts = { common = { language = "{%- include \"missing\" -%}"; }; }; }"#, + r#"{ prompts = { common = { language = "{% include \"common.workspace\" %}"; workspace = "{% include \"common.language\" %}"; }; }; }"#, + ] { + let snapshot = ConfigTreeSnapshot::from_entries( + 0, + [ConfigEntry::new( + VirtualPath::parse("main.dcdl").unwrap(), + ConfigContentType::Decodal, + source, + ) + .unwrap()], + ) + .unwrap(); + let contract = ToolchainContract::with_schema_bundle( + config_source::DEFAULT_SCHEMA_VERSION, + vec![VirtualPath::parse("main.dcdl").unwrap()], + config_source::DEFAULT_IMPORT_POLICY_VERSION, + schema.clone(), + ); + assert!( + SnapshotEnvironment::new(snapshot) + .evaluate_contract(&contract) + .is_err() + ); + } + } + + #[test] + fn closed_prompt_schema_rejects_unknown_and_non_string_leaves() { + let schema = WorkspaceConfigSchemaBundle::compose([PromptConfigSchemaProvider + .contribution() + .unwrap()]) + .unwrap(); + for source in [ + "{ prompts = { common = { unknown = \"bad\"; }; }; }", + "{ prompts = { common = { language = 42; }; }; }", + ] { + let snapshot = ConfigTreeSnapshot::from_entries( + 0, + [ConfigEntry::new( + VirtualPath::parse("main.dcdl").unwrap(), + ConfigContentType::Decodal, + source, + ) + .unwrap()], + ) + .unwrap(); + let contract = ToolchainContract::with_schema_bundle( + config_source::DEFAULT_SCHEMA_VERSION, + vec![VirtualPath::parse("main.dcdl").unwrap()], + config_source::DEFAULT_IMPORT_POLICY_VERSION, + schema.clone(), + ); + assert!( + SnapshotEnvironment::new(snapshot) + .evaluate_contract(&contract) + .is_err() + ); + } + } +} diff --git a/crates/workspace-server/src/server.rs b/crates/workspace-server/src/server.rs index b2354b8b..095d88fb 100644 --- a/crates/workspace-server/src/server.rs +++ b/crates/workspace-server/src/server.rs @@ -84,12 +84,7 @@ use crate::observation::{ BackendObservationProxy, ObservationProxyError, RuntimeObservationClient, RuntimeObservationSource, RuntimeObservationSourceConfig, }; -use crate::profile_settings::{ - CreateWorkspaceProfileSourceRequest, DeleteWorkspaceProfileSourceRequest, - DeleteWorkspaceProfileTreeFileRequest, ReadWorkspaceProfileTreeFileQuery, - UpdateWorkspaceMetadataRequest, UpdateWorkspaceProfileRegistryRequest, - UpdateWorkspaceProfileSourceRequest, WriteWorkspaceProfileTreeFileRequest, -}; +use crate::profile_settings::UpdateWorkspaceMetadataRequest; use crate::records::{ObjectiveDetail, ProjectRecordList, TicketDetail}; use crate::repositories::{ ConfiguredRepository, RepositoryListProjection, RepositoryLogRead, RepositoryLookupError, @@ -245,10 +240,7 @@ impl ServerConfig { } const ORCHESTRATOR_ATTENTION_TICKET_LIMIT: usize = 20; -const ORCHESTRATOR_ATTENTION_PROMPT: &str = include_str!(concat!( - env!("CARGO_MANIFEST_DIR"), - "/../../resources/prompts/internal/workspace_orchestrator_queue_attention.md" -)); +const ORCHESTRATOR_ATTENTION_PROMPT_NAME: &str = "internal.workspace_orchestrator_queue_attention"; #[derive(Clone)] pub struct WorkspaceApi { @@ -756,9 +748,20 @@ impl WorkspaceApi { let config_store = Arc::new(crate::SqliteWorkspaceStore::open( config.database_path.clone(), )?); + let config_schema_registry = crate::config_source::WorkspaceConfigSchemaRegistry::default() + .with_provider(Arc::new( + crate::profile_settings::ProfileConfigSchemaProvider, + )) + .with_provider(Arc::new(crate::prompt_settings::PromptConfigSchemaProvider)) + .with_provider(Arc::new(skills::SkillConfigSchemaProvider)); + config_store.ensure_workspace_config_materialized_with_schema( + &config.workspace_id, + &Utc::now().to_rfc3339_opts(SecondsFormat::Secs, true), + config_schema_registry.compose()?, + )?; let api = Self { config_store, - config_schema_registry: crate::config_source::WorkspaceConfigSchemaRegistry::default(), + config_schema_registry, authority: SqliteWorkspaceAuthority::new( config.database_path.clone(), config.workspace_id.clone(), @@ -1171,27 +1174,7 @@ pub fn build_router(api: WorkspaceApi) -> Router { ) .route( "/api/w/{workspace_id}/settings/profiles", - get(scoped_get_profile_settings).post(scoped_create_profile_source), - ) - .route( - "/api/w/{workspace_id}/settings/profiles/registry", - put(scoped_update_profile_registry), - ) - .route( - "/api/w/{workspace_id}/settings/profiles/trees/{source_tree_id}", - get(scoped_get_profile_source_tree), - ) - .route( - "/api/w/{workspace_id}/settings/profiles/trees/{source_tree_id}/file", - get(scoped_get_profile_tree_file) - .put(scoped_write_profile_tree_file) - .delete(scoped_delete_profile_tree_file), - ) - .route( - "/api/w/{workspace_id}/settings/profiles/{profile_source_id}", - get(scoped_get_profile_source) - .put(scoped_update_profile_source) - .delete(scoped_delete_profile_source), + get(scoped_get_profile_settings), ) .route( "/api/w/{workspace_id}/flows", @@ -2537,13 +2520,13 @@ async fn scoped_preview_workspace_config_tree( Json(request): Json, ) -> ApiResult> { validate_workspace_scope(&api, &path.workspace_id)?; - Ok(Json( - api.config_store.preview_workspace_config_with_schema( - &path.workspace_id, - &request, - api.config_schema_registry.compose()?, - )?, - )) + let candidate = api.config_store.preview_workspace_config_with_schema( + &path.workspace_id, + &request, + api.config_schema_registry.compose()?, + )?; + crate::prompt_settings::validate_evaluated_prompt_catalog(&candidate.evaluation)?; + Ok(Json(candidate)) } async fn scoped_commit_workspace_config_tree( @@ -2559,6 +2542,7 @@ async fn scoped_commit_workspace_config_tree( &request, api.config_schema_registry.compose()?, )?; + crate::prompt_settings::validate_evaluated_prompt_catalog(&candidate.evaluation)?; let state = api .config_store .commit_evaluated_workspace_config(&path.workspace_id, &candidate)?; @@ -2577,130 +2561,19 @@ async fn scoped_get_profile_settings( AxumPath(path): AxumPath, ) -> ApiResult> { validate_workspace_scope(&api, &path.workspace_id)?; - Ok(Json(crate::profile_settings::load_profile_settings( - &api.config.workspace_id, - &api.config.workspace_root, - ))) -} - -async fn scoped_create_profile_source( - State(api): State, - AxumPath(path): AxumPath, - Json(request): Json, -) -> ApiResult> { - validate_workspace_scope(&api, &path.workspace_id)?; - Ok(Json(crate::profile_settings::create_profile_source( - &api.config.workspace_id, - &api.config.workspace_root, - request, - )?)) -} - -async fn scoped_update_profile_registry( - State(api): State, - AxumPath(path): AxumPath, - Json(request): Json, -) -> ApiResult> { - validate_workspace_scope(&api, &path.workspace_id)?; - Ok(Json(crate::profile_settings::update_profile_registry( - &api.config.workspace_id, - &api.config.workspace_root, - request, - )?)) -} - -async fn scoped_get_profile_source_tree( - State(api): State, - AxumPath((workspace_id, source_tree_id)): AxumPath<(String, String)>, -) -> ApiResult> { - validate_workspace_scope(&api, &workspace_id)?; - Ok(Json(crate::profile_settings::read_profile_source_tree( - &workspace_id, - &api.config.workspace_root, - &source_tree_id, - )?)) -} - -async fn scoped_get_profile_tree_file( - State(api): State, - AxumPath((workspace_id, source_tree_id)): AxumPath<(String, String)>, - Query(query): Query, -) -> ApiResult> { - validate_workspace_scope(&api, &workspace_id)?; - Ok(Json(crate::profile_settings::read_profile_tree_file( - &workspace_id, - &api.config.workspace_root, - &source_tree_id, - query, - )?)) -} - -async fn scoped_write_profile_tree_file( - State(api): State, - AxumPath((workspace_id, source_tree_id)): AxumPath<(String, String)>, - Json(request): Json, -) -> ApiResult> { - validate_workspace_scope(&api, &workspace_id)?; - Ok(Json(crate::profile_settings::write_profile_tree_file( - &workspace_id, - &api.config.workspace_root, - &source_tree_id, - request, - )?)) -} - -async fn scoped_delete_profile_tree_file( - State(api): State, - AxumPath((workspace_id, source_tree_id)): AxumPath<(String, String)>, - Json(request): Json, -) -> ApiResult> { - validate_workspace_scope(&api, &workspace_id)?; - Ok(Json(crate::profile_settings::delete_profile_tree_file( - &workspace_id, - &api.config.workspace_root, - &source_tree_id, - request, - )?)) -} - -async fn scoped_get_profile_source( - State(api): State, - AxumPath((workspace_id, profile_source_id)): AxumPath<(String, String)>, -) -> ApiResult> { - validate_workspace_scope(&api, &workspace_id)?; - Ok(Json(crate::profile_settings::read_profile_source( - &api.config.workspace_id, - &api.config.workspace_root, - &profile_source_id, - )?)) -} - -async fn scoped_update_profile_source( - State(api): State, - AxumPath((workspace_id, profile_source_id)): AxumPath<(String, String)>, - Json(request): Json, -) -> ApiResult> { - validate_workspace_scope(&api, &workspace_id)?; - Ok(Json(crate::profile_settings::update_profile_source( - &api.config.workspace_id, - &api.config.workspace_root, - &profile_source_id, - request, - )?)) -} - -async fn scoped_delete_profile_source( - State(api): State, - AxumPath((workspace_id, profile_source_id)): AxumPath<(String, String)>, - Json(request): Json, -) -> ApiResult> { - validate_workspace_scope(&api, &workspace_id)?; - Ok(Json(crate::profile_settings::delete_profile_source( - &api.config.workspace_id, - &api.config.workspace_root, - &profile_source_id, - request, - )?)) + let state = api + .config_store + .load_workspace_config(&path.workspace_id)? + .ok_or_else(|| { + ApiError::from(Error::InvalidRecordId("virtual config source tree".into())) + })?; + Ok(Json( + crate::profile_settings::project_profiles_from_workspace_config( + &path.workspace_id, + &state, + )? + .settings, + )) } async fn scoped_list_tickets( @@ -4935,10 +4808,31 @@ fn dispatch_orchestrator_queue_attention(api: &WorkspaceApi) { } else { format!("Additional queued Tickets omitted from this notice: {omitted}\n") }; - let content = ORCHESTRATOR_ATTENTION_PROMPT - .replace("{{omitted_line}}", &omitted_line) - .replace("{{workspace_id}}", &api.config.workspace_id) - .replace("{{ticket_lines}}", &shown); + let Ok(Some(config_state)) = api + .config_store + .load_workspace_config(&api.config.workspace_id) + else { + return; + }; + let Ok(projection) = + crate::prompt_settings::project_prompts_from_workspace_config(&config_state) + else { + return; + }; + let Ok(catalog) = worker::PromptCatalog::from_projection(projection) else { + return; + }; + let content = match catalog.render_serializable( + ORCHESTRATOR_ATTENTION_PROMPT_NAME, + &BTreeMap::from([ + ("omitted_line", omitted_line.as_str()), + ("workspace_id", api.config.workspace_id.as_str()), + ("ticket_lines", shown.as_str()), + ]), + ) { + Ok(content) => content, + Err(_) => return, + }; let accepted = api .runtime .send_input( @@ -5134,12 +5028,7 @@ fn start_memory_staging_consolidation( } let profile_selector = ProfileSelector::Builtin(MEMORY_CONSOLIDATION_PROFILE.to_string()); - let resolved_config_bundle = crate::profile_settings::build_workspace_profile_config_bundle( - &api.config.workspace_root, - &api.config.workspace_id, - &api.config.workspace_created_at, - MEMORY_CONSOLIDATION_PROFILE, - )?; + let resolved_config_bundle = None; let initial_submit = vec![Segment::text(input_content)]; let result = api.spawn_workspace_worker( &runtime_id, @@ -5300,7 +5189,16 @@ async fn scoped_list_skills( AxumPath(path): AxumPath, ) -> ApiResult> { validate_workspace_scope(&api, &path.workspace_id)?; - Ok(Json(skills::catalog(&api.config.workspace_root))) + let state = api + .config_store + .load_workspace_config(&path.workspace_id)? + .ok_or_else(|| { + Error::RegistryInconsistency(format!( + "Workspace {} has no active config revision", + path.workspace_id + )) + })?; + skills::catalog(&state).map(Json).map_err(skill_api_error) } async fn scoped_lint_skills( @@ -5308,7 +5206,16 @@ async fn scoped_lint_skills( AxumPath(path): AxumPath, ) -> ApiResult> { validate_workspace_scope(&api, &path.workspace_id)?; - Ok(Json(skills::lint(&api.config.workspace_root))) + let state = api + .config_store + .load_workspace_config(&path.workspace_id)? + .ok_or_else(|| { + Error::RegistryInconsistency(format!( + "Workspace {} has no active config revision", + path.workspace_id + )) + })?; + skills::lint(&state).map(Json).map_err(skill_api_error) } async fn scoped_get_skill( @@ -5316,7 +5223,16 @@ async fn scoped_get_skill( AxumPath(path): AxumPath, ) -> ApiResult> { validate_workspace_scope(&api, &path.workspace_id)?; - skills::detail(&api.config.workspace_root, &path.name) + let state = api + .config_store + .load_workspace_config(&path.workspace_id)? + .ok_or_else(|| { + Error::RegistryInconsistency(format!( + "Workspace {} has no active config revision", + path.workspace_id + )) + })?; + skills::detail(&state, &path.name) .map(Json) .map_err(skill_api_error) } @@ -5326,7 +5242,16 @@ async fn scoped_activate_skill( AxumPath(path): AxumPath, ) -> ApiResult> { validate_workspace_scope(&api, &path.workspace_id)?; - skills::activation(&api.config.workspace_root, &path.name) + let state = api + .config_store + .load_workspace_config(&path.workspace_id)? + .ok_or_else(|| { + Error::RegistryInconsistency(format!( + "Workspace {} has no active config revision", + path.workspace_id + )) + })?; + skills::activation(&state, &path.name) .map(Json) .map_err(skill_api_error) } @@ -5345,7 +5270,14 @@ fn skill_api_error(error: skills::SkillError) -> ApiError { message: format!("unknown Skill `{name}`"), }], ), - skills::SkillError::Io(error) => ApiError::from(Error::Io(error)), + skills::SkillError::InvalidSkill(name) => ApiError::from(Error::InvalidInput(format!( + "Skill `{name}` has blocking diagnostics" + ))), + error => ApiError::from(Error::RuntimeOperationFailed { + runtime_id: "workspace".to_string(), + code: "skill_projection_failed".to_string(), + message: error.to_string(), + }), } } @@ -8142,7 +8074,7 @@ async fn test_remote_runtime_connection( async fn get_worker_launch_options( State(api): State, ) -> ApiResult> { - Ok(Json(worker_launch_options_response(&api))) + Ok(Json(worker_launch_options_response(&api)?)) } fn working_directory_request_from_repository( @@ -8298,18 +8230,20 @@ async fn create_workspace_worker( initial_submit, working_directory, } = request; + let config_state = api + .config_store + .load_workspace_config(&api.config.workspace_id)? + .ok_or_else(|| Error::InvalidRecordId("virtual config source tree".into()))?; + let profile_projection = crate::profile_settings::project_profiles_from_workspace_config( + &api.config.workspace_id, + &config_state, + )?; let profile = profile .as_deref() .map(str::trim) .filter(|profile| !profile.is_empty()) .map(ToOwned::to_owned) - .or_else(|| { - crate::profile_settings::load_profile_settings( - &api.config.workspace_id, - &api.config.workspace_root, - ) - .default_profile - }) + .or_else(|| profile_projection.settings.default_profile.clone()) .ok_or_else(|| { settings_bad_request( "workspace_default_profile_missing", @@ -8317,24 +8251,20 @@ async fn create_workspace_worker( ) })?; let profile_selector = - profile_selector_for_candidate_with_root(&api.config.workspace_root, &profile).ok_or_else( - || { + crate::profile_settings::selector_for_workspace_candidate(&profile_projection, &profile) + .ok_or_else(|| { settings_bad_request( "unsupported_worker_profile", "profile must be selected from Backend-published worker profile candidates", ) - }, - )?; - let resolved_config_bundle = if profile.starts_with("project:") { - crate::profile_settings::build_workspace_profile_config_bundle( - &api.config.workspace_root, - &api.config.workspace_id, - &api.config.workspace_created_at, - &profile, - )? - } else { - None - }; + })?; + let resolved_config_bundle = crate::profile_settings::build_virtual_profile_config_bundle( + &profile_projection, + &config_state, + &api.config.workspace_id, + &api.config.workspace_created_at, + &profile, + )?; let display_name = sanitize_worker_display_name(&display_name).ok_or_else(|| { settings_bad_request( "invalid_worker_display_name", @@ -10437,7 +10367,7 @@ async fn probe_remote_json( }) } -fn worker_launch_options_response(api: &WorkspaceApi) -> WorkerLaunchOptionsResponse { +fn worker_launch_options_response(api: &WorkspaceApi) -> ApiResult { let runtimes = api .runtime .list_runtimes(api.config.max_records.min(200)) @@ -10456,10 +10386,17 @@ fn worker_launch_options_response(api: &WorkspaceApi) -> WorkerLaunchOptionsResp } }) .collect(); - let profile_settings = crate::profile_settings::load_profile_settings( + let config_state = api + .config_store + .load_workspace_config(&api.config.workspace_id)? + .ok_or_else(|| { + ApiError::from(Error::InvalidRecordId("virtual config source tree".into())) + })?; + let profile_settings = crate::profile_settings::project_profiles_from_workspace_config( &api.config.workspace_id, - &api.config.workspace_root, - ); + &config_state, + )? + .settings; let profiles = profile_settings .profiles .into_iter() @@ -10477,7 +10414,7 @@ fn worker_launch_options_response(api: &WorkspaceApi) -> WorkerLaunchOptionsResp .unwrap_or_else(|| "Workspace profile.".to_string()), }) .collect(); - WorkerLaunchOptionsResponse { + Ok(WorkerLaunchOptionsResponse { workspace_id: api.config.workspace_id.clone(), runtimes, default_profile: profile_settings.default_profile, @@ -10485,7 +10422,7 @@ fn worker_launch_options_response(api: &WorkspaceApi) -> WorkerLaunchOptionsResp repositories: working_directory_repository_options(api), working_directories: available_working_directory_summaries(api).unwrap_or_default(), diagnostics: Vec::new(), - } + }) } fn working_directory_repository_options( @@ -11069,27 +11006,6 @@ fn working_directory_request_for_browser( }) } -fn profile_selector_for_candidate_with_root( - workspace_root: &Path, - profile: &str, -) -> Option { - if let Some(selector @ ProfileSelector::Builtin(_)) = - crate::profile_settings::selector_for_builtin_candidate(profile) - { - return Some(selector); - } - crate::profile_settings::project_profile_candidates(workspace_root) - .into_iter() - .find(|candidate| { - candidate.profile_id == profile - && !candidate - .diagnostics - .iter() - .any(|diagnostic| diagnostic.severity == DiagnosticSeverity::Error) - }) - .and_then(|_| crate::profile_settings::selector_for_builtin_candidate(profile)) -} - fn parse_runtime_worker_id_for_registry(worker_id: &str) -> ApiResult { worker_id.parse::().map_err(|_| { settings_bad_request( @@ -12776,93 +12692,6 @@ mod tests { assert!(!serialized.contains("materialized_path")); } - #[test] - fn worker_profile_candidates_are_backend_published_and_mapped() { - let dir = tempfile::tempdir().unwrap(); - fs::create_dir_all(dir.path().join(".yoi")).unwrap(); - fs::write( - dir.path().join(".yoi/profiles.toml"), - "default = \"builtin:companion\"\n", - ) - .unwrap(); - let settings = crate::profile_settings::load_profile_settings("workspace-test", dir.path()); - for expected in [ - "builtin:companion", - "builtin:intake", - "builtin:orchestrator", - "builtin:coder", - "builtin:reviewer", - ] { - assert!( - settings - .profiles - .iter() - .any(|profile| profile.profile_id == expected), - "missing {expected}" - ); - } - assert!( - settings - .profiles - .iter() - .all(|profile| profile.profile_id != "builtin:default") - ); - assert_eq!( - settings.default_profile.as_deref(), - Some("builtin:companion") - ); - assert!(matches!( - profile_selector_for_candidate_with_root(dir.path(), "builtin:coder"), - Some(ProfileSelector::Builtin(value)) if value == "builtin:coder" - )); - assert!( - profile_selector_for_candidate_with_root(dir.path(), "free-text-profile").is_none() - ); - } - - #[tokio::test] - async fn worker_launch_options_publish_every_valid_workspace_profile_and_default() { - let dir = tempfile::tempdir().unwrap(); - fs::create_dir_all(dir.path().join(".yoi/profiles")).unwrap(); - fs::write( - dir.path().join(".yoi/profiles.toml"), - concat!( - "default = \"builtin:companion\"\n", - "[profile.custom]\n", - "path = \".yoi/profiles/custom.dcdl\"\n", - ), - ) - .unwrap(); - fs::write( - dir.path().join(".yoi/profiles/custom.dcdl"), - "slug = \"custom\"; description = \"Custom profile\";\n", - ) - .unwrap(); - let api = test_app(dir.path()).await; - - let response = get_json(api, "/api/workers/launch-options").await; - assert_eq!(response["default_profile"], "builtin:companion"); - let profiles = response["profiles"].as_array().unwrap(); - for expected in [ - "builtin:companion", - "builtin:intake", - "builtin:orchestrator", - "builtin:coder", - "builtin:reviewer", - "project:custom", - ] { - assert!( - profiles.iter().any(|profile| profile["id"] == expected), - "missing {expected}: {profiles:?}" - ); - } - assert!( - profiles - .iter() - .all(|profile| profile["id"] != "builtin:default") - ); - } - #[test] fn runtime_connection_request_validation_bounds_browser_input() { let ok = AddRemoteRuntimeConnectionRequest { @@ -12911,218 +12740,6 @@ mod tests { ); } - #[tokio::test] - async fn profile_settings_api_returns_typed_diagnostics_for_duplicate_selector() { - let dir = tempfile::tempdir().unwrap(); - let response = profile_settings_request( - dir.path(), - "POST", - "/settings/profiles", - json!({ - "name": "coder", - "content": valid_profile_source("coder"), - "registry_revision": "missing" - }), - ) - .await; - assert_eq!(response.0, StatusCode::BAD_REQUEST); - assert_diagnostic(&response.1, "profile_selector_duplicate"); - } - - #[tokio::test] - async fn profile_settings_api_returns_typed_diagnostics_for_invalid_decodal() { - let dir = tempfile::tempdir().unwrap(); - let response = profile_settings_request( - dir.path(), - "POST", - "/settings/profiles", - json!({ - "name": "bad", - "content": "not decodal", - "registry_revision": "missing" - }), - ) - .await; - assert_eq!(response.0, StatusCode::BAD_REQUEST); - assert!( - diagnostic_codes(&response.1) - .iter() - .any(|code| code.starts_with("profile_source_")) - ); - } - - #[tokio::test] - async fn profile_settings_api_returns_typed_diagnostic_for_invalid_registry_schema() { - let dir = tempfile::tempdir().unwrap(); - fs::create_dir_all(dir.path().join(".yoi")).unwrap(); - fs::write(dir.path().join(".yoi/profiles.toml"), "[profile\n").unwrap(); - let response = profile_settings_request( - dir.path(), - "POST", - "/settings/profiles", - json!({ - "name": "schema", - "content": valid_profile_source("schema"), - "registry_revision": test_file_revision(&dir.path().join(".yoi/profiles.toml")) - }), - ) - .await; - assert_eq!(response.0, StatusCode::BAD_REQUEST); - assert_diagnostic(&response.1, "profile_registry_schema_invalid"); - } - - #[tokio::test] - async fn profile_settings_api_returns_conflict_diagnostic_for_stale_revision() { - let dir = tempfile::tempdir().unwrap(); - let response = profile_settings_request( - dir.path(), - "POST", - "/settings/profiles", - json!({ - "name": "alpha", - "content": valid_profile_source("alpha"), - "registry_revision": "stale" - }), - ) - .await; - assert_eq!(response.0, StatusCode::CONFLICT); - assert_diagnostic(&response.1, "profile_registry_revision_conflict"); - } - - #[tokio::test] - async fn profile_settings_api_returns_typed_diagnostic_for_too_large_source() { - let dir = tempfile::tempdir().unwrap(); - let response = profile_settings_request( - dir.path(), - "POST", - "/settings/profiles", - json!({ - "name": "large", - "content": "x".repeat((256 * 1024) + 1), - "registry_revision": "missing" - }), - ) - .await; - assert_eq!(response.0, StatusCode::BAD_REQUEST); - assert_diagnostic(&response.1, "profile_source_too_large"); - } - - #[cfg(unix)] - #[tokio::test] - async fn profile_settings_api_redacts_symlink_escape_response() { - let dir = tempfile::tempdir().unwrap(); - fs::create_dir_all(dir.path().join(".yoi/profiles")).unwrap(); - fs::write(dir.path().join(".yoi/profiles.toml"), "").unwrap(); - let outside = dir.path().join("outside.dcdl"); - fs::write(&outside, valid_profile_source("escape")).unwrap(); - std::os::unix::fs::symlink(&outside, dir.path().join(".yoi/profiles/escape.dcdl")).unwrap(); - let response = profile_settings_request( - dir.path(), - "PUT", - "/settings/profiles/registry", - json!({ - "registry_revision": test_file_revision(&dir.path().join(".yoi/profiles.toml")), - "default_profile": null, - "profiles": [{ "name": "escape", "profile_source_id": "project:escape" }] - }), - ) - .await; - assert_eq!(response.0, StatusCode::BAD_REQUEST); - assert_diagnostic(&response.1, "profile_source_symlink_escape"); - let rendered = response.1.to_string(); - assert!(!rendered.contains(dir.path().to_string_lossy().as_ref())); - } - - #[tokio::test] - async fn worker_launch_rejects_invalid_project_profile_candidate() { - let dir = tempfile::tempdir().unwrap(); - fs::create_dir_all(dir.path().join(".yoi/profiles")).unwrap(); - fs::write( - dir.path().join(".yoi/profiles.toml"), - "[profile.bad]\npath = \"profiles/bad.dcdl\"\n", - ) - .unwrap(); - fs::write(dir.path().join(".yoi/profiles/bad.dcdl"), "not decodal").unwrap(); - let candidates = - crate::profile_settings::load_profile_settings("workspace-test", dir.path()) - .profiles - .into_iter() - .filter(|profile| { - !profile - .diagnostics - .iter() - .any(|diagnostic| diagnostic.severity == DiagnosticSeverity::Error) - }) - .map(|profile| profile.profile_id) - .collect::>(); - assert!(!candidates.iter().any(|profile| profile == "project:bad")); - assert!(profile_selector_for_candidate_with_root(dir.path(), "project:bad").is_none()); - } - - fn valid_profile_source(slug: &str) -> String { - format!( - r#"{{ - slug = "{slug}"; - description = "Test"; - scope = "workspace_read"; - }}"# - ) - } - - fn test_file_revision(path: &Path) -> String { - let Ok(metadata) = fs::metadata(path) else { - return "missing".to_string(); - }; - let modified = metadata - .modified() - .ok() - .and_then(|time| time.duration_since(std::time::UNIX_EPOCH).ok()) - .map(|duration| duration.as_nanos()) - .unwrap_or_default(); - format!("rev:{modified}:{}", metadata.len()) - } - - async fn profile_settings_request( - workspace_root: &Path, - method: &str, - path: &str, - body: Value, - ) -> (StatusCode, Value) { - let app = test_app(workspace_root.to_path_buf()).await; - let request = Request::builder() - .method(method) - .uri(format!("/api/w/{TEST_WORKSPACE_ID}{path}")) - .header(CONTENT_TYPE, "application/json") - .body(Body::from(body.to_string())) - .unwrap(); - let response = app.oneshot(request).await.unwrap(); - let status = response.status(); - let bytes = to_bytes(response.into_body(), 1024 * 1024).await.unwrap(); - let json = serde_json::from_slice::(&bytes).unwrap(); - (status, json) - } - - fn diagnostic_codes(response: &Value) -> Vec { - response["diagnostics"] - .as_array() - .expect("diagnostics array") - .iter() - .map(|diagnostic| diagnostic["code"].as_str().unwrap().to_string()) - .collect() - } - - fn assert_diagnostic(response: &Value, code: &str) { - let codes = diagnostic_codes(response); - assert!( - !codes.is_empty(), - "diagnostics must not be empty: {response}" - ); - assert!( - codes.iter().any(|actual| actual == code), - "missing {code}: {codes:?}" - ); - } - struct DeterministicExecutionBackend { contexts: std::sync::Mutex< std::collections::HashMap< @@ -13405,14 +13022,7 @@ mod tests { ) .unwrap(); - let resolved_config_bundle = - crate::profile_settings::build_workspace_profile_config_bundle( - &api.config.workspace_root, - &api.config.workspace_id, - &api.config.workspace_created_at, - MEMORY_CONSOLIDATION_PROFILE, - ) - .unwrap(); + let resolved_config_bundle = None; let existing = api .runtime .spawn_worker( @@ -14463,16 +14073,60 @@ mod tests { } #[tokio::test] - async fn skills_endpoints_use_workspace_backend_catalog_and_progressive_detail() { + async fn skills_endpoints_use_active_virtual_config_and_ignore_repository_yoi() { let dir = tempfile::tempdir().unwrap(); let skill_dir = dir.path().join(".yoi/skills/triage-errors"); fs::create_dir_all(&skill_dir).unwrap(); fs::write( skill_dir.join("SKILL.md"), - "---\nname: triage-errors\ndescription: Use when triaging backend errors and choosing safe diagnostics.\n---\n\n# Triage Errors\n\nInspect logs before changing code.", + "---\nname: triage-errors\ndescription: stale filesystem authority\n---\nfilesystem body", ) .unwrap(); let api = test_api(dir.path()).await; + let current = api + .config_store + .load_workspace_config(TEST_WORKSPACE_ID) + .unwrap() + .unwrap(); + let main_path = config_source::VirtualPath::parse("main.dcdl").unwrap(); + let skill_path = + config_source::VirtualPath::parse("skills/triage-errors/SKILL.md").unwrap(); + let main = format!( + r#"{{ skills = {{ triage_errors = import "./skills/triage-errors/SKILL.md" as {}; }}; }}"#, + skills::SKILL_DOCUMENT_SCHEMA_SOURCE + ); + let request = crate::config_source::ConfigCommitRequest { + base_revision: current.snapshot.revision, + base_digest: current.snapshot.digest.clone(), + changes: vec![ + config_source::ConfigTreeChange::Update { + path: main_path.clone(), + expected_digest: current.snapshot.entries[&main_path] + .content_digest + .clone(), + content: main, + }, + config_source::ConfigTreeChange::Create { + path: skill_path, + content_type: config_source::ConfigContentType::Text, + content: "---\nname: triage-errors\ndescription: Use the active DB-backed virtual config when triaging errors.\n---\n# Triage Errors\n\nInspect logs before changing code." + .to_string(), + }, + ], + entrypoints: current.contract.entrypoints.clone(), + toolchain_fingerprint: current.contract.fingerprint.clone(), + }; + let candidate = api + .config_store + .evaluate_workspace_config_candidate_with_schema( + TEST_WORKSPACE_ID, + &request, + api.config_schema_registry.compose().unwrap(), + ) + .unwrap(); + api.config_store + .commit_evaluated_workspace_config(TEST_WORKSPACE_ID, &candidate) + .unwrap(); let Json(catalog) = scoped_list_skills( State(api.clone()), @@ -14488,6 +14142,13 @@ mod tests { .find(|entry| entry.name == "triage-errors") .expect("workspace Skill catalog entry"); assert_eq!(entry.provenance.id, "workspace:triage-errors"); + assert_eq!( + entry.provenance.virtual_path.as_deref(), + Some("skills/triage-errors/SKILL.md") + ); + assert!(entry.provenance.revision.is_some()); + assert!(entry.provenance.source_digest.is_some()); + assert_ne!(entry.description, "stale filesystem authority"); assert!( !serde_json::to_string(&catalog) .unwrap() @@ -15615,6 +15276,7 @@ mod tests { label: Some("server-test".to_string()), }], declarations: Vec::new(), + prompt_catalog: None, profile_source_archive: None, profile_source_archive_handle: None, } @@ -16064,12 +15726,6 @@ mod tests { #[tokio::test] async fn browser_worker_create_uses_workspace_default_and_preserves_unsupported_diagnostics() { let dir = tempfile::tempdir().unwrap(); - fs::create_dir_all(dir.path().join(".yoi")).unwrap(); - fs::write( - dir.path().join(".yoi/profiles.toml"), - "default = \"builtin:coder\"\n", - ) - .unwrap(); let app = test_app(dir.path()).await; let created = post_json( app.clone(), @@ -16090,7 +15746,7 @@ mod tests { .find(|worker| worker["worker_id"] == created["worker_id"]) .expect("created Worker should be listed"); assert_eq!(worker["label"], "Worker"); - assert_eq!(worker["profile"], "builtin:coder"); + assert_eq!(worker["profile"], "builtin:companion"); assert!(worker.get("role").is_none()); assert_eq!(worker["worker_id"], created["worker_id"]); let detail_path = format!( @@ -17745,6 +17401,37 @@ mod tests { assert_eq!(shown["linked_tickets"], json!(["00000000001J3"])); } + #[tokio::test] + async fn profile_settings_are_read_only_virtual_config_projection() { + let dir = tempfile::tempdir().unwrap(); + let app = test_app(dir.path()).await; + let path = format!("/api/w/{TEST_WORKSPACE_ID}/settings/profiles"); + let settings = get_json(app.clone(), &path).await; + assert_eq!(settings["default_profile"], "builtin:companion"); + assert_eq!(settings["config_revision"], 1); + assert!(settings["tree_digest"].as_str().is_some()); + assert!(settings["projection_digest"].as_str().is_some()); + assert!( + settings["profiles"] + .as_array() + .unwrap() + .iter() + .all(|profile| profile["editable"] == false) + ); + let mutation = app + .oneshot( + Request::builder() + .method("POST") + .uri(&path) + .header(CONTENT_TYPE, "application/json") + .body(Body::from("{}")) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(mutation.status(), StatusCode::METHOD_NOT_ALLOWED); + } + async fn get_json(app: Router, uri: &str) -> Value { let response = app .oneshot(Request::builder().uri(uri).body(Body::empty()).unwrap()) diff --git a/crates/workspace-server/src/skills.rs b/crates/workspace-server/src/skills.rs index a55977ec..03bcc793 100644 --- a/crates/workspace-server/src/skills.rs +++ b/crates/workspace-server/src/skills.rs @@ -1,789 +1,600 @@ -use std::collections::BTreeMap; -use std::fs; -use std::path::{Path, PathBuf}; +use std::collections::{BTreeMap, BTreeSet}; +use config_source::{ + ConfigSchemaContribution, MarkdownDocumentProjection, VirtualPath, project_markdown_document, +}; use serde::Deserialize; use worker::skill::{ SkillActivationResponse, SkillCatalogEntry, SkillCatalogResponse, SkillDetailResponse, SkillDiagnostic, SkillDiagnosticSeverity, SkillProvenance, SkillResourceRef, SkillSourceKind, }; -const BUILTIN_AGENT_SKILLS: &str = include_str!("../../../resources/skills/agent-skills/SKILL.md"); +use crate::config_source::{ + WorkspaceConfigSchemaProvider, WorkspaceConfigState, evaluate_workspace_config_state, +}; + +const BUILTIN_SKILL_ID: &str = "agent-skills"; +const BUILTIN_SKILL_SOURCE: &str = include_str!("../../../resources/skills/agent-skills/SKILL.md"); +const BUILTIN_SKILL_VIRTUAL_PATH: &str = "builtin/skills/agent-skills/SKILL.md"; +const SKILL_SCHEMA_PROVIDER_ID: &str = "builtin:skills"; +const SKILL_SCHEMA_NAMESPACE: &str = "skills"; +const SKILL_SCHEMA_VERSION: &str = "1"; +const SKILL_CATALOG_AUTHORITY: &str = "workspace-config-skills-v1"; + +/// Skill documents are values imported from `SKILL.md`. Known Agent Skills +/// frontmatter is typed while extension keys remain concrete values. +pub const SKILL_DOCUMENT_SCHEMA_SOURCE: &str = r#"{ + frontmatter = { + name = String; + description = String; + license = String default ""; + compatibility = String default ""; + metadata = {...String} default {}; + ...Unknown + }; + content = String; +}"#; + +const SKILL_CONFIG_SCHEMA_SOURCE: &str = r#"{ + skills = {...{ + frontmatter = { + name = String; + description = String; + license = String default ""; + compatibility = String default ""; + metadata = {...String} default {}; + ...Unknown + }; + content = String; + }} default {}; +}"#; + +#[derive(Debug, Clone, Copy)] +pub struct SkillConfigSchemaProvider; + +impl WorkspaceConfigSchemaProvider for SkillConfigSchemaProvider { + fn contribution(&self) -> crate::Result { + ConfigSchemaContribution::new( + SKILL_SCHEMA_PROVIDER_ID, + SKILL_SCHEMA_NAMESPACE, + SKILL_SCHEMA_VERSION, + SKILL_CONFIG_SCHEMA_SOURCE, + ) + .map_err(|error| crate::Error::Config(error.to_string())) + } +} #[derive(Debug, thiserror::Error)] pub enum SkillError { #[error("unknown Skill `{0}`")] NotFound(String), - #[error("io error: {0}")] - Io(#[from] std::io::Error), -} - -#[derive(Debug, Clone)] -struct SkillSource { - source_kind: SkillSourceKind, - parent_name: String, - content: String, - resource_root: Option, + #[error("Skill `{0}` has blocking diagnostics")] + InvalidSkill(String), + #[error("failed to evaluate the active Workspace config revision: {0}")] + Evaluation(String), + #[error("the active Workspace config projection is missing its root value")] + MissingProjection, + #[error("the active Workspace config Skill projection is invalid: {0}")] + InvalidProjection(String), } #[derive(Debug, Clone)] struct ParsedSkill { name: String, description: String, - content: String, allowed_tools: Vec, - diagnostics: Vec, + body: String, provenance: SkillProvenance, - resource_root: Option, + overrides: Vec, + resources: Vec, + diagnostics: Vec, } #[derive(Debug, Deserialize)] -#[serde(rename_all = "kebab-case")] -struct SkillFrontmatter { - name: Option, - description: Option, - license: Option, - compatibility: Option, - metadata: Option>, +struct WorkspaceSkillProjection { #[serde(default)] - allowed_tools: Option, + skills: BTreeMap, } -pub fn catalog(workspace_root: &Path) -> SkillCatalogResponse { - let mut diagnostics = Vec::new(); - let mut active = BTreeMap::::new(); - let mut builtin_by_name = BTreeMap::::new(); - - for source in builtin_skill_sources() { - match parse_skill_source(source) { - Ok(skill) => { - builtin_by_name.insert(skill.name.clone(), skill.clone()); - active.insert(skill.name.clone(), skill); - } - Err(errs) => diagnostics.extend(errs), - } - } - - let workspace_sources = match workspace_skill_sources(workspace_root) { - Ok(sources) => sources, - Err(error) => { - diagnostics.push(SkillDiagnostic::error( - "workspace_skill_read_failed", - format!("failed to read workspace Skills: {error}"), - Some("workspace:.yoi/skills".to_string()), - )); - Vec::new() - } - }; - - for source in workspace_sources { - match parse_skill_source(source) { - Ok(skill) => { - let overrides = builtin_by_name - .get(&skill.name) - .map(|builtin| vec![builtin.provenance.clone()]) - .unwrap_or_default(); - if let Some(overridden) = overrides.first() { - diagnostics.push(SkillDiagnostic::warning( - "workspace_skill_overrides_builtin", - format!( - "workspace Skill `{}` overrides builtin Skill `{}`", - skill.name, overridden.id - ), - Some(skill.provenance.id.clone()), - )); - } - let mut skill = skill; - if !overrides.is_empty() { - skill.diagnostics.push(SkillDiagnostic::warning( - "workspace_skill_overrides_builtin", - "workspace Skill has priority over the builtin Skill with the same name", - Some(skill.provenance.id.clone()), - )); - } - active.insert(skill.name.clone(), skill); - } - Err(errs) => diagnostics.extend(errs), - } - } - - let mut entries = active +pub fn catalog(state: &WorkspaceConfigState) -> Result { + let entries = merged_skills(state)? .into_values() - .map(|skill| { - let overrides = if matches!(&skill.provenance.kind, SkillSourceKind::Workspace) { - builtin_by_name - .get(&skill.name) - .map(|builtin| vec![builtin.provenance.clone()]) - .unwrap_or_default() - } else { - Vec::new() - }; - SkillCatalogEntry { - name: skill.name, - description: skill.description, - provenance: skill.provenance, - overrides, - diagnostics: skill.diagnostics, - } - }) - .collect::>(); - entries.sort_by(|a, b| a.name.cmp(&b.name)); - - SkillCatalogResponse { - authority: "workspace-backend-skills-v0".to_string(), + .map(|skill| skill.catalog_entry()) + .collect(); + Ok(SkillCatalogResponse { + authority: SKILL_CATALOG_AUTHORITY.to_string(), entries, - diagnostics, - } + diagnostics: Vec::new(), + }) } -pub fn lint(workspace_root: &Path) -> SkillCatalogResponse { - catalog(workspace_root) +pub fn lint(state: &WorkspaceConfigState) -> Result { + catalog(state) } -pub fn detail(workspace_root: &Path, name: &str) -> Result { - let skill = active_skill(workspace_root, name)?; - let overrides = if matches!(&skill.provenance.kind, SkillSourceKind::Workspace) { - builtin_skill_sources() - .into_iter() - .filter_map(|source| parse_skill_source(source).ok()) - .find(|builtin| builtin.name == skill.name) - .map(|builtin| vec![builtin.provenance]) - .unwrap_or_default() - } else { - Vec::new() - }; - let resources = resource_refs(&skill); +pub fn detail(state: &WorkspaceConfigState, name: &str) -> Result { + let skill = merged_skills(state)? + .remove(name) + .ok_or_else(|| SkillError::NotFound(name.to_string()))?; Ok(SkillDetailResponse { name: skill.name, description: skill.description, provenance: skill.provenance, - overrides, + overrides: skill.overrides, diagnostics: skill.diagnostics, - body: skill.content, + body: skill.body, allowed_tools: skill.allowed_tools, - allowed_tools_status: - "experimental_ignored_by_workspace_backend; does not grant or deny tool authority" - .to_string(), - resources, + allowed_tools_status: "experimental_hint_only".to_string(), + resources: skill.resources, }) } pub fn activation( - workspace_root: &Path, + state: &WorkspaceConfigState, name: &str, ) -> Result { - let detail = detail(workspace_root, name)?; + let skill = merged_skills(state)? + .remove(name) + .ok_or_else(|| SkillError::NotFound(name.to_string()))?; + if skill.has_errors() { + return Err(SkillError::InvalidSkill(name.to_string())); + } Ok(SkillActivationResponse { - name: detail.name, - provenance: detail.provenance, - diagnostics: detail.diagnostics, - body: detail.body, + name: skill.name, + provenance: skill.provenance, + diagnostics: skill.diagnostics, + body: skill.body, }) } -fn active_skill(workspace_root: &Path, name: &str) -> Result { - let mut parsed = BTreeMap::::new(); - for source in builtin_skill_sources() { - if let Ok(skill) = parse_skill_source(source) { - parsed.insert(skill.name.clone(), skill); +fn merged_skills( + state: &WorkspaceConfigState, +) -> Result, SkillError> { + let mut merged = BTreeMap::new(); + let builtin_projection = project_markdown_document(BUILTIN_SKILL_SOURCE) + .expect("embedded built-in Skill Markdown is valid"); + let builtin = parse_skill( + BUILTIN_SKILL_ID, + builtin_projection, + SkillProvenance { + kind: SkillSourceKind::Builtin, + id: format!("builtin:{BUILTIN_SKILL_ID}"), + virtual_path: Some(BUILTIN_SKILL_VIRTUAL_PATH.to_string()), + revision: None, + source_digest: Some(config_source::digest_bytes(BUILTIN_SKILL_SOURCE.as_bytes())), + tree_digest: None, + }, + Vec::new(), + None, + ); + merged.insert(builtin.name.clone(), builtin); + + let evaluation = evaluate_workspace_config_state(state, state.contract.schema_bundle.clone()) + .map_err(|error| SkillError::Evaluation(error.to_string()))?; + let projection = evaluation + .projections + .first() + .ok_or(SkillError::MissingProjection)?; + let workspace = + serde_json::from_value::(projection.data_json.clone()) + .map_err(|error| SkillError::InvalidProjection(error.to_string()))?; + + for (config_key, document) in workspace.skills { + let name = string_field(&document.frontmatter, "name") + .unwrap_or(&config_key) + .to_string(); + let canonical_path = format!("skills/{name}/SKILL.md"); + let mut source_diagnostic = None; + let source_entry = VirtualPath::parse(&canonical_path) + .ok() + .and_then(|path| state.snapshot.entries.get(&path)); + if let Some(entry) = source_entry { + match project_markdown_document(&entry.content) { + Ok(expected) if normalize_document(expected.clone()) == document => {} + Ok(_) => { + source_diagnostic = Some(SkillDiagnostic::error( + "skill_source_mismatch", + format!( + "Skill `{name}` must be the imported value of `{canonical_path}` in the active config revision" + ), + Some(format!("workspace:{name}")), + )); + } + Err(message) => { + source_diagnostic = Some(SkillDiagnostic::error( + "invalid_skill_markdown", + format!("{canonical_path}: {message}"), + Some(format!("workspace:{name}")), + )); + } + } + } else { + source_diagnostic = Some(SkillDiagnostic::error( + "missing_skill_source", + format!("Skill `{name}` requires `{canonical_path}` in the active config revision"), + Some(format!("workspace:{name}")), + )); + } + let source_digest = source_entry + .map(|entry| entry.content_digest.clone()) + .unwrap_or_else(|| config_source::digest_bytes(canonical_path.as_bytes())); + let resources = workspace_resources(state, &name); + let mut skill = parse_skill( + &name, + document, + SkillProvenance { + kind: SkillSourceKind::Workspace, + id: format!("workspace:{name}"), + virtual_path: Some(canonical_path), + revision: Some(state.snapshot.revision), + source_digest: Some(source_digest), + tree_digest: Some(state.snapshot.digest.clone()), + }, + resources, + source_diagnostic, + ); + if let Some(overridden) = merged.insert(name, skill.clone()) { + skill.overrides.push(overridden.provenance); + merged.insert(skill.name.clone(), skill); } } - for source in workspace_skill_sources(workspace_root)? { - if let Ok(skill) = parse_skill_source(source) { - parsed.insert(skill.name.clone(), skill); - } - } - parsed - .remove(name) - .ok_or_else(|| SkillError::NotFound(name.to_string())) + Ok(merged) } -fn builtin_skill_sources() -> Vec { - vec![SkillSource { - source_kind: SkillSourceKind::Builtin, - parent_name: "agent-skills".to_string(), - content: BUILTIN_AGENT_SKILLS.to_string(), - resource_root: None, - }] +fn normalize_document(mut document: MarkdownDocumentProjection) -> MarkdownDocumentProjection { + document + .frontmatter + .entry("license") + .or_insert_with(|| serde_json::Value::String(String::new())); + document + .frontmatter + .entry("compatibility") + .or_insert_with(|| serde_json::Value::String(String::new())); + document + .frontmatter + .entry("metadata") + .or_insert_with(|| serde_json::Value::Object(serde_json::Map::new())); + document } -fn workspace_skill_sources(workspace_root: &Path) -> Result, std::io::Error> { - let skills_dir = workspace_root.join(".yoi").join("skills"); - if !skills_dir.exists() { - return Ok(Vec::new()); - } - let mut sources = Vec::new(); - for entry in fs::read_dir(&skills_dir)? { - let entry = entry?; - let file_type = entry.file_type()?; - if !file_type.is_dir() { - continue; - } - let parent_name = entry.file_name().to_string_lossy().to_string(); - let skill_path = entry.path().join("SKILL.md"); - if !skill_path.exists() { - sources.push(SkillSource { - source_kind: SkillSourceKind::Workspace, - parent_name, - content: String::new(), - resource_root: Some(entry.path()), - }); - continue; - } - match fs::read_to_string(&skill_path) { - Ok(content) => sources.push(SkillSource { - source_kind: SkillSourceKind::Workspace, - parent_name, - content, - resource_root: Some(entry.path()), - }), - Err(error) => sources.push(SkillSource { - source_kind: SkillSourceKind::Workspace, - parent_name, - content: format!("__read_error__:{error}"), - resource_root: None, - }), - } - } - Ok(sources) -} - -fn parse_skill_source(source: SkillSource) -> Result> { - let provenance = provenance(source.source_kind.clone(), &source.parent_name); +fn parse_skill( + fallback_name: &str, + document: MarkdownDocumentProjection, + provenance: SkillProvenance, + resources: Vec, + source_diagnostic: Option, +) -> ParsedSkill { let mut diagnostics = Vec::new(); - - if !valid_skill_name(&source.parent_name) { + if let Some(diagnostic) = source_diagnostic { + diagnostics.push(diagnostic); + } + let frontmatter = document.frontmatter; + let name = string_field(&frontmatter, "name").unwrap_or(fallback_name); + if !valid_skill_name(name) { diagnostics.push(SkillDiagnostic::error( - "invalid_skill_directory_name", - "Skill directory name must be 1-64 chars of lowercase letters, numbers, or single hyphens with no leading/trailing hyphen", + "invalid_skill_name", + "frontmatter `name` must be a lowercase kebab-case Skill id", Some(provenance.id.clone()), )); } - if source.content.is_empty() { + if name != fallback_name { diagnostics.push(SkillDiagnostic::error( - "missing_skill_markdown", - "Skill directory must contain SKILL.md", - Some(provenance.id.clone()), - )); - return Err(diagnostics); - } - if source.content.starts_with("__read_error__:") { - diagnostics.push(SkillDiagnostic::error( - "skill_markdown_read_failed", - source - .content - .trim_start_matches("__read_error__:") - .to_string(), - Some(provenance.id.clone()), - )); - return Err(diagnostics); - } - - let Some((frontmatter, _markdown)) = split_frontmatter(&source.content) else { - diagnostics.push(SkillDiagnostic::error( - "missing_frontmatter", - "SKILL.md must start with YAML frontmatter delimited by ---", - Some(provenance.id.clone()), - )); - return Err(diagnostics); - }; - let frontmatter_value = match serde_yaml::from_str::(frontmatter) { - Ok(value) => value, - Err(error) => { - diagnostics.push(SkillDiagnostic::error( - "invalid_frontmatter_yaml", - format!("SKILL.md frontmatter is invalid YAML: {error}"), - Some(provenance.id.clone()), - )); - return Err(diagnostics); - } - }; - diagnose_unsupported_frontmatter_keys(&frontmatter_value, &provenance, &mut diagnostics); - let frontmatter = match serde_yaml::from_value::(frontmatter_value) { - Ok(frontmatter) => frontmatter, - Err(error) => { - diagnostics.push(SkillDiagnostic::error( - "invalid_frontmatter_yaml", - format!("SKILL.md frontmatter is invalid YAML: {error}"), - Some(provenance.id.clone()), - )); - return Err(diagnostics); - } - }; - - let Some(name) = frontmatter.name else { - diagnostics.push(SkillDiagnostic::error( - "missing_name", - "Skill frontmatter requires `name`", - Some(provenance.id.clone()), - )); - return Err(diagnostics); - }; - if name != source.parent_name { - diagnostics.push(SkillDiagnostic::error( - "name_parent_mismatch", - "Skill frontmatter `name` must match its parent directory name", + "skill_name_mismatch", + format!("frontmatter name `{name}` must match Skill id `{fallback_name}`"), Some(provenance.id.clone()), )); } - if !valid_skill_name(&name) { - diagnostics.push(SkillDiagnostic::error( - "invalid_name", - "Skill name must be 1-64 chars of lowercase letters, numbers, or single hyphens with no leading/trailing hyphen", - Some(provenance.id.clone()), - )); - } - - let Some(description) = frontmatter.description else { + let description = string_field(&frontmatter, "description") + .unwrap_or_default() + .trim() + .to_string(); + if description.is_empty() { diagnostics.push(SkillDiagnostic::error( "missing_description", - "Skill frontmatter requires `description`", - Some(provenance.id.clone()), - )); - return Err(diagnostics); - }; - let description = description.trim().to_string(); - if description.is_empty() || description.chars().count() > 1024 { - diagnostics.push(SkillDiagnostic::error( - "invalid_description", - "Skill description must be 1-1024 characters", - Some(provenance.id.clone()), - )); - } else if description.chars().count() < 16 { - diagnostics.push(SkillDiagnostic::warning( - "description_too_generic", - "Skill description should state concrete when/what guidance", + "frontmatter `description` must be a non-empty string", Some(provenance.id.clone()), )); } - - if let Some(license) = frontmatter.license.as_deref() { - validate_optional_string("license", license, &provenance, &mut diagnostics); - } - if let Some(compatibility) = frontmatter.compatibility.as_deref() { - validate_optional_string( - "compatibility", - compatibility, - &provenance, - &mut diagnostics, - ); - } - if let Some(metadata) = frontmatter.metadata { - for (key, value) in metadata { - if !matches!(value, serde_yaml::Value::String(_)) { - diagnostics.push(SkillDiagnostic::error( - "invalid_metadata_value", - format!("metadata `{key}` must be a string value"), - Some(provenance.id.clone()), - )); - } + for field in [ + "profile", + "system_prompt", + "prompt", + "plugins", + "plugin", + "model_invokation", + "model_invocation", + "user_invocable", + "graph", + "invocation", + ] { + if frontmatter.contains_key(field) { + diagnostics.push(SkillDiagnostic::error( + "workflow_authority", + format!("frontmatter `{field}` is workflow/profile authority and is not allowed"), + Some(provenance.id.clone()), + )); } } - - let mut allowed_tools = Vec::new(); - if let Some(value) = frontmatter.allowed_tools { - allowed_tools = parse_allowed_tools(value, &provenance, &mut diagnostics); + let allowed_tools = frontmatter + .get("allowed-tools") + .map(parse_allowed_tools) + .unwrap_or_default(); + if !allowed_tools.is_empty() { diagnostics.push(SkillDiagnostic::warning( - "allowed_tools_ignored", - "allowed-tools is experimental metadata only; Workspace Skill activation does not grant or deny tools", + "allowed_tools_hint", + "frontmatter `allowed-tools` is an instruction hint only and does not grant tools", Some(provenance.id.clone()), )); } - - if diagnostics - .iter() - .any(|d| d.severity == SkillDiagnosticSeverity::Error) - { - return Err(diagnostics); - } - - Ok(ParsedSkill { - name, + ParsedSkill { + name: fallback_name.to_string(), description, - content: source.content, allowed_tools, - diagnostics, + body: document.content, provenance, - resource_root: source.resource_root, - }) -} - -fn provenance(kind: SkillSourceKind, parent_name: &str) -> SkillProvenance { - let prefix = match kind { - SkillSourceKind::Builtin => "builtin", - SkillSourceKind::Workspace => "workspace", - }; - SkillProvenance { - kind, - id: format!("{prefix}:{parent_name}"), + overrides: Vec::new(), + resources, + diagnostics, } } -fn split_frontmatter(content: &str) -> Option<(&str, &str)> { - let rest = content.strip_prefix("---\n")?; - let (frontmatter, body) = rest.split_once("\n---")?; - let body = body.strip_prefix('\n').unwrap_or(body); - Some((frontmatter, body)) -} - fn valid_skill_name(name: &str) -> bool { - let len = name.chars().count(); - if !(1..=64).contains(&len) - || name.starts_with('-') - || name.ends_with('-') - || name.contains("--") - { - return false; - } - name.chars() - .all(|ch| ch.is_ascii_lowercase() || ch.is_ascii_digit() || ch == '-') + !name.is_empty() + && !name.starts_with('-') + && !name.ends_with('-') + && !name.contains("--") + && name + .bytes() + .all(|byte| byte.is_ascii_lowercase() || byte.is_ascii_digit() || byte == b'-') } -fn validate_optional_string( +fn string_field<'a>( + frontmatter: &'a serde_json::Map, field: &str, - value: &str, - provenance: &SkillProvenance, - diagnostics: &mut Vec, -) { - if value.trim().is_empty() { - diagnostics.push(SkillDiagnostic::error( - format!("invalid_{field}"), - format!("optional `{field}` must be a non-empty string when present"), - Some(provenance.id.clone()), - )); - } +) -> Option<&'a str> { + frontmatter.get(field).and_then(serde_json::Value::as_str) } -fn diagnose_unsupported_frontmatter_keys( - value: &serde_yaml::Value, - provenance: &SkillProvenance, - diagnostics: &mut Vec, -) { - let Some(mapping) = value.as_mapping() else { - diagnostics.push(SkillDiagnostic::error( - "invalid_frontmatter_shape", - "SKILL.md frontmatter must be a YAML mapping", - Some(provenance.id.clone()), - )); - return; - }; - - for key in mapping.keys() { - let Some(key) = key.as_str() else { - diagnostics.push(SkillDiagnostic::error( - "invalid_frontmatter_key", - "Skill frontmatter keys must be strings", - Some(provenance.id.clone()), - )); - continue; - }; - if !is_supported_frontmatter_key(key) { - let (code, message) = if is_workflow_projection_key(key) { - ( - "unsupported_workflow_frontmatter_field", - format!( - "Skill frontmatter field `{key}` is a removed Workflow projection/invocation field and is not accepted as Skill semantics" - ), - ) - } else { - ( - "unsupported_frontmatter_field", - format!( - "Skill frontmatter field `{key}` is not supported; supported fields are name, description, license, compatibility, metadata, and allowed-tools" - ), - ) - }; - diagnostics.push(SkillDiagnostic::error( - code, - message, - Some(provenance.id.clone()), - )); - } - } -} - -fn is_supported_frontmatter_key(key: &str) -> bool { - matches!( - key, - "name" | "description" | "license" | "compatibility" | "metadata" | "allowed-tools" - ) -} - -fn is_workflow_projection_key(key: &str) -> bool { - matches!( - key, - "model_invokation" - | "model_invocation" - | "user_invocable" - | "workflow" - | "workflow_record" - | "workflow_invoke" - | "invocation" - | "invocations" - | "graph" - | "nodes" - | "edges" - | "triggers" - ) -} - -fn parse_allowed_tools( - value: serde_yaml::Value, - provenance: &SkillProvenance, - diagnostics: &mut Vec, -) -> Vec { +fn parse_allowed_tools(value: &serde_json::Value) -> Vec { match value { - serde_yaml::Value::String(text) => text - .split(',') - .map(str::trim) - .filter(|item| !item.is_empty()) + serde_json::Value::String(value) => value + .split(|character: char| character == ',' || character.is_whitespace()) + .filter(|value| !value.is_empty()) .map(ToOwned::to_owned) .collect(), - serde_yaml::Value::Sequence(items) => items - .into_iter() - .filter_map(|item| match item { - serde_yaml::Value::String(text) if !text.trim().is_empty() => Some(text), - _ => { - diagnostics.push(SkillDiagnostic::warning( - "invalid_allowed_tools_entry_ignored", - "allowed-tools entries must be strings; invalid entries are ignored", - Some(provenance.id.clone()), - )); - None - } - }) + serde_json::Value::Array(values) => values + .iter() + .filter_map(serde_json::Value::as_str) + .map(ToOwned::to_owned) .collect(), - _ => { - diagnostics.push(SkillDiagnostic::warning( - "invalid_allowed_tools_ignored", - "allowed-tools must be a string or string list; value ignored", - Some(provenance.id.clone()), - )); - Vec::new() - } + _ => Vec::new(), } } -fn resource_refs(skill: &ParsedSkill) -> Vec { - let Some(root) = &skill.resource_root else { - return Vec::new(); - }; - let mut refs = Vec::new(); - for (dir, kind, supported, diagnostic) in [ - ( - "references", - "reference", - false, - Some( - "Skill references are listed only; resource read endpoints are not implemented yet", - ), - ), - ( - "assets", - "asset", - false, - Some("Skill assets are listed only; resource read endpoints are not implemented yet"), - ), - ( - "scripts", - "script", - false, - Some( - "Skill scripts are discovered but not executable; use normal typed tools and permissions", - ), - ), +fn workspace_resources(state: &WorkspaceConfigState, name: &str) -> Vec { + let mut resources = Vec::new(); + for (kind, directory) in [ + ("reference", "references"), + ("asset", "assets"), + ("script", "scripts"), ] { - let path = root.join(dir); - if !path.is_dir() { + let prefix = format!("skills/{name}/{directory}/"); + for child in resource_children(state, &prefix) { + resources.push(SkillResourceRef { + kind: kind.to_string(), + name: child, + supported: kind == "reference", + diagnostic: (kind != "reference").then(|| { + format!("{kind} resources are catalogued but are not loaded automatically") + }), + }); + } + } + resources +} + +fn resource_children(state: &WorkspaceConfigState, prefix: &str) -> Vec { + let mut children = BTreeSet::new(); + for path in state.snapshot.entries.keys() { + let Some(relative) = path.as_str().strip_prefix(prefix) else { + continue; + }; + if relative.is_empty() { continue; } - if let Ok(entries) = fs::read_dir(&path) { - for entry in entries.flatten() { - if let Ok(file_type) = entry.file_type() { - if !(file_type.is_file() || file_type.is_dir()) { - continue; - } - } - let name = format!("{dir}/{}", entry.file_name().to_string_lossy()); - refs.push(SkillResourceRef { - kind: kind.to_string(), - name, - supported, - diagnostic: diagnostic.map(ToOwned::to_owned), - }); - } + let child = relative.split('/').next().unwrap_or(relative); + children.insert(format!("{prefix}{child}")); + } + children.into_iter().collect() +} + +impl ParsedSkill { + fn has_errors(&self) -> bool { + self.diagnostics + .iter() + .any(|diagnostic| diagnostic.severity == SkillDiagnosticSeverity::Error) + } + + fn catalog_entry(&self) -> SkillCatalogEntry { + SkillCatalogEntry { + name: self.name.clone(), + description: self.description.clone(), + provenance: self.provenance.clone(), + overrides: self.overrides.clone(), + diagnostics: self.diagnostics.clone(), } } - refs.sort_by(|a, b| a.name.cmp(&b.name)); - refs } #[cfg(test)] mod tests { + use config_source::{ + ConfigContentType, ConfigEntry, ConfigTreeSnapshot, ToolchainContract, + WorkspaceConfigSchemaBundle, + }; + use super::*; - fn write_skill(root: &Path, name: &str, content: &str) { - let dir = root.join(".yoi").join("skills").join(name); - std::fs::create_dir_all(&dir).unwrap(); - std::fs::write(dir.join("SKILL.md"), content).unwrap(); + fn state(main: &str, markdown: &str, name: &str) -> WorkspaceConfigState { + let skill_path = format!("skills/{name}/SKILL.md"); + let reference_path = format!("skills/{name}/references/checklist.md"); + let tree = ConfigTreeSnapshot::from_entries( + 9, + [ + ConfigEntry::new( + VirtualPath::parse("main.dcdl").unwrap(), + ConfigContentType::Decodal, + main, + ) + .unwrap(), + ConfigEntry::new( + VirtualPath::parse(&skill_path).unwrap(), + ConfigContentType::Text, + markdown, + ) + .unwrap(), + ConfigEntry::new( + VirtualPath::parse(&reference_path).unwrap(), + ConfigContentType::Text, + "checklist", + ) + .unwrap(), + ], + ) + .unwrap(); + let bundle = WorkspaceConfigSchemaBundle::compose([SkillConfigSchemaProvider + .contribution() + .unwrap()]) + .unwrap(); + WorkspaceConfigState { + snapshot: tree, + contract: ToolchainContract::with_schema_bundle( + 1, + vec![VirtualPath::parse("main.dcdl").unwrap()], + 1, + bundle, + ), + projection_digest: "projection".to_string(), + } + } + + fn main_source(name: &str) -> String { + let config_key = name.replace('-', "_"); + format!( + r#"{{ skills = {{ {config_key} = import "./skills/{name}/SKILL.md" as {}; }}; }}"#, + SKILL_DOCUMENT_SCHEMA_SOURCE + ) } #[test] - fn workspace_skill_is_cataloged_without_body_and_detail_contains_body() { - let tmp = tempfile::tempdir().unwrap(); - write_skill( - tmp.path(), - "debug-rust", - "---\nname: debug-rust\ndescription: Use when debugging Rust failures and deciding what tests to run.\nallowed-tools:\n - Bash\nmetadata:\n owner: dev\n---\n\n# Debug Rust\n\nRun focused checks.", + fn workspace_skill_projection_keeps_extensions_and_uses_virtual_resources() { + let markdown = concat!( + "---\n", + "name: debug-rust\n", + "description: Debug Rust failures\n", + "custom-authority: no\n", + "allowed-tools: Read Grep\n", + "metadata:\n owner: platform\n", + "---\n", + "# Debug Rust\n", ); - - let catalog = catalog(tmp.path()); - let entry = catalog + let state = state(&main_source("debug-rust"), markdown, "debug-rust"); + let evaluation = + evaluate_workspace_config_state(&state, state.contract.schema_bundle.clone()).unwrap(); + assert_eq!( + evaluation.projections[0].data_json["skills"]["debug_rust"]["frontmatter"]["custom-authority"], + "no" + ); + let catalog = catalog(&state).unwrap(); + let item = catalog .entries .iter() - .find(|entry| entry.name == "debug-rust") - .expect("workspace skill listed"); + .find(|item| item.name == "debug-rust") + .unwrap(); + assert_eq!(item.provenance.kind, SkillSourceKind::Workspace); + assert_eq!(item.provenance.revision, Some(9)); + assert!( + item.diagnostics + .iter() + .all(|diagnostic| diagnostic.severity != SkillDiagnosticSeverity::Error) + ); + let detail = detail(&state, "debug-rust").unwrap(); + assert_eq!(detail.body, "# Debug Rust\n"); + assert_eq!(detail.allowed_tools, vec!["Read", "Grep"]); assert_eq!( - entry.description, - "Use when debugging Rust failures and deciding what tests to run." + detail.resources[0].name, + "skills/debug-rust/references/checklist.md" ); - assert_eq!(entry.provenance.id, "workspace:debug-rust"); - let catalog_json = serde_json::to_string(&catalog).unwrap(); - assert!(!catalog_json.contains("# Debug Rust")); - - let detail = detail(tmp.path(), "debug-rust").unwrap(); - assert!(detail.body.contains("# Debug Rust")); - assert_eq!(detail.allowed_tools, vec!["Bash"]); - assert!( - detail - .allowed_tools_status - .contains("does not grant or deny") - ); - assert!( - detail - .diagnostics - .iter() - .any(|d| d.code == "allowed_tools_ignored") + assert_eq!( + activation(&state, "debug-rust").unwrap().body, + "# Debug Rust\n" ); } #[test] - fn invalid_name_and_parent_mismatch_are_lint_errors() { - let tmp = tempfile::tempdir().unwrap(); - write_skill( - tmp.path(), - "Bad--Name", - "---\nname: other\ndescription: Use when checking invalid examples.\n---\n\n# Invalid", + fn workspace_override_replaces_builtin_deterministically() { + let markdown = concat!( + "---\nname: agent-skills\n", + "description: Workspace override\n", + "---\n# Workspace agent skills\n", ); - let diagnostics = catalog(tmp.path()).diagnostics; - assert!( - diagnostics - .iter() - .any(|d| d.code == "invalid_skill_directory_name") - ); - assert!(diagnostics.iter().any(|d| d.code == "name_parent_mismatch")); - } - - #[test] - fn workflow_projection_frontmatter_fields_are_rejected() { - let tmp = tempfile::tempdir().unwrap(); - write_skill( - tmp.path(), - "workflow-shaped", - "---\nname: workflow-shaped\ndescription: Use when proving workflow projection fields are rejected as Skills.\nmodel_invokation: old-typo\nuser_invocable: true\ngraph: {}\ninvocation:\n run: now\n---\n\n# Workflow Shaped", - ); - - let catalog = catalog(tmp.path()); - assert!( + let state = state(&main_source("agent-skills"), markdown, "agent-skills"); + let catalog = catalog(&state).unwrap(); + assert_eq!( catalog .entries .iter() - .all(|entry| entry.name != "workflow-shaped") + .filter(|item| item.name == "agent-skills") + .count(), + 1 ); - let codes = catalog - .diagnostics + let item = catalog + .entries .iter() - .map(|diagnostic| diagnostic.code.as_str()) - .collect::>(); - assert!(codes.contains(&"unsupported_workflow_frontmatter_field")); - for unsupported in ["model_invokation", "user_invocable", "graph", "invocation"] { - assert!( - catalog.diagnostics.iter().any(|diagnostic| { - diagnostic.code == "unsupported_workflow_frontmatter_field" - && diagnostic.message.contains(unsupported) - }), - "missing unsupported-field diagnostic for {unsupported}" - ); - } + .find(|item| item.name == "agent-skills") + .unwrap(); + assert_eq!(item.description, "Workspace override"); + assert_eq!(item.provenance.kind, SkillSourceKind::Workspace); + assert_eq!(item.overrides[0].kind, SkillSourceKind::Builtin); + } + + #[test] + fn inline_skill_value_cannot_claim_canonical_source_identity() { + let main = r#"{ + skills = { + debug_rust = { + frontmatter = { + name = "debug-rust"; + description = "Inline authority"; + }; + content = "inline"; + }; + }; + }"#; + let markdown = "---\nname: debug-rust\ndescription: File authority\n---\nfile\n"; + let state = state(main, markdown, "debug-rust"); + let item = catalog(&state) + .unwrap() + .entries + .into_iter() + .find(|item| item.name == "debug-rust") + .unwrap(); + assert!( + item.diagnostics + .iter() + .any(|diagnostic| diagnostic.code == "skill_source_mismatch") + ); assert!(matches!( - detail(tmp.path(), "workflow-shaped"), - Err(SkillError::NotFound(_)) + activation(&state, "debug-rust"), + Err(SkillError::InvalidSkill(_)) )); } #[test] - fn unknown_frontmatter_fields_are_rejected() { - let tmp = tempfile::tempdir().unwrap(); - write_skill( - tmp.path(), - "unknown-field", - "---\nname: unknown-field\ndescription: Use when proving unsupported Skill fields are rejected.\ncustom-authority: no\n---\n\n# Unknown", - ); - - let catalog = catalog(tmp.path()); - assert!( - catalog - .entries - .iter() - .all(|entry| entry.name != "unknown-field") - ); - assert!(catalog.diagnostics.iter().any(|diagnostic| diagnostic.code - == "unsupported_frontmatter_field" - && diagnostic.message.contains("custom-authority"))); - } - - #[test] - fn workspace_skill_overrides_builtin() { - let tmp = tempfile::tempdir().unwrap(); - write_skill( - tmp.path(), - "agent-skills", - "---\nname: agent-skills\ndescription: Use when testing deterministic workspace override of builtin Skills.\n---\n\n# Workspace Override", - ); - let catalog = catalog(tmp.path()); - let entry = catalog - .entries - .iter() - .find(|entry| entry.name == "agent-skills") - .unwrap(); - assert_eq!(entry.provenance.id, "workspace:agent-skills"); - assert_eq!(entry.overrides[0].id, "builtin:agent-skills"); - let detail = detail(tmp.path(), "agent-skills").unwrap(); - assert!(detail.body.contains("Workspace Override")); - } - - #[test] - fn scripts_are_reported_not_executable_without_raw_paths() { - let tmp = tempfile::tempdir().unwrap(); - write_skill( - tmp.path(), - "scripted-help", - "---\nname: scripted-help\ndescription: Use when checking Skill resource diagnostics safely.\n---\n\n# Scripted", - ); - let script_dir = tmp.path().join(".yoi/skills/scripted-help/scripts"); - std::fs::create_dir_all(&script_dir).unwrap(); - std::fs::write(script_dir.join("run.sh"), "echo no").unwrap(); - let detail = detail(tmp.path(), "scripted-help").unwrap(); - let script = detail - .resources - .iter() - .find(|r| r.name == "scripts/run.sh") - .unwrap(); - assert!(!script.supported); - assert!( - !serde_json::to_string(&detail) - .unwrap() - .contains(tmp.path().to_str().unwrap()) - ); + fn schema_accepts_unknown_extensions_but_keeps_skill_documents_typed() { + let contribution = SkillConfigSchemaProvider.contribution().unwrap(); + assert_eq!(contribution.namespace, "skills"); + assert!(contribution.source.contains("...Unknown")); + assert!(contribution.source.contains("metadata = {...String}")); } } diff --git a/docs/development/work-items.md b/docs/development/work-items.md index fdd78af5..df27693d 100644 --- a/docs/development/work-items.md +++ b/docs/development/work-items.md @@ -121,19 +121,19 @@ root = ".yoi/tickets" [ticket.roles.intake] profile = "project:intake" -launch_prompt = "$workspace/ticket/intake/launch" +launch_prompt = "ticket.intake.launch" [ticket.roles.orchestrator] profile = "project:orchestrator" -launch_prompt = "$workspace/ticket/orchestrator/launch" +launch_prompt = "ticket.orchestrator.launch" [ticket.roles.coder] profile = "project:coder" -launch_prompt = "$workspace/ticket/coder/launch" +launch_prompt = "ticket.coder.launch" [ticket.roles.reviewer] profile = "project:reviewer" -launch_prompt = "$workspace/ticket/reviewer/launch" +launch_prompt = "ticket.reviewer.launch" ``` Fixed roles are: diff --git a/docs/manifest.toml b/docs/manifest.toml index 14d1a465..28ce2e2f 100644 --- a/docs/manifest.toml +++ b/docs/manifest.toml @@ -30,12 +30,6 @@ # 必須。Worker の表示名 (ResolveError::MissingField("worker.name") の対象)。 name = "example-agent" -# 任意。デフォルト: なし。 -# PromptCatalog の 4 つ目の overlay 層として読み込む TOML pack のパス。 -# 相対パスは manifest base 起点で解決。`worker.instruction` (`$prefix/...`) -# とは別系統の単なるファイルパス。 -# prompt_pack = "./prompts.local.toml" - # ===== [model] ============================================================== # LLM モデル設定。次の 3 形態を受ける: @@ -95,10 +89,9 @@ ref = "anthropic/claude-sonnet-4-6" # ワーカーの生成パラメータ等。セクション自体省略可 (全フィールド任意)。 [engine] -# 任意。デフォルト: "$yoi/default" (`defaults::DEFAULT_INSTRUCTION`)。 -# システムプロンプト本体の `PromptLoader` 参照。 -# プレフィクス: "$yoi/..." | "$user/..." | "$workspace/..." -# instruction = "$yoi/default" +# 任意。デフォルト: "default" (`defaults::DEFAULT_INSTRUCTION`)。 +# effective Prompt catalog の exact dotted name を選択する。 +# instruction = "default" # 任意。デフォルト: なし (プロバイダ任せ)。 # 1 レスポンスあたりの出力 token 上限。 diff --git a/resources/profiles/coder.dcdl b/resources/profiles/coder.dcdl index 5a064bd7..74f3795b 100644 --- a/resources/profiles/coder.dcdl +++ b/resources/profiles/coder.dcdl @@ -2,6 +2,7 @@ import "./base.dcdl" // { slug = "coder"; description = "Ticket implementation coder profile."; scope = "workspace_write"; + engine = { instruction = "role.coder"; }; feature = { task = { enabled = true; }; diff --git a/resources/profiles/intake.dcdl b/resources/profiles/intake.dcdl index ae69be4b..1778c930 100644 --- a/resources/profiles/intake.dcdl +++ b/resources/profiles/intake.dcdl @@ -2,6 +2,7 @@ import "./base.dcdl" // { slug = "intake"; description = "Ticket intake profile."; scope = "workspace_write"; + engine = { instruction = "role.intake"; }; feature = { task = { enabled = true; }; diff --git a/resources/profiles/orchestrator.dcdl b/resources/profiles/orchestrator.dcdl index 74549402..feb7f402 100644 --- a/resources/profiles/orchestrator.dcdl +++ b/resources/profiles/orchestrator.dcdl @@ -2,6 +2,7 @@ import "./base.dcdl" // { slug = "orchestrator"; description = "Ticket orchestrator profile."; scope = "workspace_write"; + engine = { instruction = "role.orchestrator"; }; feature = { task = { enabled = true; }; diff --git a/resources/profiles/reviewer.dcdl b/resources/profiles/reviewer.dcdl index e86a7406..82270494 100644 --- a/resources/profiles/reviewer.dcdl +++ b/resources/profiles/reviewer.dcdl @@ -2,6 +2,7 @@ import "./base.dcdl" // { slug = "reviewer"; description = "Ticket review profile."; scope = "workspace_read"; + engine = { instruction = "role.reviewer"; }; feature = { task = { enabled = true; }; diff --git a/resources/prompts/catalog.dcdl b/resources/prompts/catalog.dcdl new file mode 100644 index 00000000..c6e3599f --- /dev/null +++ b/resources/prompts/catalog.dcdl @@ -0,0 +1,71 @@ +# Deterministic builtin Prompt source tree. Markdown imports use the shared +# { frontmatter, content } contract; every leaf below selects only .content. +# `default_prompt` is the DCDL source alias for the effective catalog's +# reserved dotted name `default`. +let +defaultDocument = import "./default.md"; +commonLanguage = import "./common/language.md"; +commonTickets = import "./common/tickets.md"; +commonToolUsage = import "./common/tool-usage.md"; +commonWorkerObservation = import "./common/worker-observation.md"; +commonWorkerOrchestration = import "./common/worker-orchestration.md"; +commonWorkspace = import "./common/workspace.md"; +commonWriting = import "./common/writing.md"; +roleCoder = import "./role/coder.md"; +roleIntake = import "./role/intake.md"; +roleOrchestrator = import "./role/orchestrator.md"; +roleReviewer = import "./role/reviewer.md"; +internalCompactSystem = import "./internal/compact_system.md"; +internalFlowVerifierSystem = import "./internal/flow_verifier_system.md"; +internalMemoryConsolidationSystem = import "./internal/memory_consolidation_system.md"; +internalMemoryExtractSystem = import "./internal/memory_extract_system.md"; +internalWorkspaceOrchestratorQueueAttention = import "./internal/workspace_orchestrator_queue_attention.md"; +internalNotifyWrapper = import "./internal/notify_wrapper.md"; +internalInterruptToolResultSummary = import "./internal/interrupt_tool_result_summary.md"; +internalInterruptSystemNote = import "./internal/interrupt_system_note.md"; +internalWorkingBoundariesSection = import "./internal/working_boundaries_section.md"; +internalAgentsMdSection = import "./internal/agents_md_section.md"; +internalResidentMemorySummarySection = import "./internal/resident_memory_summary_section.md"; +internalSubWorkerSpawnToolDescription = import "./internal/sub_worker_spawn_tool_description.md"; +panelOrchestratorIdleQueueNotice = import "./panel/orchestrator_idle_queue_notice.md"; +workerTicketEventCompanionNotice = import "./worker/ticket_event_companion_notice.md"; +in +{ + default_prompt = defaultDocument.content; + common = { + language = commonLanguage.content; + tickets = commonTickets.content; + tool_usage = commonToolUsage.content; + worker_observation = commonWorkerObservation.content; + worker_orchestration = commonWorkerOrchestration.content; + workspace = commonWorkspace.content; + writing = commonWriting.content; + }; + role = { + coder = roleCoder.content; + intake = roleIntake.content; + orchestrator = roleOrchestrator.content; + reviewer = roleReviewer.content; + }; + internal = { + compact_system = internalCompactSystem.content; + flow_verifier_system = internalFlowVerifierSystem.content; + memory_consolidation_system = internalMemoryConsolidationSystem.content; + memory_extract_system = internalMemoryExtractSystem.content; + workspace_orchestrator_queue_attention = internalWorkspaceOrchestratorQueueAttention.content; + notify_wrapper = internalNotifyWrapper.content; + interrupt_tool_result_summary = internalInterruptToolResultSummary.content; + interrupt_system_note = internalInterruptSystemNote.content; + working_boundaries_section = internalWorkingBoundariesSection.content; + agents_md_section = internalAgentsMdSection.content; + resident_memory_summary_section = internalResidentMemorySummarySection.content; + worker_orchestration_guidance_section = commonWorkerOrchestration.content; + sub_worker_spawn_tool_description = internalSubWorkerSpawnToolDescription.content; + }; + panel = { + orchestrator_idle_queue_notice = panelOrchestratorIdleQueueNotice.content; + }; + worker = { + ticket_event_companion_notice = workerTicketEventCompanionNotice.content; + }; +} diff --git a/resources/prompts/common/worker-orchestration.md b/resources/prompts/common/worker-orchestration.md index d7147789..6bf73286 100644 --- a/resources/prompts/common/worker-orchestration.md +++ b/resources/prompts/common/worker-orchestration.md @@ -1,3 +1,4 @@ + --- ## SubWorker orchestration diff --git a/resources/prompts/default.md b/resources/prompts/default.md index 8f88470e..81c945e6 100644 --- a/resources/prompts/default.md +++ b/resources/prompts/default.md @@ -2,11 +2,11 @@ You are here as an agent of the "yoi system". Stay precise, edit code directly when asked, and avoid speculative refactoring. -{% include "common/workspace" %} +{% include "common.workspace" %} -{% include "common/tool-usage" %} +{% include "common.tool_usage" %} -{% include "common/language" %} +{% include "common.language" %} -{% include "common/writing" %} +{% include "common.writing" %} diff --git a/resources/prompts/internal.toml b/resources/prompts/internal.toml deleted file mode 100644 index bbcd711e..00000000 --- a/resources/prompts/internal.toml +++ /dev/null @@ -1,71 +0,0 @@ -# Worker internal prompts (builtin pack). -# -# Values are minijinja template strings. Use `{% include "$prefix/..." %}` -# to pull in long text from the $yoi / $user / $workspace prompt -# libraries. -# -# Every key here MUST correspond to a `WorkerPrompt` variant; missing or -# extra keys cause a build-time error (see `crates/worker/build.rs`). - -[prompt] -compact_system = "{% include \"$yoi/internal/compact_system\" %}" - -memory_extract_system = "{% include \"$yoi/internal/memory_extract_system\" %}" - -memory_consolidation_system = "{% include \"$yoi/internal/memory_consolidation_system\" %}" - -flow_verifier_system = "{% include \"$yoi/internal/flow_verifier_system\" %}" - -notify_wrapper = """\ -[Notification] -{{ message }} - -This is a notification, not a blocking request. If you are in the middle of a task, continue your current work and address this at a natural stopping point.\ -""" - -interrupt_tool_result_summary = "[Interrupted by user]" - -interrupt_system_note = "[The previous turn was interrupted by the user. The user's next request follows.]" - -working_boundaries_section = """\ ---- -## Working boundaries - -{{ scope_summary }}\ -""" - -agents_md_section = """\ ---- -## Project instructions (AGENTS.md) - -{{ agents_md }}\ -""" - -resident_memory_summary_section = """\ ---- -## Resident memory summary - -The following is the current durable session/workspace summary. Treat it as background context; it is not a user request. - -{{ summary }}\ -""" - - -worker_orchestration_guidance_section = "{% include \"$yoi/common/worker-orchestration\" %}" - -ticket_event_companion_notice = "{% include \"$yoi/worker/ticket_event_companion_notice\" %}" - -sub_worker_spawn_tool_description = """\ -Spawn a parent-owned Internal SubWorker session to split context for a delegated task. The parent Worker's write scope is reduced by the scope passed here; the Internal SubWorker starts running `task` immediately without creating a Runtime Worker record, OS process, PID, or Unix socket. It remains available for follow-up turns until explicitly stopped or its parent exits. - -Optional `cwd`: when provided, it is the Internal SubWorker's tool default working directory only. It must be an absolute existing directory covered by the child's delegated readable scope, and it does not change workspace/Profile/memory/Ticket roots or grant authority. `name` must be unique among this Worker's direct children. - -Profile selection: `profile` may be omitted or set to `default` to use the effective child default profile, set to `inherit` to derive reusable child configuration from this Worker, or set to one of the registry selectors below. Raw/path profile selectors are not accepted by SubWorkerSpawn. `scope` is always the only delegated filesystem capability; profile scope is replaced by the explicit SubWorkerSpawn scope. - -Default profile: {{ default_profile }} -Special selector: inherit — derive reusable model/worker/tool policy from the spawner while replacing worker.name and scope. -Available registry profiles: -{{ available_profiles }}{% if profile_diagnostic %} - -Profile discovery diagnostic: {{ profile_diagnostic }}{% endif %}\ -""" diff --git a/resources/prompts/internal/agents_md_section.md b/resources/prompts/internal/agents_md_section.md new file mode 100644 index 00000000..d9279c4d --- /dev/null +++ b/resources/prompts/internal/agents_md_section.md @@ -0,0 +1,5 @@ + +--- +## Project instructions (AGENTS.md) + +{{ agents_md }} \ No newline at end of file diff --git a/resources/prompts/internal/interrupt_system_note.md b/resources/prompts/internal/interrupt_system_note.md new file mode 100644 index 00000000..3d4eca60 --- /dev/null +++ b/resources/prompts/internal/interrupt_system_note.md @@ -0,0 +1 @@ +[The previous turn was interrupted by the user. The user's next request follows.] \ No newline at end of file diff --git a/resources/prompts/internal/interrupt_tool_result_summary.md b/resources/prompts/internal/interrupt_tool_result_summary.md new file mode 100644 index 00000000..5c191724 --- /dev/null +++ b/resources/prompts/internal/interrupt_tool_result_summary.md @@ -0,0 +1 @@ +[Interrupted by user] \ No newline at end of file diff --git a/resources/prompts/internal/notify_wrapper.md b/resources/prompts/internal/notify_wrapper.md new file mode 100644 index 00000000..d73ad961 --- /dev/null +++ b/resources/prompts/internal/notify_wrapper.md @@ -0,0 +1,4 @@ +[Notification] +{{ message }} + +This is a notification, not a blocking request. If you are in the middle of a task, continue your current work and address this at a natural stopping point. \ No newline at end of file diff --git a/resources/prompts/internal/resident_memory_summary_section.md b/resources/prompts/internal/resident_memory_summary_section.md new file mode 100644 index 00000000..56e7447c --- /dev/null +++ b/resources/prompts/internal/resident_memory_summary_section.md @@ -0,0 +1,7 @@ + +--- +## Resident memory summary + +The following is the current durable session/workspace summary. Treat it as background context; it is not a user request. + +{{ summary }} \ No newline at end of file diff --git a/resources/prompts/internal/sub_worker_spawn_tool_description.md b/resources/prompts/internal/sub_worker_spawn_tool_description.md new file mode 100644 index 00000000..a652f551 --- /dev/null +++ b/resources/prompts/internal/sub_worker_spawn_tool_description.md @@ -0,0 +1,12 @@ +Spawn a parent-owned Internal SubWorker session to split context for a delegated task. The parent Worker's write scope is reduced by the scope passed here; the Internal SubWorker starts running `task` immediately without creating a Runtime Worker record, OS process, PID, or Unix socket. It remains available for follow-up turns until explicitly stopped or its parent exits. + +Optional `cwd`: when provided, the spawned SubWorker's tool default working directory only. It must be an absolute existing directory covered by the child's delegated readable scope, and it does not change workspace/Profile/memory/Ticket roots or grant authority. `name` must be unique among this Worker's direct children. + +Profile selection: `profile` may be omitted or set to `default` to use the effective child default profile, set to `inherit` to derive reusable child configuration from this Worker, or set to one of the registry selectors below. Raw/path profile selectors are not accepted by SubWorkerSpawn. `scope` is always the only delegated filesystem capability; profile scope is replaced by the explicit SubWorkerSpawn scope. + +Default profile: {{ default_profile }} +Special selector: inherit — derive reusable model/worker/tool policy from the spawner while replacing worker.name and scope. +Available registry profiles: +{{ available_profiles }}{% if profile_diagnostic %} + +Profile discovery diagnostic: {{ profile_diagnostic }}{% endif %} \ No newline at end of file diff --git a/resources/prompts/internal/working_boundaries_section.md b/resources/prompts/internal/working_boundaries_section.md new file mode 100644 index 00000000..94cbf5dd --- /dev/null +++ b/resources/prompts/internal/working_boundaries_section.md @@ -0,0 +1,5 @@ + +--- +## Working boundaries + +{{ scope_summary }} \ No newline at end of file diff --git a/web/workspace/deno.json b/web/workspace/deno.json index 55704d17..a86241c8 100644 --- a/web/workspace/deno.json +++ b/web/workspace/deno.json @@ -6,7 +6,7 @@ "dev": "deno run -A npm:vite@7.2.7 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@2.49.4 sync && deno run -A npm:svelte-check@4.3.4 --tsconfig ./tsconfig.json", - "test": "deno test --allow-read=src --allow-env=VSCODE_TEXTMATE_DEBUG src/lib/workspace/auth/model.test.ts src/lib/workspace/api/http.test.ts src/lib/workspace/header/breadcrumb-model.test.ts src/lib/workspace/console/chat-submit.test.ts src/lib/workspace/console/composer-command.test.ts src/lib/workspace/console/composer-completion.test.ts src/lib/workspace/console/markdown.test.ts src/lib/workspace/console/model.test.ts src/lib/workspace/console/tasks.test.ts src/lib/workspace/console/worker-console.ui.test.ts src/lib/workspace/settings/model.test.ts src/lib/workspace/sidebar/workers.test.ts src/lib/workspace/sidebar/worker-subscription.test.ts src/lib/workspace/sidebar/worker-launch.test.ts src/lib/workspace/sidebar/repository-nav.test.ts src/lib/workspace/tickets/ticket-panel.test.ts", + "test": "deno test --allow-read=src,test --allow-env=LOG,VSCODE_TEXTMATE_DEBUG src/lib/workspace/auth/model.test.ts src/lib/workspace/api/http.test.ts src/lib/workspace/header/breadcrumb-model.test.ts src/lib/workspace/console/chat-submit.test.ts src/lib/workspace/console/composer-command.test.ts src/lib/workspace/console/composer-completion.test.ts src/lib/workspace/console/markdown.test.ts src/lib/workspace/console/model.test.ts src/lib/workspace/console/tasks.test.ts src/lib/workspace/console/worker-console.ui.test.ts src/lib/workspace/settings/model.test.ts src/lib/workspace/sidebar/workers.test.ts src/lib/workspace/sidebar/worker-subscription.test.ts src/lib/workspace/sidebar/worker-launch.test.ts src/lib/workspace/sidebar/repository-nav.test.ts src/lib/workspace/tickets/ticket-panel.test.ts test/config-source/decodal-grammar.test.ts test/config-source/wasm-parity.test.ts", "build": "deno run -A npm:vite@7.2.7 build", "preview": "deno run -A npm:vite@7.2.7 preview" }, @@ -18,7 +18,7 @@ "@codemirror/autocomplete": "npm:@codemirror/autocomplete@6.20.0", "@codemirror/state": "npm:@codemirror/state@6.7.1", "@codemirror/view": "npm:@codemirror/view@6.43.8", - "decodal-codemirror": "npm:decodal-codemirror@0.1.6", + "decodal-codemirror": "npm:decodal-codemirror@0.3.0", "clsx": "npm:clsx@2.1.1", "cookie": "npm:cookie@0.6.0", "devalue": "npm:devalue@5.6.4", diff --git a/web/workspace/deno.lock b/web/workspace/deno.lock index 0811f3fa..d25f484e 100644 --- a/web/workspace/deno.lock +++ b/web/workspace/deno.lock @@ -12,7 +12,7 @@ "npm:@sveltejs/vite-plugin-svelte@6.2.1": "6.2.1_svelte@5.45.6_vite@7.2.7", "npm:clsx@2.1.1": "2.1.1", "npm:cookie@0.6.0": "0.6.0", - "npm:decodal-codemirror@0.1.6": "0.1.6_@codemirror+view@6.43.8", + "npm:decodal-codemirror@0.3.0": "0.3.0_@codemirror+language@6.12.4_@codemirror+view@6.43.8_@lezer+highlight@1.2.3_@lezer+lr@1.4.10", "npm:devalue@5.6.4": "5.6.4", "npm:gen-interface-jp@0.8.0": "0.8.0", "npm:set-cookie-parser@2.7.2": "2.7.2", @@ -554,8 +554,8 @@ "ms" ] }, - "decodal-codemirror@0.1.6_@codemirror+view@6.43.8": { - "integrity": "sha512-XTS5vAY+vTb/yEg5n+1yORtBPuhozG4KO0YGbYEFR8oZX0KghZuHyFEsq/CJMXF223YMC/FQt3SC19NVYGWKMw==", + "decodal-codemirror@0.3.0_@codemirror+language@6.12.4_@codemirror+view@6.43.8_@lezer+highlight@1.2.3_@lezer+lr@1.4.10": { + "integrity": "sha512-M+Iod3UAZigpt46TmuLJIlSEBhL6KOOksyVxUOGGdlfKYCMN9oN6YG5G9tjkr3/r+Eq8ig1RipC7VZX5iiACTQ==", "dependencies": [ "@codemirror/language", "@codemirror/view", @@ -1007,7 +1007,7 @@ "npm:@sveltejs/vite-plugin-svelte@6.2.1", "npm:clsx@2.1.1", "npm:cookie@0.6.0", - "npm:decodal-codemirror@0.1.6", + "npm:decodal-codemirror@0.3.0", "npm:devalue@5.6.4", "npm:set-cookie-parser@2.7.2", "npm:shiki@3.13.0", diff --git a/web/workspace/src/lib/workspace/api/http.ts b/web/workspace/src/lib/workspace/api/http.ts index 65c98aa6..235cd6fb 100644 --- a/web/workspace/src/lib/workspace/api/http.ts +++ b/web/workspace/src/lib/workspace/api/http.ts @@ -15,6 +15,10 @@ export type SkillDiagnostic = { export type SkillProvenance = { kind: "builtin" | "workspace"; id: string; + virtual_path?: string; + revision?: number; + source_digest?: string; + tree_digest?: string; }; export type SkillCatalogEntry = { diff --git a/web/workspace/src/lib/workspace/config-source/generated/config_source_wasm.d.ts b/web/workspace/src/lib/workspace/config-source/generated/config_source_wasm.d.ts index f6e5ecf0..af61ca25 100644 --- a/web/workspace/src/lib/workspace/config-source/generated/config_source_wasm.d.ts +++ b/web/workspace/src/lib/workspace/config-source/generated/config_source_wasm.d.ts @@ -9,6 +9,8 @@ export function changes_between(base: any, candidate: any): any; export function complete_current(entrypoint: string, source: string, utf16_offset: number, explicit: boolean): any; +export function compose_schema_bundle(contributions: any): any; + export function evaluate_current(contract: any): any; export function evaluate_snapshot(snapshot: any, contract: any): any; @@ -27,6 +29,7 @@ export interface InitOutput { readonly apply_changes: (a: any) => [number, number, number]; readonly changes_between: (a: any, b: any) => [number, number, number]; readonly complete_current: (a: number, b: number, c: number, d: number, e: number, f: number) => [number, number, number]; + readonly compose_schema_bundle: (a: any) => [number, number, number]; readonly evaluate_current: (a: any) => [number, number, number]; readonly evaluate_snapshot: (a: any, b: any) => [number, number, number]; readonly format_source: (a: number, b: number) => [number, number, number, number]; diff --git a/web/workspace/src/lib/workspace/config-source/generated/config_source_wasm.js b/web/workspace/src/lib/workspace/config-source/generated/config_source_wasm.js index 713887df..9fc330c8 100644 --- a/web/workspace/src/lib/workspace/config-source/generated/config_source_wasm.js +++ b/web/workspace/src/lib/workspace/config-source/generated/config_source_wasm.js @@ -62,6 +62,18 @@ export function complete_current(entrypoint, source, utf16_offset, explicit) { return takeFromExternrefTable0(ret[0]); } +/** + * @param {any} contributions + * @returns {any} + */ +export function compose_schema_bundle(contributions) { + const ret = wasm.compose_schema_bundle(contributions); + if (ret[2]) { + throw takeFromExternrefTable0(ret[1]); + } + return takeFromExternrefTable0(ret[0]); +} + /** * @param {any} contract * @returns {any} 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 9ca6bd64..5f621ea3 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/config_source_wasm_bg.wasm.d.ts b/web/workspace/src/lib/workspace/config-source/generated/config_source_wasm_bg.wasm.d.ts index 70f74b45..361b58be 100644 --- a/web/workspace/src/lib/workspace/config-source/generated/config_source_wasm_bg.wasm.d.ts +++ b/web/workspace/src/lib/workspace/config-source/generated/config_source_wasm_bg.wasm.d.ts @@ -5,6 +5,7 @@ export const analyze_snapshot: (a: any, b: number, c: number, d: number, e: numb export const apply_changes: (a: any) => [number, number, number]; export const changes_between: (a: any, b: any) => [number, number, number]; export const complete_current: (a: number, b: number, c: number, d: number, e: number, f: number) => [number, number, number]; +export const compose_schema_bundle: (a: any) => [number, number, number]; export const evaluate_current: (a: any) => [number, number, number]; export const evaluate_snapshot: (a: any, b: any) => [number, number, number]; export const format_source: (a: number, b: number) => [number, number, number, number]; diff --git a/web/workspace/src/lib/workspace/config-source/generated/types/ConfigProjectionValidator.ts b/web/workspace/src/lib/workspace/config-source/generated/types/ConfigProjectionValidator.ts new file mode 100644 index 00000000..04cd5395 --- /dev/null +++ b/web/workspace/src/lib/workspace/config-source/generated/types/ConfigProjectionValidator.ts @@ -0,0 +1,3 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +export type ConfigProjectionValidator = { "kind": "static_template_catalog", namespace: string, key_aliases?: { [key in string]: string }, }; 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 4e89881b..69d5a595 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,3 +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, source_digest: string, }; +export type ConfigSchemaContribution = { provider_id: string, namespace: string, version: string, source: string, projection_validator?: ConfigProjectionValidator | null, source_digest: string, }; diff --git a/web/workspace/src/lib/workspace/settings/model.test.ts b/web/workspace/src/lib/workspace/settings/model.test.ts index c4b80221..e00f5721 100644 --- a/web/workspace/src/lib/workspace/settings/model.test.ts +++ b/web/workspace/src/lib/workspace/settings/model.test.ts @@ -6,10 +6,6 @@ import { SETTINGS_SECTIONS, settingsSectionHref, } from "./model.ts"; -import { - profileSourceTreeSettingsHref, - virtualProfilePathForCreate, -} from "./profile-routes.ts"; declare const Deno: { test(name: string, fn: () => void): void; @@ -24,6 +20,11 @@ function assert(condition: unknown, message: string): asserts condition { Deno.test("settings section navigation stays under the settings route", () => { assert(SETTINGS_ROUTE === "/settings", "settings route should be stable"); + assert( + settingsSectionHref("configuration-sources") === "/settings/configuration", + "shared configuration editor route should stay canonical", + ); + for (const section of SETTINGS_SECTIONS) { const href = settingsSectionHref(section.id); assert( @@ -106,29 +107,3 @@ Deno.test("diagnostic labels preserve severity and code", () => { "diagnostic label should be bounded and stable", ); }); - -Deno.test("profile source tree routes use encoded scoped ids", () => { - const href = profileSourceTreeSettingsHref("workspace a", "project/tree"); - assert( - href === "/w/workspace%20a/settings/profiles/trees/project%2Ftree", - `unexpected href ${href}`, - ); - assert(!href.includes("/home/"), "route must not contain host paths"); -}); - -Deno.test("profile source create paths are normalized to virtual profile paths", () => { - assert( - virtualProfilePathForCreate("alpha.dcdl") === "profiles/alpha.dcdl", - "bare file names are scoped", - ); - assert( - virtualProfilePathForCreate("profiles/alpha.dcdl") === - "profiles/alpha.dcdl", - "virtual paths are preserved", - ); - assert( - virtualProfilePathForCreate("project:profiles/alpha.dcdl") === - "project:profiles/alpha.dcdl", - "safe virtual namespaces are preserved", - ); -}); diff --git a/web/workspace/src/lib/workspace/settings/model.ts b/web/workspace/src/lib/workspace/settings/model.ts index 9aab0873..2a89efd8 100644 --- a/web/workspace/src/lib/workspace/settings/model.ts +++ b/web/workspace/src/lib/workspace/settings/model.ts @@ -108,7 +108,7 @@ export const SETTINGS_SECTIONS: readonly SettingsSection[] = [ bullets: [ "Virtual paths and imports resolve inside the committed Workspace tree, never from browser or Server host paths.", "Browser analysis is advisory; Server evaluation is required before an atomic revision commit.", - "Profile, Skill, Prompt, and Plugin consumers remain on their existing authorities until their follow-up cutovers.", + "Profile launch data is projected from this active revision; remaining Skill, Prompt, and Plugin consumers migrate in their follow-up cutovers.", ], }, { @@ -116,11 +116,11 @@ export const SETTINGS_SECTIONS: readonly SettingsSection[] = [ label: "Profile Sources", status: "editable", summary: - "Manage the workspace-scoped Decodal Profile registry and source files used by Backend-published launch profile discovery.", + "Inspect the Profile launch projection derived from the active Workspace configuration revision.", bullets: [ "Selectors are source-qualified (builtin:* or project:*); raw profile source paths, archive content, archive digests, resource handles, and runtime tokens are not exposed.", - "Profile source edits are validated through the Backend ProfileSourceArchive/Decodal boundary before they are persisted.", - "Launch profile candidates refresh from the same Backend projection after registry or source updates.", + "Profile declarations and sources are edited only through the shared Workspace configuration editor and evaluate-before-commit contract.", + "Launch candidates and Profile archives carry the same active config revision, tree digest, and projection digest.", ], }, { diff --git a/web/workspace/src/lib/workspace/settings/profile-api.ts b/web/workspace/src/lib/workspace/settings/profile-api.ts index b23fc3f6..56ae0332 100644 --- a/web/workspace/src/lib/workspace/settings/profile-api.ts +++ b/web/workspace/src/lib/workspace/settings/profile-api.ts @@ -1,166 +1,72 @@ -import { workspaceApiJson, workspaceApiJsonWithBody } from "../api/http"; import type { - ProfileSettingsMutationResponse, ProfileSettingsResponse, WorkspaceMetadataMutationResponse, WorkspaceMetadataSettingsResponse, - WorkspaceProfileSourceDetailResponse, - WorkspaceProfileSourceTreeFileResponse, - WorkspaceProfileSourceTreeResponse, } from "./profile-types"; -export function fetchWorkspaceMetadataSettings( +export type WorkspaceProfileApi = { + getMetadata(workspaceId: string): Promise; + updateMetadata( + workspaceId: string, + displayName: string, + expectedRevision: string, + ): Promise; + getProfiles(workspaceId: string): Promise; +}; + +async function requestJson(input: RequestInfo | URL, init?: RequestInit): Promise { + const response = await fetch(input, init); + if (!response.ok) { + throw new Error(`request failed: ${response.status}`); + } + return (await response.json()) as T; +} + +export async function fetchWorkspaceMetadataSettings( workspaceId: string, ): Promise { - return workspaceApiJson( + return await requestJson( `/api/w/${encodeURIComponent(workspaceId)}/settings/workspace`, ); } -export function updateWorkspaceMetadataSettings( +export async function updateWorkspaceMetadataSettings( workspaceId: string, request: { display_name: string; revision: string }, ): Promise { - return workspaceApiJsonWithBody( + return await requestJson( `/api/w/${encodeURIComponent(workspaceId)}/settings/workspace`, { method: "PUT", + headers: { "content-type": "application/json" }, body: JSON.stringify(request), }, ); } -export function fetchProfileSettings( - workspaceId: string, -): Promise { - return workspaceApiJson( +export async function fetchProfileSettings(workspaceId: string): Promise { + return await requestJson( `/api/w/${encodeURIComponent(workspaceId)}/settings/profiles`, ); } -export function createProfileSource( - workspaceId: string, - request: { - name: string; - description?: string; - content: string; - registry_revision: string; - }, -): Promise { - return workspaceApiJsonWithBody( - `/api/w/${encodeURIComponent(workspaceId)}/settings/profiles`, - { - method: "POST", - body: JSON.stringify(request), +export function createWorkspaceProfileApi(): WorkspaceProfileApi { + return { + async getMetadata(workspaceId) { + return await requestJson( + `/api/w/${encodeURIComponent(workspaceId)}/settings/metadata`, + ); }, - ); -} - -export function updateProfileRegistry( - workspaceId: string, - request: { - registry_revision: string; - default_profile?: string | null; - profiles: Array< - { - name: string; - description?: string | null; - profile_source_id?: string | null; - } - >; - }, -): Promise { - return workspaceApiJsonWithBody( - `/api/w/${encodeURIComponent(workspaceId)}/settings/profiles/registry`, - { - method: "PUT", - body: JSON.stringify(request), + async updateMetadata(workspaceId, displayName, expectedRevision) { + return await requestJson( + `/api/w/${encodeURIComponent(workspaceId)}/settings/metadata`, + { + method: "PUT", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ display_name: displayName, expected_revision: expectedRevision }), + }, + ); }, - ); -} - -export function fetchProfileSource( - workspaceId: string, - sourceId: string, -): Promise { - return workspaceApiJson( - `/api/w/${encodeURIComponent(workspaceId)}/settings/profiles/${ - encodeURIComponent(sourceId) - }`, - ); -} - -export function updateProfileSource( - workspaceId: string, - sourceId: string, - request: { content: string; revision: string }, -): Promise { - return workspaceApiJsonWithBody( - `/api/w/${encodeURIComponent(workspaceId)}/settings/profiles/${ - encodeURIComponent(sourceId) - }`, - { method: "PUT", body: JSON.stringify(request) }, - ); -} - -export function deleteProfileSource( - workspaceId: string, - sourceId: string, - request: { registry_revision: string; source_revision: string }, -): Promise { - return workspaceApiJsonWithBody( - `/api/w/${encodeURIComponent(workspaceId)}/settings/profiles/${ - encodeURIComponent(sourceId) - }`, - { method: "DELETE", body: JSON.stringify(request) }, - ); -} - -export function fetchProfileSourceTree( - workspaceId: string, - sourceTreeId: string, -): Promise { - return workspaceApiJson( - `/api/w/${encodeURIComponent(workspaceId)}/settings/profiles/trees/${ - encodeURIComponent(sourceTreeId) - }`, - ); -} - -export function fetchProfileTreeFile( - workspaceId: string, - sourceTreeId: string, - path: string, -): Promise { - return workspaceApiJson( - `/api/w/${encodeURIComponent(workspaceId)}/settings/profiles/trees/${ - encodeURIComponent(sourceTreeId) - }/file?path=${encodeURIComponent(path)}`, - ); -} - -export function writeProfileTreeFile( - workspaceId: string, - sourceTreeId: string, - request: { path: string; content: string; revision?: string | null }, -): Promise { - return workspaceApiJsonWithBody( - `/api/w/${encodeURIComponent(workspaceId)}/settings/profiles/trees/${ - encodeURIComponent(sourceTreeId) - }/file`, - { method: "PUT", body: JSON.stringify(request) }, - ); -} - -export function deleteProfileTreeFile( - workspaceId: string, - sourceTreeId: string, - request: { path: string; revision: string }, -): Promise { - return workspaceApiJsonWithBody( - `/api/w/${encodeURIComponent(workspaceId)}/settings/profiles/trees/${ - encodeURIComponent(sourceTreeId) - }/file`, - { method: "DELETE", body: JSON.stringify(request) }, - ); + getProfiles: fetchProfileSettings, + }; } diff --git a/web/workspace/src/lib/workspace/settings/profile-routes.ts b/web/workspace/src/lib/workspace/settings/profile-routes.ts deleted file mode 100644 index d217bc35..00000000 --- a/web/workspace/src/lib/workspace/settings/profile-routes.ts +++ /dev/null @@ -1,22 +0,0 @@ -export function profileSettingsHref(workspaceId: string): string { - return `/w/${encodeURIComponent(workspaceId)}/settings/profiles`; -} - -export function profileSourceTreeSettingsHref( - workspaceId: string, - sourceTreeId: string, -): string { - return `${profileSettingsHref(workspaceId)}/trees/${ - encodeURIComponent(sourceTreeId) - }`; -} - -export function virtualProfilePathForCreate(input: string): string { - const trimmed = input.trim(); - if (!trimmed) return ""; - if (trimmed.startsWith("project:") || trimmed.startsWith("workspace:")) { - return trimmed; - } - if (trimmed.startsWith("profiles/")) return trimmed; - return `profiles/${trimmed}`; -} diff --git a/web/workspace/src/lib/workspace/settings/profile-types.ts b/web/workspace/src/lib/workspace/settings/profile-types.ts index d5d525e1..85fdf323 100644 --- a/web/workspace/src/lib/workspace/settings/profile-types.ts +++ b/web/workspace/src/lib/workspace/settings/profile-types.ts @@ -29,7 +29,7 @@ export type WorkspaceProfileSummary = { export type WorkspaceProfileSourceSummary = { profile_source_id: string; display_path: string; - kind: "decodal" | string; + kind: "virtual_config" | string; content_type: string; content_digest: string; provenance: "project_profile_source_tree" | string; @@ -42,64 +42,11 @@ export type WorkspaceProfileSourceSummary = { export type ProfileSettingsResponse = { workspace_id: string; registry_revision: string; + config_revision?: number | null; + tree_digest?: string | null; + projection_digest?: string | null; default_profile?: string | null; profiles: WorkspaceProfileSummary[]; sources: WorkspaceProfileSourceSummary[]; - source_trees: WorkspaceProfileSourceTreeSummary[]; - diagnostics: Diagnostic[]; -}; - -export type WorkspaceProfileSourceDetailResponse = { - workspace_id: string; - profile: WorkspaceProfileSummary; - source: WorkspaceProfileSourceSummary; - content: string; - diagnostics: Diagnostic[]; -}; - -export type ProfileSettingsMutationResponse = { - workspace_id: string; - settings: ProfileSettingsResponse; - diagnostics: Diagnostic[]; -}; - -export type WorkspaceProfileSourceTreeSummary = { - source_tree_id: string; - label: string; - root_path: string; - kind: "decodal_source_tree" | string; - content_type: string; - content_digest: string; - provenance: "project_profile_source_tree" | string; - editable: boolean; - revision: string; - file_count: number; - diagnostics: Diagnostic[]; -}; - -export type WorkspaceProfileSourceTreeFileSummary = { - path: string; - kind: "decodal" | string; - content_type: string; - content_digest: string; - provenance: "project_profile_source_tree" | string; - editable: boolean; - revision: string; - size_bytes: number; - diagnostics: Diagnostic[]; -}; - -export type WorkspaceProfileSourceTreeResponse = { - workspace_id: string; - tree: WorkspaceProfileSourceTreeSummary; - files: WorkspaceProfileSourceTreeFileSummary[]; - diagnostics: Diagnostic[]; -}; - -export type WorkspaceProfileSourceTreeFileResponse = { - workspace_id: string; - source_tree_id: string; - file: WorkspaceProfileSourceTreeFileSummary; - content: string; diagnostics: Diagnostic[]; }; diff --git a/web/workspace/src/routes/w/[workspaceId]/settings/profiles/+page.svelte b/web/workspace/src/routes/w/[workspaceId]/settings/profiles/+page.svelte index 8a2d0a46..87ae865a 100644 --- a/web/workspace/src/routes/w/[workspaceId]/settings/profiles/+page.svelte +++ b/web/workspace/src/routes/w/[workspaceId]/settings/profiles/+page.svelte @@ -1,130 +1,31 @@ - - - Profile source tree · Yoi Workspace - - -
-
-
-

Profile source tree

-
-
- - {#if loading} -

Loading source tree…

- {:else if tree} -

- {tree.tree.root_path} · {tree.tree.file_count} files · {tree.tree.content_type} · {tree.tree.content_digest} -

-
- - -
-
-
-

Files

-
    - {#each tree.files as file (file.path)} -
  • - - {file.content_type} · {file.content_digest} - {file.size_bytes} bytes · rev {file.revision} - -
  • - {/each} -
-
- {#if selectedFile} -
-
-
-

{selectedFile.file.content_type}

-

{selectedFile.file.path}

-
-
- - -
-
- (draftContent = value)} ariaLabel={`Decodal source ${selectedFile.file.path}`} /> -
- {/if} -
- {/if} - - {#if message}

{message}

{/if} - -
diff --git a/web/workspace/src/routes/w/[workspaceId]/settings/profiles/trees/[sourceTreeId]/+page.ts b/web/workspace/src/routes/w/[workspaceId]/settings/profiles/trees/[sourceTreeId]/+page.ts deleted file mode 100644 index b96d2552..00000000 --- a/web/workspace/src/routes/w/[workspaceId]/settings/profiles/trees/[sourceTreeId]/+page.ts +++ /dev/null @@ -1,5 +0,0 @@ -import type { PageLoad } from "./$types"; - -export const load: PageLoad = ({ params }) => ({ - sourceTreeId: params.sourceTreeId, -}); diff --git a/web/workspace/test/config-source/decodal-grammar.test.ts b/web/workspace/test/config-source/decodal-grammar.test.ts new file mode 100644 index 00000000..852be041 --- /dev/null +++ b/web/workspace/test/config-source/decodal-grammar.test.ts @@ -0,0 +1,25 @@ +import { decodalLanguage } from "decodal-codemirror"; + +Deno.test("editor grammar accepts Decodal 0.4 schema syntax", () => { + const source = ` +import "main.dcdl" as WorkspaceConfigSchema +{ + features = {...{ enabled = Bool; }}; + web = { enabled = Bool; ...Unknown }; +} +`; + const tree = decodalLanguage.parser.parse(source); + const errors: string[] = []; + tree.iterate({ + enter(node) { + if (node.type.isError) { + errors.push(`${node.from}..${node.to}`); + } + }, + }); + if (errors.length > 0) { + throw new Error( + `Decodal 0.4 grammar produced parse errors at ${errors.join(", ")}`, + ); + } +}); diff --git a/web/workspace/test/config-source/wasm-parity.test.ts b/web/workspace/test/config-source/wasm-parity.test.ts index a2f30077..41df078b 100644 --- a/web/workspace/test/config-source/wasm-parity.test.ts +++ b/web/workspace/test/config-source/wasm-parity.test.ts @@ -1,19 +1,48 @@ /// import { assertEquals } from "jsr:@std/assert"; -// The generated wasm-bindgen loader is JavaScript with an adjacent declaration file. -// @ts-expect-error Deno checks the generated JS implementation rather than its .d.ts. import init, { analyze_snapshot, + compose_schema_bundle, evaluate_snapshot, } from "../../src/lib/workspace/config-source/generated/config_source_wasm.js"; -import type { ConfigTreeSnapshot, ToolchainContract } from "../../src/lib/workspace/config-source/types.ts"; +import type { + ConfigTreeSnapshot, + ToolchainContract, + WorkspaceConfigSchemaBundle, +} from "../../src/lib/workspace/config-source/types.ts"; const bytes = await Deno.readFile( - new URL("../../src/lib/workspace/config-source/generated/config_source_wasm_bg.wasm", import.meta.url), + new URL( + "../../src/lib/workspace/config-source/generated/config_source_wasm_bg.wasm", + import.meta.url, + ), ); await init({ module_or_path: bytes }); +async function digestText(text: string): Promise { + const bytes = new TextEncoder().encode(text); + const digest = await crypto.subtle.digest("SHA-256", bytes); + return `sha256:${ + Array.from(new Uint8Array(digest), (byte) => + byte.toString(16).padStart(2, "0")).join("") + }`; +} + +async function toolchainFingerprint( + entrypoints: string[], + schemaBundle: WorkspaceConfigSchemaBundle, +): Promise { + return await digestText(JSON.stringify([ + 2, + "0.4.0", + 1, + entrypoints, + 1, + schemaBundle.fingerprint, + ])); +} + const snapshot: ConfigTreeSnapshot = { revision: 4, digest: "sha256:test-tree", @@ -33,13 +62,20 @@ const snapshot: ConfigTreeSnapshot = { }, }; +const emptySchemaBundle = compose_schema_bundle( + [], +) as WorkspaceConfigSchemaBundle; const contract: ToolchainContract = { - contract_version: 1, - decodal_version: "0.2.0", + contract_version: 2, + decodal_version: "0.4.0", schema_version: 1, entrypoints: ["workspace.dcdl"], import_policy_version: 1, - fingerprint: "sha256:test-contract", + schema_bundle: emptySchemaBundle, + fingerprint: await toolchainFingerprint( + ["workspace.dcdl"], + emptySchemaBundle, + ), }; Deno.test("generated WASM evaluates the same virtual import contract", () => { @@ -65,3 +101,143 @@ Deno.test("generated WASM diagnostics carry snapshot provenance", () => { assertEquals(diagnostics[0].tree_digest, "sha256:test-tree"); assertEquals(diagnostics[0].kind, "syntax"); }); + +const featuresSchema = "{ features = {...{ enabled = Bool; }}; }"; +const webSchema = "{ web = { enabled = Bool; ...Unknown }; }"; +const schemaBundle = compose_schema_bundle([ + { + provider_id: "builtin:features", + namespace: "features", + version: "1", + source: featuresSchema, + source_digest: await digestText(featuresSchema), + }, + { + provider_id: "builtin:web", + namespace: "web", + version: "1", + source: webSchema, + source_digest: await digestText(webSchema), + }, +]) as WorkspaceConfigSchemaBundle; + +function schemaSnapshot(source: string): ConfigTreeSnapshot { + return { + revision: 7, + digest: "sha256:schema-tree", + entries: { + "main.dcdl": { + path: "main.dcdl", + content_type: "decodal", + content: source, + content_digest: "sha256:main", + }, + }, + }; +} + +const schemaContract: ToolchainContract = { + contract_version: 2, + decodal_version: "0.4.0", + schema_version: 1, + entrypoints: ["main.dcdl"], + import_policy_version: 1, + schema_bundle: schemaBundle, + fingerprint: await toolchainFingerprint(["main.dcdl"], schemaBundle), +}; + +const markdownSnapshot: ConfigTreeSnapshot = { + revision: 8, + digest: "sha256:markdown-tree", + entries: { + "main.dcdl": { + path: "main.dcdl", + content_type: "decodal", + content: + `{ skill = import "./skills/debug-rust/SKILL.md" as { frontmatter = { name = String; description = String; ...Unknown }; content = String; }; }`, + content_digest: "sha256:markdown-main", + }, + "skills/debug-rust/SKILL.md": { + path: "skills/debug-rust/SKILL.md", + content_type: "text", + content: + "---\nname: debug-rust\ndescription: Debug Rust\ncustom-authority: no\n---\n# Debug Rust\n", + content_digest: "sha256:markdown-skill", + }, + }, +}; +const markdownContract: ToolchainContract = { + ...contract, + entrypoints: ["main.dcdl"], + fingerprint: await toolchainFingerprint(["main.dcdl"], emptySchemaBundle), +}; + +Deno.test("generated WASM evaluates Markdown with the shared Skill projection", () => { + const result = evaluate_snapshot(markdownSnapshot, markdownContract) as { + projections: Array<{ data_json: Record }>; + }; + assertEquals(result.projections[0].data_json, { + skill: { + frontmatter: { + "custom-authority": "no", + description: "Debug Rust", + name: "debug-rust", + }, + content: "# Debug Rust\n", + }, + }); +}); + +Deno.test("generated WASM applies Decodal 0.4 typed maps and explicit object rest", () => { + const result = evaluate_snapshot( + schemaSnapshot( + "{ features = { console = { enabled = true; }; }; web = { enabled = true; extension_value = 42; }; }", + ), + schemaContract, + ) as { projections: Array<{ data_json: Record }> }; + assertEquals(result.projections[0].data_json, { + features: { console: { enabled: true } }, + web: { enabled: true, extension_value: 42 }, + }); +}); + +type ProjectedDiagnostic = { + path: string; + kind: string; + message: string; + span: { start_byte: number; end_byte: number }; +}; + +function evaluateFailure(source: string): ProjectedDiagnostic { + try { + evaluate_snapshot(schemaSnapshot(source), schemaContract); + } catch (error) { + const diagnostics = error as ProjectedDiagnostic[]; + assertEquals(Array.isArray(diagnostics), true); + return diagnostics[0]; + } + throw new Error("expected Decodal evaluation to fail"); +} + +Deno.test("generated WASM preserves native Decodal 0.4 diagnostic semantics", () => { + for ( + const [source, expectedKind] of [ + ["{ features = {}; custom = 42; }", "constraintviolation"], + [ + '{ features = { web = { enabled = "yes"; }; }; }', + "constraintviolation", + ], + ["{ features = { web = {}; }; }", "materialize"], + [ + "{ features = { web = { enabled = true; typo = 1; }; }; }", + "constraintviolation", + ], + ] as const + ) { + const diagnostic = evaluateFailure(source); + assertEquals(diagnostic.path, "main.dcdl"); + assertEquals(diagnostic.kind, expectedKind); + assertEquals(diagnostic.message.length > 0, true); + assertEquals(diagnostic.span.end_byte > diagnostic.span.start_byte, true); + } +});