Merge branch 'orchestration' into develop
# Conflicts: # web/workspace/deno.json
This commit is contained in:
@@ -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);
|
||||
|
||||
@@ -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
@@ -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(
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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`].
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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"
|
||||
"#,
|
||||
);
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
@@ -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");
|
||||
|
||||
@@ -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(())
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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"),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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
@@ -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");
|
||||
}
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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}");
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
+447
-584
File diff suppressed because it is too large
Load Diff
@@ -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(¤t)).unwrap();
|
||||
assert_eq!(sibling.to_qualified_string(), "$yoi/common/workspace");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unqualified_ref_from_root_file_has_empty_dir() {
|
||||
let loader = PromptLoader::builtins_only();
|
||||
let current = loader.parse_ref("$yoi/default", None).unwrap();
|
||||
let sibling = loader.parse_ref("other", Some(¤t)).unwrap();
|
||||
assert_eq!(sibling.to_qualified_string(), "$yoi/other");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn explicit_prefix_overrides_current() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let user_dir = tmp.path().to_path_buf();
|
||||
std::fs::write(user_dir.join("custom.md"), "user-body").unwrap();
|
||||
let loader = PromptLoader::new(Some(user_dir), None);
|
||||
|
||||
let current = loader.parse_ref("$yoi/default", None).unwrap();
|
||||
// Even with an $yoi-rooted current, an explicit $user
|
||||
// prefix must win.
|
||||
let (reference, source) = loader.resolve("$user/custom", Some(¤t)).unwrap();
|
||||
assert_eq!(reference.to_qualified_string(), "$user/custom");
|
||||
assert_eq!(source, "user-body");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn traversal_segments_rejected() {
|
||||
let loader = PromptLoader::builtins_only();
|
||||
let err = loader.resolve("$yoi/../etc/passwd", None).unwrap_err();
|
||||
assert!(matches!(err, LoaderError::InvalidRef { .. }));
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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
@@ -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}"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
|
||||
@@ -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(®istry_path, registry_toml).unwrap();
|
||||
AvailableProfiles {
|
||||
registry: Some(
|
||||
@@ -1605,7 +1594,7 @@ scheme = "anthropic"
|
||||
model_id = "coder-model"
|
||||
|
||||
[engine]
|
||||
instruction = "$yoi/coder"
|
||||
instruction = "role.coder"
|
||||
language = "Coderish"
|
||||
max_tokens = 2222
|
||||
"#;
|
||||
@@ -1619,7 +1608,7 @@ scheme = "anthropic"
|
||||
model_id = "reviewer-model"
|
||||
|
||||
[engine]
|
||||
instruction = "$yoi/reviewer"
|
||||
instruction = "role.reviewer"
|
||||
language = "Reviewerish"
|
||||
max_tokens = 3333
|
||||
"#;
|
||||
@@ -1636,8 +1625,7 @@ max_tokens = 3333
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
let config_json =
|
||||
build_spawn_config_json("child", "$yoi/default", &[], &model, false).unwrap();
|
||||
let config_json = build_spawn_config_json("child", "default", &[], &model, false).unwrap();
|
||||
let parsed: WorkerManifestConfig = serde_json::from_str(&config_json).unwrap();
|
||||
|
||||
assert_eq!(parsed.model.scheme, Some(SchemeKind::Anthropic));
|
||||
@@ -1659,8 +1647,7 @@ max_tokens = 3333
|
||||
ref_: Some("anthropic/claude-sonnet-4-6".into()),
|
||||
..Default::default()
|
||||
};
|
||||
let config_json =
|
||||
build_spawn_config_json("child", "$yoi/default", &[], &model, false).unwrap();
|
||||
let config_json = build_spawn_config_json("child", "default", &[], &model, false).unwrap();
|
||||
let parsed: WorkerManifestConfig = serde_json::from_str(&config_json).unwrap();
|
||||
assert_eq!(
|
||||
parsed.model.ref_.as_deref(),
|
||||
@@ -1681,7 +1668,7 @@ max_tokens = 3333
|
||||
}];
|
||||
|
||||
let config_json =
|
||||
build_spawn_config_json("child", "$yoi/default", &scope, &model, true).unwrap();
|
||||
build_spawn_config_json("child", "default", &scope, &model, true).unwrap();
|
||||
let parsed: WorkerManifestConfig = serde_json::from_str(&config_json).unwrap();
|
||||
assert_eq!(
|
||||
parsed.session.as_ref().and_then(|s| s.record_event_trace),
|
||||
@@ -1701,8 +1688,7 @@ max_tokens = 3333
|
||||
ref_: Some("anthropic/claude-sonnet-4-6".into()),
|
||||
..Default::default()
|
||||
};
|
||||
let config_json =
|
||||
build_spawn_config_json("child", "$yoi/default", &[], &model, false).unwrap();
|
||||
let config_json = build_spawn_config_json("child", "default", &[], &model, false).unwrap();
|
||||
let parsed: WorkerManifestConfig = serde_json::from_str(&config_json).unwrap();
|
||||
|
||||
assert!(parsed.session.is_none());
|
||||
@@ -1738,7 +1724,7 @@ max_tokens = 3333
|
||||
|
||||
assert_eq!(config.worker.name.as_deref(), Some("child-default"));
|
||||
assert_eq!(config.model.model_id.as_deref(), Some("reviewer-model"));
|
||||
assert_eq!(config.engine.instruction.as_deref(), Some("$yoi/reviewer"));
|
||||
assert_eq!(config.engine.instruction.as_deref(), Some("role.reviewer"));
|
||||
assert_eq!(config.engine.language.as_deref(), Some("Reviewerish"));
|
||||
assert_eq!(config.scope.allow, scope);
|
||||
assert!(config.scope.deny.is_empty());
|
||||
@@ -1777,7 +1763,7 @@ max_tokens = 3333
|
||||
|
||||
assert_eq!(config.worker.name.as_deref(), Some("review-child"));
|
||||
assert_eq!(config.model.model_id.as_deref(), Some("reviewer-model"));
|
||||
assert_eq!(config.engine.instruction.as_deref(), Some("$yoi/reviewer"));
|
||||
assert_eq!(config.engine.instruction.as_deref(), Some("role.reviewer"));
|
||||
assert_eq!(config.engine.language.as_deref(), Some("Reviewerish"));
|
||||
assert_eq!(config.engine.max_tokens, Some(3333));
|
||||
assert_eq!(config.scope.allow, scope);
|
||||
@@ -1811,7 +1797,7 @@ max_tokens = 3333
|
||||
|
||||
assert_eq!(config.worker.name.as_deref(), Some("inherited-child"));
|
||||
assert_eq!(config.model.model_id.as_deref(), Some("parent-model"));
|
||||
assert_eq!(config.engine.instruction.as_deref(), Some("$yoi/parent"));
|
||||
assert_eq!(config.engine.instruction.as_deref(), Some("default"));
|
||||
assert_eq!(config.engine.language.as_deref(), Some("Parentish"));
|
||||
assert_eq!(config.engine.max_tokens, Some(1234));
|
||||
assert_eq!(
|
||||
@@ -1846,15 +1832,12 @@ max_tokens = 3333
|
||||
&available,
|
||||
&project,
|
||||
"override-child",
|
||||
Some("$user/custom-reviewer"),
|
||||
Some("role.reviewer"),
|
||||
&scope,
|
||||
SpawnProfileSelector::Default,
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
config.engine.instruction.as_deref(),
|
||||
Some("$user/custom-reviewer")
|
||||
);
|
||||
assert_eq!(config.engine.instruction.as_deref(), Some("role.reviewer"));
|
||||
assert_eq!(config.model.model_id.as_deref(), Some("reviewer-model"));
|
||||
assert_eq!(config.engine.language.as_deref(), Some("Reviewerish"));
|
||||
assert_eq!(config.engine.max_tokens, Some(3333));
|
||||
@@ -1944,7 +1927,7 @@ max_tokens = 3333
|
||||
|
||||
let user_config = tmp.path().join("user-profiles.toml");
|
||||
std::fs::write(&user_config, "[profile]\ncoder = \"user-coder.toml\"\n").unwrap();
|
||||
let project_config = project.join(".yoi/profiles.toml");
|
||||
let project_config = project.join("explicit-project-profiles.toml");
|
||||
let ambiguous = AvailableProfiles {
|
||||
registry: Some(
|
||||
ProfileDiscovery::with_sources(Some(user_config), Some(project_config))
|
||||
|
||||
+22
-22
@@ -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);
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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()
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
Reference in New Issue
Block a user