Merge branch 'orchestration' into develop

# Conflicts:
#	web/workspace/deno.json
This commit is contained in:
2026-08-14 14:49:01 +09:00
81 changed files with 3997 additions and 6039 deletions
+4 -4
View File
@@ -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);
+2
View File
@@ -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"
+662 -10
View File
@@ -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<String, serde_json::Value>,
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<MarkdownDocumentProjection, String> {
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::<serde_yaml::Value>(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<serde_json::Value, String> {
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::<Result<Vec<_>, _>>()
.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<Value, String> {
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<Value, String> {
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::<Result<Vec<_>, _>>()
.map(Value::array),
serde_json::Value::Object(values) => values
.into_iter()
.map(|(name, value)| Ok((name, json_to_decodal_value(value)?)))
.collect::<Result<Vec<_>, _>>()
.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<String, String>,
},
}
#[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<ConfigProjectionValidator>,
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::<Vec<_>>(),
@@ -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<String, String>,
) -> 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() { "<root>" } else { prefix }
)),
}
}
pub fn validate_static_template_catalog(
templates: &BTreeMap<String, String>,
) -> 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<String, Vec<String>>,
visiting: &mut Vec<String>,
visited: &mut BTreeSet<String>,
) -> 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<Vec<String>, 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<VirtualPath, ConfigEntry>) -> 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(
+1 -20
View File
@@ -349,11 +349,6 @@ impl From<FeatureConfig> for FeatureConfigPartial {
pub struct WorkerMetaConfig {
#[serde(default)]
pub name: Option<String>,
/// 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<PathBuf>,
}
#[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<WorkerManifestConfig> 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<WorkerManifestConfig> 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),
+3 -4
View File
@@ -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`].
+6 -22
View File
@@ -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<PathBuf>,
}
/// 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));
+1 -27
View File
@@ -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<PathBuf> {
user_profiles_path_from_config_dir(config_dir())
}
/// `<config_dir>/prompts/` — user prompts ライブラリ。
pub fn user_prompts_dir() -> Option<PathBuf> {
user_prompts_dir_from_config_dir(config_dir())
}
/// `<config_dir>/prompts.toml` — user prompt pack。
pub fn user_pack_file() -> Option<PathBuf> {
user_pack_file_from_config_dir(config_dir())
}
/// `<config_dir>/<file_name>` — providers.toml / models.toml 等の
/// user override ファイル。
pub fn user_catalog_override(file_name: &str) -> Option<PathBuf> {
@@ -200,14 +190,6 @@ fn user_profiles_path_from_config_dir(config_dir: Option<PathBuf>) -> Option<Pat
Some(config_dir?.join("profiles.toml"))
}
fn user_prompts_dir_from_config_dir(config_dir: Option<PathBuf>) -> Option<PathBuf> {
Some(config_dir?.join("prompts"))
}
fn user_pack_file_from_config_dir(config_dir: Option<PathBuf>) -> Option<PathBuf> {
Some(config_dir?.join("prompts.toml"))
}
fn user_catalog_override_from_config_dir(
config_dir: Option<PathBuf>,
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")
+8 -265
View File
@@ -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<PathBuf>, project_config: Option<PathBuf>) -> Self {
@@ -363,19 +362,6 @@ pub struct ProfileManifestSnapshot {
pub source: ProfileSource,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub profile: Option<ProfileMetadata>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub workspace_override: Option<WorkspaceOverrideSnapshot>,
}
#[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<WorkspaceOverrideLayer>,
) -> Result<ResolvedProfile, ProfileError> {
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<Option<WorkspaceOverrideLayer>, ProfileError> {
find_workspace_override_from(workspace_base)
.map(|path| load_workspace_override_file(&path))
.transpose()
}
fn load_workspace_override_file(path: &Path) -> Result<WorkspaceOverrideLayer, ProfileError> {
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<PathBuf> {
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<PathBuf> {
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");
+6 -6
View File
@@ -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"
"#,
);
+1 -1
View File
@@ -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
+4 -11
View File
@@ -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<String, minijinja::Error> {
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<String, worker::CatalogError> {
worker::PromptCatalog::builtins_only()?
.render_serializable(ORCHESTRATOR_IDLE_QUEUE_NOTICE_PROMPT, context)
}
fn orchestrator_work_set_detail(
+12 -52
View File
@@ -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") })
);
}
@@ -24,6 +24,8 @@ pub struct ConfigBundle {
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub declarations: Vec<ConfigDeclaration>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub prompt_catalog: Option<worker::EffectivePromptCatalog>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub profile_source_archive: Option<ProfileSourceArchive>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub profile_source_archive_handle: Option<BackendResourceHandle>,
@@ -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");
+8 -1
View File
@@ -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(())
}
+12
View File
@@ -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,
}
+3 -3
View File
@@ -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"),
}
}
+110 -25
View File
@@ -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::<Vec<_>>();
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<Option<ConfigBundle>, 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<Option<WorkerExecutionSpawnResult>>,
restore_count: Mutex<u64>,
run_generations: Mutex<Vec<u64>>,
config_bundles: Mutex<Vec<Option<ConfigBundle>>>,
contexts: Mutex<BTreeMap<WorkerId, WorkerExecutionContext>>,
dispatched_inputs: Mutex<Vec<WorkerInput>>,
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]
+20 -5
View File
@@ -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,
}
+1 -3
View File
@@ -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 }
+1 -47
View File
@@ -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<String> = 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");
}
+21 -26
View File
@@ -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::<WorkerManifestConfig>(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());
}
}
+4 -4
View File
@@ -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();
+1 -1
View File
@@ -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";
@@ -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}");
}
}
+4 -2
View File
@@ -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;
File diff suppressed because it is too large Load Diff
-425
View File
@@ -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` | `<config_dir>/prompts/` (resolved by `manifest::paths`) |
//! | `$workspace` | `<project>/.yoi/prompts/` |
//!
//! A reference is `$<prefix>/<path>` where `<path>` 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<PathBuf>,
workspace_dir: Option<PathBuf>,
user_pack_file: Option<PathBuf>,
workspace_pack_file: Option<PathBuf>,
}
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<PathBuf>, workspace_dir: Option<PathBuf>) -> 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<PathBuf>,
workspace_pack_file: Option<PathBuf>,
) -> 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<PromptRef, LoaderError> {
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<String, LoaderError> {
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<Prefix, LoaderError> {
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<String, LoaderError> {
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<String, LoaderError> {
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<String, LoaderError> {
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(&current)).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(&current)).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(&current)).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 { .. }));
}
}
+1 -1
View File
@@ -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;
+30
View File
@@ -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<Arc<EffectivePromptCatalog>>,
}
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()
}
}
+117 -539
View File
@@ -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<Environment<'static>>,
catalog: Arc<PromptCatalog>,
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<Self, SystemPromptError> {
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<Self, SystemPromptError> {
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<String, SystemPromptError> {
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<String> {
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<PromptRef> {
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<String>,
agents_md: Option<String>,
) -> 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<String> {
["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<Arc<PromptCatalog>> = 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}"
);
}
}
}
+16 -3
View File
@@ -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:<name>` or `workspace:<name>`.
/// Stable id: `builtin:<name>` or `workspace:<name>`.
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<String>,
/// Active Workspace config revision for Workspace Skills.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub revision: Option<u64>,
/// Digest of the immutable `SKILL.md` source.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub source_digest: Option<String>,
/// Digest of the active virtual config tree snapshot.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub tree_digest: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
@@ -101,7 +113,8 @@ pub struct SkillDetailResponse {
pub overrides: Vec<SkillProvenance>,
#[serde(default)]
pub diagnostics: Vec<SkillDiagnostic>,
/// 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<String>,
@@ -117,7 +130,7 @@ pub struct SkillActivationResponse {
pub provenance: SkillProvenance,
#[serde(default)]
pub diagnostics: Vec<SkillDiagnostic>,
/// 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,
}
+25 -42
View File
@@ -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<String>,
/// 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<String>,
/// 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<SpawnedWorkerRegistry>,
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(&registry_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))
+22 -22
View File
@@ -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<Self, WorkerError> {
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<Self, WorkerError> {
@@ -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<Box<dyn LlmClient>>,
@@ -4435,7 +4435,7 @@ where
pub async fn from_manifest_spawned(
manifest: WorkerManifest,
store: St,
loader: PromptLoader,
loader: PromptCatalogSource,
callback_socket: PathBuf,
) -> Result<Self, WorkerError> {
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<Self, WorkerError> {
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<Self, WorkerError> {
@@ -4613,7 +4613,7 @@ where
worker_name: &str,
fallback: WorkerManifest,
store: St,
loader: PromptLoader,
loader: PromptCatalogSource,
workspace_context: WorkerWorkspaceContext,
filesystem_authority: WorkerFilesystemAuthority,
) -> Result<Self, WorkerError> {
@@ -4683,7 +4683,7 @@ where
segment_id: SegmentId,
manifest: WorkerManifest,
store: St,
loader: PromptLoader,
loader: PromptCatalogSource,
) -> Result<Self, WorkerError> {
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<Self, WorkerError> {
@@ -4903,7 +4903,7 @@ where
pub async fn from_manifest_toml(toml: &str, store: St) -> Result<Self, WorkerError> {
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<ScopeRul
/// a previously-rendered `system_prompt` verbatim.
fn prepare_worker_common_with_context(
manifest: &WorkerManifest,
loader: &PromptLoader,
loader: &PromptCatalogSource,
parse_template: bool,
workspace_context: WorkerWorkspaceContext,
filesystem_authority: WorkerFilesystemAuthority,
@@ -5633,7 +5633,7 @@ fn prepare_worker_common_with_context(
fn prepare_worker_common_from_scope(
manifest: &WorkerManifest,
loader: &PromptLoader,
loader: &PromptCatalogSource,
parse_template: bool,
workspace_context: WorkerWorkspaceContext,
filesystem_authority: WorkerFilesystemAuthority,
@@ -5655,7 +5655,7 @@ fn prepare_worker_common_from_scope(
DelegationScope::from_config(&manifest.delegation_scope).map_err(WorkerError::Scope)?;
let client = crate::model_client::build_client(&manifest.model)?;
let prompts = PromptCatalog::load(loader, manifest.worker.prompt_pack.as_deref())?;
let prompts = PromptCatalog::load(loader)?;
let system_prompt_template = if parse_template {
Some(
SystemPromptTemplate::parse(&manifest.engine.instruction, loader.clone())
@@ -5706,7 +5706,7 @@ mod spawned_context_tests {
manifest.memory = Some(manifest::MemoryConfig::default());
let common = prepare_worker_common_with_context(
&manifest,
&PromptLoader::builtins_only(),
&PromptCatalogSource::builtins_only(),
false,
WorkerWorkspaceContext::local_filesystem(Some(WorkspaceId::new("ws-test").unwrap())),
WorkerFilesystemAuthority::local(workspace_root.clone(), cwd.clone()),
@@ -5739,7 +5739,7 @@ mod spawned_context_tests {
std::fs::create_dir_all(&cwd).unwrap();
let mut manifest = minimal_manifest_for_context_test(&workspace_root, &cwd);
manifest.memory = Some(manifest::MemoryConfig::default());
let loader = PromptLoader::new(None, Some(workspace_root.clone()));
let loader = PromptCatalogSource::builtins_only();
let workspace_id = WorkspaceId::new("ws-api-only").unwrap();
let common = prepare_worker_common_with_context(
&manifest,
@@ -5776,7 +5776,7 @@ mod spawned_context_tests {
let manifest = minimal_manifest_for_context_test(&workspace_root, &cwd);
let err = match prepare_worker_common_with_context(
&manifest,
&PromptLoader::builtins_only(),
&PromptCatalogSource::builtins_only(),
false,
WorkerWorkspaceContext::local_filesystem(Some(WorkspaceId::new("ws-test").unwrap())),
WorkerFilesystemAuthority::local(workspace_root.clone(), cwd.clone()),
@@ -5812,7 +5812,7 @@ mod spawned_context_tests {
let manifest = minimal_manifest_for_context_test(&workspace_root, &cwd);
let err = match prepare_worker_common_with_context(
&manifest,
&PromptLoader::builtins_only(),
&PromptCatalogSource::builtins_only(),
false,
WorkerWorkspaceContext::local_filesystem(Some(WorkspaceId::new("ws-test").unwrap())),
WorkerFilesystemAuthority::local(workspace_root.clone(), cwd.clone()),
@@ -6888,8 +6888,8 @@ mod build_summary_prompt_tests {
.unwrap();
worker.set_resident_memory_injection(gates.summary);
let template = SystemPromptTemplate::parse(
"$yoi/default",
crate::prompt::loader::PromptLoader::builtins_only(),
"default",
crate::prompt::source::PromptCatalogSource::builtins_only(),
)
.unwrap();
worker.set_system_prompt_template(template);
+5 -5
View File
@@ -49,7 +49,7 @@ async fn restore_from_worker_metadata_rejects_missing_metadata() {
"restore-test",
manifest,
store,
worker::PromptLoader::builtins_only(),
worker::PromptCatalogSource::builtins_only(),
)
.await;
@@ -84,7 +84,7 @@ async fn restore_from_worker_metadata_rejects_pending_segment() {
"restore-test",
manifest,
store,
worker::PromptLoader::builtins_only(),
worker::PromptCatalogSource::builtins_only(),
)
.await;
@@ -126,7 +126,7 @@ async fn restore_from_worker_metadata_resolves_active_pointer_through_session_lo
"restore-test",
manifest,
store,
worker::PromptLoader::builtins_only(),
worker::PromptCatalogSource::builtins_only(),
)
.await;
@@ -158,7 +158,7 @@ async fn restore_from_manifest_rejects_unknown_segment() {
unknown_seg,
manifest,
store,
worker::PromptLoader::builtins_only(),
worker::PromptCatalogSource::builtins_only(),
)
.await;
@@ -195,7 +195,7 @@ async fn restore_from_manifest_rejects_empty_segment_log() {
segid,
manifest,
store,
worker::PromptLoader::builtins_only(),
worker::PromptCatalogSource::builtins_only(),
)
.await;
@@ -11,7 +11,10 @@ use llm_engine::llm_client::{ClientError, LlmClient, Request};
use session_store::{CombinedStore, FsWorkerStore};
use session_store::{FsStore, LogEntry, Store};
use worker::{PromptLoader, SystemPromptTemplate, Worker, WorkerError};
use worker::{
EffectivePromptCatalog, PromptCatalog, PromptCatalogSource, SystemPromptTemplate, Worker,
WorkerError,
};
type TestStore = CombinedStore<FsStore, FsWorkerStore>;
@@ -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]
+389 -15
View File
@@ -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<ConfigDiagnostic>) -> 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<EvaluationResult> {
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<WorkspaceConfigState> {
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<VirtualPath> = 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<WorkspaceConfigState> {
initial_state_with_schema(WorkspaceConfigSchemaBundle::empty())
}
pub(crate) fn initial_state_with_schema(
schema_bundle: WorkspaceConfigSchemaBundle,
) -> Result<WorkspaceConfigState> {
let path = main_config_path();
let snapshot = ConfigTreeSnapshot::empty()
.apply(&[ConfigTreeChange::Create {
@@ -510,7 +594,7 @@ pub(crate) fn initial_state() -> Result<WorkspaceConfigState> {
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;
+71 -2
View File
@@ -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<RuntimeDiagnostic> {
)]
}
fn spawn_config_bundle_ref(request: &WorkerSpawnRequest) -> Option<ConfigBundleRef> {
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(
+1
View File
@@ -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;
+76 -11
View File
@@ -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<dyn std::error::Error>> {
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<dyn std::error::Error>>
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<yoi_workspace_server::config_source::WorkspaceConfigState, Box<dyn std::error::Error>> {
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<dyn std::error::Error>
})
}
async fn run_serve(options: ServeOptions) -> Result<(), Box<dyn std::error::Error>> {
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<Command, CliError> {
};
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<Command, CliError> {
}
}
fn parse_skill_workspace_options(args: &[String]) -> Result<SkillWorkspaceOptions, CliError> {
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 <workspace-id>".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<WorkspacePathOptions, CliError> {
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 <NAME> [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 <PATH> Workspace root (defaults to cwd)\n -h, --help Print help"
"yoi-server skills\n\nUsage:\n yoi-server skills list --workspace <WORKSPACE_ID>\n yoi-server skills lint --workspace <WORKSPACE_ID>\n yoi-server skills show <NAME> --workspace <WORKSPACE_ID>\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> 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 <workspace-id>"
);
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()];
File diff suppressed because it is too large Load Diff
@@ -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> {
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<EffectivePromptCatalog> {
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()
);
}
}
}
+230 -543
View File
@@ -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<ConfigPreviewRequest>,
) -> ApiResult<Json<crate::config_source::EvaluatedConfigCandidate>> {
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<ScopedWorkspacePath>,
) -> ApiResult<Json<crate::profile_settings::ProfileSettingsResponse>> {
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<WorkspaceApi>,
AxumPath(path): AxumPath<ScopedWorkspacePath>,
Json(request): Json<CreateWorkspaceProfileSourceRequest>,
) -> ApiResult<Json<crate::profile_settings::ProfileSettingsMutationResponse>> {
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<WorkspaceApi>,
AxumPath(path): AxumPath<ScopedWorkspacePath>,
Json(request): Json<UpdateWorkspaceProfileRegistryRequest>,
) -> ApiResult<Json<crate::profile_settings::ProfileSettingsMutationResponse>> {
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<WorkspaceApi>,
AxumPath((workspace_id, source_tree_id)): AxumPath<(String, String)>,
) -> ApiResult<Json<crate::profile_settings::WorkspaceProfileSourceTreeResponse>> {
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<WorkspaceApi>,
AxumPath((workspace_id, source_tree_id)): AxumPath<(String, String)>,
Query(query): Query<ReadWorkspaceProfileTreeFileQuery>,
) -> ApiResult<Json<crate::profile_settings::WorkspaceProfileSourceTreeFileResponse>> {
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<WorkspaceApi>,
AxumPath((workspace_id, source_tree_id)): AxumPath<(String, String)>,
Json(request): Json<WriteWorkspaceProfileTreeFileRequest>,
) -> ApiResult<Json<crate::profile_settings::WorkspaceProfileSourceTreeFileResponse>> {
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<WorkspaceApi>,
AxumPath((workspace_id, source_tree_id)): AxumPath<(String, String)>,
Json(request): Json<DeleteWorkspaceProfileTreeFileRequest>,
) -> ApiResult<Json<crate::profile_settings::WorkspaceProfileSourceTreeResponse>> {
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<WorkspaceApi>,
AxumPath((workspace_id, profile_source_id)): AxumPath<(String, String)>,
) -> ApiResult<Json<crate::profile_settings::WorkspaceProfileSourceDetailResponse>> {
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<WorkspaceApi>,
AxumPath((workspace_id, profile_source_id)): AxumPath<(String, String)>,
Json(request): Json<UpdateWorkspaceProfileSourceRequest>,
) -> ApiResult<Json<crate::profile_settings::ProfileSettingsMutationResponse>> {
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<WorkspaceApi>,
AxumPath((workspace_id, profile_source_id)): AxumPath<(String, String)>,
Json(request): Json<DeleteWorkspaceProfileSourceRequest>,
) -> ApiResult<Json<crate::profile_settings::ProfileSettingsMutationResponse>> {
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<ScopedWorkspacePath>,
) -> ApiResult<Json<worker::skill::SkillCatalogResponse>> {
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<ScopedWorkspacePath>,
) -> ApiResult<Json<worker::skill::SkillCatalogResponse>> {
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<ScopedSkillPath>,
) -> ApiResult<Json<worker::skill::SkillDetailResponse>> {
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<ScopedSkillPath>,
) -> ApiResult<Json<worker::skill::SkillActivationResponse>> {
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<WorkspaceApi>,
) -> ApiResult<Json<WorkerLaunchOptionsResponse>> {
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<WorkerLaunchOptionsResponse> {
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<ProfileSelector> {
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<u64> {
worker_id.parse::<u64>().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::<Vec<_>>();
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::<Value>(&bytes).unwrap();
(status, json)
}
fn diagnostic_codes(response: &Value) -> Vec<String> {
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())
File diff suppressed because it is too large Load Diff