Merge branch 'orchestration' into develop
# Conflicts: # web/workspace/deno.json
This commit is contained in:
Generated
+10
-7
@@ -596,9 +596,11 @@ dependencies = [
|
|||||||
"decodal",
|
"decodal",
|
||||||
"decodal-language-service",
|
"decodal-language-service",
|
||||||
"decodal-language-tools",
|
"decodal-language-tools",
|
||||||
|
"minijinja",
|
||||||
"pretty_assertions",
|
"pretty_assertions",
|
||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
|
"serde_yaml",
|
||||||
"sha2 0.11.0",
|
"sha2 0.11.0",
|
||||||
"thiserror 2.0.18",
|
"thiserror 2.0.18",
|
||||||
"ts-rs",
|
"ts-rs",
|
||||||
@@ -986,24 +988,24 @@ checksum = "092966b41edc516079bdf31ec78a2e0588d1d0c08f78b91d8307215928642b2b"
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "decodal"
|
name = "decodal"
|
||||||
version = "0.2.0"
|
version = "0.4.0"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "2b6e47d6bc66cd3cd42c8df8ff77a994a7743e889045c6b762a6dbc360ad8494"
|
checksum = "30e2a1ff0bf0d4160b998401a82aef64d26395e5b4798c55fbca6880a52f8e64"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "decodal-language-service"
|
name = "decodal-language-service"
|
||||||
version = "0.2.0"
|
version = "0.4.0"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "f25e462dce7c86743bd229ba91b831daf7928d524de9cef4ef861257ca156aa8"
|
checksum = "577f8cdf109dc318c6bef32c95194f0b164ccea234ab0f70a02aeb3296813b16"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"decodal",
|
"decodal",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "decodal-language-tools"
|
name = "decodal-language-tools"
|
||||||
version = "0.2.0"
|
version = "0.4.0"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "08d8e3eb978cb1c2259df838ba2a8102bc45553d7b415216c80fc5b6a3f378c6"
|
checksum = "2a18ca80b27386c5dbb160c74e88e6c1bfe33fd80f3cd5108bebe800b8aa698c"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"decodal",
|
"decodal",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
@@ -4793,7 +4795,6 @@ dependencies = [
|
|||||||
"fs4",
|
"fs4",
|
||||||
"llm-engine",
|
"llm-engine",
|
||||||
"manifest",
|
"manifest",
|
||||||
"minijinja",
|
|
||||||
"protocol",
|
"protocol",
|
||||||
"pulldown-cmark",
|
"pulldown-cmark",
|
||||||
"ratatui",
|
"ratatui",
|
||||||
@@ -4807,6 +4808,7 @@ dependencies = [
|
|||||||
"toml",
|
"toml",
|
||||||
"unicode-width",
|
"unicode-width",
|
||||||
"uuid",
|
"uuid",
|
||||||
|
"worker",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -6058,6 +6060,7 @@ dependencies = [
|
|||||||
"chrono",
|
"chrono",
|
||||||
"clap",
|
"clap",
|
||||||
"client",
|
"client",
|
||||||
|
"config-source",
|
||||||
"dotenv",
|
"dotenv",
|
||||||
"flow",
|
"flow",
|
||||||
"fs4",
|
"fs4",
|
||||||
|
|||||||
+3
-3
@@ -98,9 +98,9 @@ yoi-workspace-server = { path = "crates/workspace-server" }
|
|||||||
async-trait = "0.1"
|
async-trait = "0.1"
|
||||||
axum = "0.8"
|
axum = "0.8"
|
||||||
base64 = "0.22.1"
|
base64 = "0.22.1"
|
||||||
decodal = "0.2.0"
|
decodal = "0.4.0"
|
||||||
decodal-language-service = "0.2.0"
|
decodal-language-service = "0.4.0"
|
||||||
decodal-language-tools = "0.2.0"
|
decodal-language-tools = "0.4.0"
|
||||||
fs4 = "0.13"
|
fs4 = "0.13"
|
||||||
futures = "0.3"
|
futures = "0.3"
|
||||||
libc = "0.2"
|
libc = "0.2"
|
||||||
|
|||||||
@@ -1028,7 +1028,7 @@ profile = "builtin:companion"
|
|||||||
r#"
|
r#"
|
||||||
[ticket.roles.reviewer]
|
[ticket.roles.reviewer]
|
||||||
profile = "builtin:companion"
|
profile = "builtin:companion"
|
||||||
launch_prompt = "$workspace/ticket/reviewer/launch"
|
launch_prompt = "ticket.reviewer.launch"
|
||||||
"#,
|
"#,
|
||||||
);
|
);
|
||||||
let mut context = TicketRoleLaunchContext::new(temp.path(), TicketRole::Reviewer);
|
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.profile, "builtin:companion");
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
plan.launch_prompt_ref.as_deref(),
|
plan.launch_prompt_ref.as_deref(),
|
||||||
Some("$workspace/ticket/reviewer/launch")
|
Some("ticket.reviewer.launch")
|
||||||
);
|
);
|
||||||
assert!(matches!(&plan.run_segments[0], Segment::Text { .. }));
|
assert!(matches!(&plan.run_segments[0], Segment::Text { .. }));
|
||||||
assert!(!text.contains("Configured launch_prompt"));
|
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("Profile selector: builtin:companion"));
|
||||||
assert!(!text.contains("Role: reviewer"));
|
assert!(!text.contains("Role: reviewer"));
|
||||||
assert!(!text.contains("system_instruction"));
|
assert!(!text.contains("system_instruction"));
|
||||||
@@ -1228,7 +1228,7 @@ profile = "./coder.toml"
|
|||||||
r#"
|
r#"
|
||||||
[ticket.roles.coder]
|
[ticket.roles.coder]
|
||||||
profile = "inherit"
|
profile = "inherit"
|
||||||
system_instruction = "$workspace/not-supported"
|
system_instruction = "unsupported"
|
||||||
"#,
|
"#,
|
||||||
);
|
);
|
||||||
let context = TicketRoleLaunchContext::new(temp.path(), TicketRole::Coder);
|
let context = TicketRoleLaunchContext::new(temp.path(), TicketRole::Coder);
|
||||||
|
|||||||
@@ -9,8 +9,10 @@ publish = false
|
|||||||
decodal.workspace = true
|
decodal.workspace = true
|
||||||
decodal-language-service.workspace = true
|
decodal-language-service.workspace = true
|
||||||
decodal-language-tools.workspace = true
|
decodal-language-tools.workspace = true
|
||||||
|
minijinja = "2.19.0"
|
||||||
serde = { workspace = true, features = ["derive"] }
|
serde = { workspace = true, features = ["derive"] }
|
||||||
serde_json.workspace = true
|
serde_json.workspace = true
|
||||||
|
serde_yaml.workspace = true
|
||||||
sha2.workspace = true
|
sha2.workspace = true
|
||||||
thiserror.workspace = true
|
thiserror.workspace = true
|
||||||
ts-rs = "12.0.1"
|
ts-rs = "12.0.1"
|
||||||
|
|||||||
+662
-10
@@ -3,20 +3,20 @@ use std::fmt;
|
|||||||
|
|
||||||
use decodal::{
|
use decodal::{
|
||||||
Data, Diagnostic, DiagnosticKind, Engine, HostEnvironment, ImportCandidate, ImportLoader,
|
Data, Diagnostic, DiagnosticKind, Engine, HostEnvironment, ImportCandidate, ImportLoader,
|
||||||
LoadedImport, Span,
|
LoadedImport, Span, Value,
|
||||||
};
|
};
|
||||||
use decodal_language_service::{CompletionResult, LanguageService};
|
use decodal_language_service::{CompletionResult, LanguageService};
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use sha2::{Digest, Sha256};
|
use sha2::{Digest, Sha256};
|
||||||
|
|
||||||
pub const CONFIG_SOURCE_CONTRACT_VERSION: u32 = 2;
|
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_SCHEMA_VERSION: u32 = 1;
|
||||||
pub const DEFAULT_IMPORT_POLICY_VERSION: u32 = 1;
|
pub const DEFAULT_IMPORT_POLICY_VERSION: u32 = 1;
|
||||||
pub const WORKSPACE_CONFIG_SCHEMA_GLOBAL: &str = "WorkspaceConfigSchema";
|
pub const WORKSPACE_CONFIG_SCHEMA_GLOBAL: &str = "WorkspaceConfigSchema";
|
||||||
pub const WORKSPACE_CONFIG_SCHEMA_SOURCE: &str = "workspace-config-schema.dcdl";
|
pub const WORKSPACE_CONFIG_SCHEMA_SOURCE: &str = "workspace-config-schema.dcdl";
|
||||||
pub const WORKSPACE_CONFIG_EVALUATION_SOURCE: &str =
|
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_ENTRY_COUNT: usize = 256;
|
||||||
pub const MAX_CHANGE_COUNT: usize = 256;
|
pub const MAX_CHANGE_COUNT: usize = 256;
|
||||||
pub const MAX_ENTRY_BYTES: usize = 256 * 1024;
|
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)]
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, ts_rs::TS)]
|
||||||
pub struct ConfigEntry {
|
pub struct ConfigEntry {
|
||||||
pub path: VirtualPath,
|
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)]
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, ts_rs::TS)]
|
||||||
#[ts(export)]
|
#[ts(export)]
|
||||||
pub struct ConfigSchemaContribution {
|
pub struct ConfigSchemaContribution {
|
||||||
@@ -332,6 +488,8 @@ pub struct ConfigSchemaContribution {
|
|||||||
pub namespace: String,
|
pub namespace: String,
|
||||||
pub version: String,
|
pub version: String,
|
||||||
pub source: String,
|
pub source: String,
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub projection_validator: Option<ConfigProjectionValidator>,
|
||||||
pub source_digest: String,
|
pub source_digest: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -372,9 +530,15 @@ impl ConfigSchemaContribution {
|
|||||||
version,
|
version,
|
||||||
source_digest: digest_bytes(source.as_bytes()),
|
source_digest: digest_bytes(source.as_bytes()),
|
||||||
source,
|
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> {
|
fn validate(&self) -> Result<(), ConfigTreeError> {
|
||||||
let expected = digest_bytes(self.source.as_bytes());
|
let expected = digest_bytes(self.source.as_bytes());
|
||||||
if self.source_digest != expected {
|
if self.source_digest != expected {
|
||||||
@@ -440,6 +604,7 @@ impl WorkspaceConfigSchemaBundle {
|
|||||||
contribution.namespace.as_str(),
|
contribution.namespace.as_str(),
|
||||||
contribution.version.as_str(),
|
contribution.version.as_str(),
|
||||||
contribution.source_digest.as_str(),
|
contribution.source_digest.as_str(),
|
||||||
|
contribution.projection_validator.as_ref(),
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
.collect::<Vec<_>>(),
|
.collect::<Vec<_>>(),
|
||||||
@@ -686,8 +851,11 @@ impl SnapshotEnvironment {
|
|||||||
)]
|
)]
|
||||||
})?;
|
})?;
|
||||||
engine.bind_global_runtime(WORKSPACE_CONFIG_SCHEMA_GLOBAL, schema);
|
engine.bind_global_runtime(WORKSPACE_CONFIG_SCHEMA_GLOBAL, schema);
|
||||||
let evaluation_source =
|
let evaluation_source = if contract.schema_bundle.contributions.is_empty() {
|
||||||
WORKSPACE_CONFIG_EVALUATION_SOURCE.replace("__MAIN_ENTRYPOINT__", entrypoint.as_str());
|
format!("import \"{}\"", entrypoint.as_str())
|
||||||
|
} else {
|
||||||
|
WORKSPACE_CONFIG_EVALUATION_SOURCE.replace("__MAIN_ENTRYPOINT__", entrypoint.as_str())
|
||||||
|
};
|
||||||
let evaluation_module = engine
|
let evaluation_module = engine
|
||||||
.add_root_source(
|
.add_root_source(
|
||||||
"workspace-config-evaluation.dcdl",
|
"workspace-config-evaluation.dcdl",
|
||||||
@@ -721,6 +889,15 @@ impl SnapshotEnvironment {
|
|||||||
)]
|
)]
|
||||||
})?;
|
})?;
|
||||||
let data_json = decodal_data_to_json(&data);
|
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(
|
let projection_digest = digest_bytes(
|
||||||
serde_json::to_vec(&data_json)
|
serde_json::to_vec(&data_json)
|
||||||
.expect("Decodal projection serializes")
|
.expect("Decodal projection serializes")
|
||||||
@@ -850,8 +1027,26 @@ impl ImportLoader for SnapshotImportLoader {
|
|||||||
format!("virtual config import is missing: {path}"),
|
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(
|
Ok(LoadedImport::source(
|
||||||
path.as_str(),
|
cache_key,
|
||||||
path.as_str(),
|
path.as_str(),
|
||||||
entry.content.clone(),
|
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(
|
pub fn resolve_import(
|
||||||
current: &VirtualPath,
|
current: &VirtualPath,
|
||||||
specifier: &str,
|
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 {
|
fn snapshot_digest(entries: &BTreeMap<VirtualPath, ConfigEntry>) -> String {
|
||||||
let mut hasher = Sha256::new();
|
let mut hasher = Sha256::new();
|
||||||
hasher.update(b"yoi-config-tree-v1\0");
|
hasher.update(b"yoi-config-tree-v1\0");
|
||||||
@@ -1144,6 +1505,10 @@ mod tests {
|
|||||||
ConfigEntry::new(path(path_value), ConfigContentType::Decodal, content).unwrap()
|
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]
|
#[test]
|
||||||
fn virtual_paths_reject_ambiguous_or_escaping_forms() {
|
fn virtual_paths_reject_ambiguous_or_escaping_forms() {
|
||||||
for invalid in ["", "/root.dcdl", "a//b", "a/./b", "a/../b", "a\\b", "a\0b"] {
|
for invalid in ["", "/root.dcdl", "a//b", "a/./b", "a/../b", "a\\b", "a\0b"] {
|
||||||
@@ -1270,10 +1635,9 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn workspace_schema_is_applied_with_normal_decodal_composition() {
|
fn workspace_schema_applies_defaults_with_asymmetric_decodal_validation() {
|
||||||
let snapshot =
|
let snapshot =
|
||||||
ConfigTreeSnapshot::from_entries(1, [entry("main.dcdl", "{ web = {}; custom = 42; }")])
|
ConfigTreeSnapshot::from_entries(1, [entry("main.dcdl", "{ web = {}; }")]).unwrap();
|
||||||
.unwrap();
|
|
||||||
let schema = WorkspaceConfigSchemaBundle::compose([ConfigSchemaContribution::new(
|
let schema = WorkspaceConfigSchemaBundle::compose([ConfigSchemaContribution::new(
|
||||||
"builtin:web",
|
"builtin:web",
|
||||||
"web",
|
"web",
|
||||||
@@ -1287,7 +1651,194 @@ mod tests {
|
|||||||
.evaluate_contract(&contract)
|
.evaluate_contract(&contract)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
assert_eq!(result.projections[0].data_json["web"]["enabled"], false);
|
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]
|
#[test]
|
||||||
@@ -1333,6 +1884,107 @@ mod tests {
|
|||||||
assert_ne!(empty.fingerprint, configured.fingerprint);
|
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]
|
#[test]
|
||||||
fn host_environment_evaluation_uses_only_snapshot_imports() {
|
fn host_environment_evaluation_uses_only_snapshot_imports() {
|
||||||
let snapshot = ConfigTreeSnapshot::from_entries(
|
let snapshot = ConfigTreeSnapshot::from_entries(
|
||||||
|
|||||||
@@ -349,11 +349,6 @@ impl From<FeatureConfig> for FeatureConfigPartial {
|
|||||||
pub struct WorkerMetaConfig {
|
pub struct WorkerMetaConfig {
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub name: Option<String>,
|
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)]
|
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
||||||
@@ -554,9 +549,6 @@ impl WorkerManifestConfig {
|
|||||||
base.display()
|
base.display()
|
||||||
);
|
);
|
||||||
resolve_auth_file(&mut self.model.auth, base);
|
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 {
|
for rule in &mut self.scope.allow {
|
||||||
rule.target = join_if_relative(base, &rule.target);
|
rule.target = join_if_relative(base, &rule.target);
|
||||||
}
|
}
|
||||||
@@ -718,7 +710,6 @@ impl WorkerMetaConfig {
|
|||||||
fn merge(self, upper: Self) -> Self {
|
fn merge(self, upper: Self) -> Self {
|
||||||
Self {
|
Self {
|
||||||
name: upper.name.or(self.name),
|
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
|
.worker
|
||||||
.name
|
.name
|
||||||
.ok_or(ResolveError::MissingField("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")?;
|
validate_model_paths(&cfg.model, "model.auth.file")?;
|
||||||
|
|
||||||
@@ -1150,7 +1137,7 @@ impl TryFrom<WorkerManifestConfig> for WorkerManifest {
|
|||||||
validate_mcp_config(&cfg.mcp)?;
|
validate_mcp_config(&cfg.mcp)?;
|
||||||
|
|
||||||
Ok(WorkerManifest {
|
Ok(WorkerManifest {
|
||||||
worker: WorkerMeta { name, prompt_pack },
|
worker: WorkerMeta { name },
|
||||||
model: cfg.model,
|
model: cfg.model,
|
||||||
engine,
|
engine,
|
||||||
scope: cfg.scope,
|
scope: cfg.scope,
|
||||||
@@ -1187,7 +1174,6 @@ mod tests {
|
|||||||
WorkerManifestConfig {
|
WorkerManifestConfig {
|
||||||
worker: WorkerMetaConfig {
|
worker: WorkerMetaConfig {
|
||||||
name: Some("test".into()),
|
name: Some("test".into()),
|
||||||
prompt_pack: None,
|
|
||||||
},
|
},
|
||||||
model: ModelManifest {
|
model: ModelManifest {
|
||||||
scheme: Some(SchemeKind::Anthropic),
|
scheme: Some(SchemeKind::Anthropic),
|
||||||
@@ -1505,7 +1491,6 @@ mod tests {
|
|||||||
let lower = WorkerManifestConfig {
|
let lower = WorkerManifestConfig {
|
||||||
worker: WorkerMetaConfig {
|
worker: WorkerMetaConfig {
|
||||||
name: Some("lower".into()),
|
name: Some("lower".into()),
|
||||||
prompt_pack: None,
|
|
||||||
},
|
},
|
||||||
model: ModelManifest {
|
model: ModelManifest {
|
||||||
model_id: Some("lower-model".into()),
|
model_id: Some("lower-model".into()),
|
||||||
@@ -1516,7 +1501,6 @@ mod tests {
|
|||||||
let upper = WorkerManifestConfig {
|
let upper = WorkerManifestConfig {
|
||||||
worker: WorkerMetaConfig {
|
worker: WorkerMetaConfig {
|
||||||
name: Some("upper".into()),
|
name: Some("upper".into()),
|
||||||
prompt_pack: None,
|
|
||||||
},
|
},
|
||||||
..Default::default()
|
..Default::default()
|
||||||
};
|
};
|
||||||
@@ -1925,7 +1909,6 @@ enabled = false
|
|||||||
.merge(WorkerManifestConfig {
|
.merge(WorkerManifestConfig {
|
||||||
worker: WorkerMetaConfig {
|
worker: WorkerMetaConfig {
|
||||||
name: Some("feature-test".into()),
|
name: Some("feature-test".into()),
|
||||||
prompt_pack: None,
|
|
||||||
},
|
},
|
||||||
model: ModelManifest {
|
model: ModelManifest {
|
||||||
scheme: Some(SchemeKind::Anthropic),
|
scheme: Some(SchemeKind::Anthropic),
|
||||||
@@ -2008,7 +1991,6 @@ enabled = true
|
|||||||
.merge(WorkerManifestConfig {
|
.merge(WorkerManifestConfig {
|
||||||
worker: WorkerMetaConfig {
|
worker: WorkerMetaConfig {
|
||||||
name: Some("feature-merge-test".into()),
|
name: Some("feature-merge-test".into()),
|
||||||
prompt_pack: None,
|
|
||||||
},
|
},
|
||||||
model: ModelManifest {
|
model: ModelManifest {
|
||||||
scheme: Some(SchemeKind::Anthropic),
|
scheme: Some(SchemeKind::Anthropic),
|
||||||
@@ -2075,7 +2057,6 @@ permission = "write"
|
|||||||
let overlay = WorkerManifestConfig {
|
let overlay = WorkerManifestConfig {
|
||||||
worker: WorkerMetaConfig {
|
worker: WorkerMetaConfig {
|
||||||
name: Some("x".into()),
|
name: Some("x".into()),
|
||||||
prompt_pack: None,
|
|
||||||
},
|
},
|
||||||
model: ModelManifest {
|
model: ModelManifest {
|
||||||
scheme: Some(SchemeKind::Anthropic),
|
scheme: Some(SchemeKind::Anthropic),
|
||||||
|
|||||||
@@ -42,10 +42,9 @@ pub const COMPACT_OVERVIEW_WARNING_TOKENS: u64 = 16_000;
|
|||||||
/// See [`crate::CompactionConfig::overview_deadline_tokens`].
|
/// See [`crate::CompactionConfig::overview_deadline_tokens`].
|
||||||
pub const COMPACT_OVERVIEW_DEADLINE_TOKENS: u64 = 40_000;
|
pub const COMPACT_OVERVIEW_DEADLINE_TOKENS: u64 = 40_000;
|
||||||
|
|
||||||
/// Default instruction asset reference used when `worker.instruction`
|
/// Default exact catalog-root dotted Prompt name used when
|
||||||
/// is omitted. See the `PromptLoader` prefix addressing scheme for the
|
/// `worker.instruction` is omitted.
|
||||||
/// `$yoi/` / `$user/` / `$workspace/` namespaces.
|
pub const DEFAULT_INSTRUCTION: &str = "default";
|
||||||
pub const DEFAULT_INSTRUCTION: &str = "$yoi/default";
|
|
||||||
|
|
||||||
/// Default language policy used by the main worker for normal prose
|
/// Default language policy used by the main worker for normal prose
|
||||||
/// responses. See [`crate::EngineManifest::language`].
|
/// responses. See [`crate::EngineManifest::language`].
|
||||||
|
|||||||
@@ -19,8 +19,8 @@ pub use paths::user_profiles_path;
|
|||||||
pub use profile::{
|
pub use profile::{
|
||||||
ProfileDiscovery, ProfileError, ProfileManifestSnapshot, ProfileMetadata, ProfileRegistry,
|
ProfileDiscovery, ProfileError, ProfileManifestSnapshot, ProfileMetadata, ProfileRegistry,
|
||||||
ProfileRegistryEntry, ProfileRegistrySource, ProfileResolveOptions, ProfileResolver,
|
ProfileRegistryEntry, ProfileRegistrySource, ProfileResolveOptions, ProfileResolver,
|
||||||
ProfileSelector, ProfileSource, ResolvedProfile, WorkspaceOverrideSnapshot,
|
ProfileSelector, ProfileSource, ResolvedProfile, resolve_profile_artifact,
|
||||||
resolve_profile_artifact, resolve_profile_artifact_value,
|
resolve_profile_artifact_value,
|
||||||
};
|
};
|
||||||
pub use protocol::{Permission, ScopeRule};
|
pub use protocol::{Permission, ScopeRule};
|
||||||
pub use scope::{DelegationScope, Scope, ScopeError, SharedScope};
|
pub use scope::{DelegationScope, Scope, ScopeError, SharedScope};
|
||||||
@@ -500,29 +500,13 @@ pub struct MemoryConfig {
|
|||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
pub struct WorkerMeta {
|
pub struct WorkerMeta {
|
||||||
pub name: String,
|
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.
|
/// Worker-level configuration embedded in the manifest.
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
pub struct EngineManifest {
|
pub struct EngineManifest {
|
||||||
/// Reference to the instruction prompt asset used as the body of
|
/// Exact catalog-root dotted Prompt name (for example `default` or
|
||||||
/// the worker's system prompt. Uses the `PromptLoader` prefix
|
/// `role.coder`).
|
||||||
/// addressing scheme (`$yoi/...`, `$user/...`,
|
|
||||||
/// `$workspace/...`) and is always populated after resolution —
|
|
||||||
/// unset manifests fall through to [`defaults::DEFAULT_INSTRUCTION`].
|
|
||||||
#[serde(default = "default_instruction")]
|
#[serde(default = "default_instruction")]
|
||||||
pub instruction: String,
|
pub instruction: String,
|
||||||
/// Language policy used by the main worker for normal prose responses.
|
/// 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" }
|
auth = { kind = "api_key", file = "/abs/keys/anthropic" }
|
||||||
|
|
||||||
[engine]
|
[engine]
|
||||||
instruction = "$user/reviewer"
|
instruction = "role.reviewer"
|
||||||
max_tokens = 4096
|
max_tokens = 4096
|
||||||
temperature = 0.3
|
temperature = 0.3
|
||||||
top_p = 0.9
|
top_p = 0.9
|
||||||
@@ -995,7 +979,7 @@ permission = "write"
|
|||||||
_ => panic!("expected ApiKey"),
|
_ => panic!("expected ApiKey"),
|
||||||
};
|
};
|
||||||
assert_eq!(file, Some(std::path::Path::new("/abs/keys/anthropic")));
|
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.max_tokens, Some(4096));
|
||||||
assert_eq!(manifest.engine.temperature, Some(0.3));
|
assert_eq!(manifest.engine.temperature, Some(0.3));
|
||||||
assert_eq!(manifest.engine.top_p, Some(0.9));
|
assert_eq!(manifest.engine.top_p, Some(0.9));
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
//! 用途別に三つの base directory を持つ:
|
//! 用途別に三つの base directory を持つ:
|
||||||
//!
|
//!
|
||||||
//! - **`config_dir`** — 人が手で書く / 編集する設定。`profiles.toml`,
|
//! - **`config_dir`** — 人が手で書く / 編集する設定。`profiles.toml`,
|
||||||
//! `providers.toml`, `models.toml`, `prompts/`, `prompts.toml` 等
|
//! `providers.toml`, `models.toml` 等
|
||||||
//! - **`data_dir`** — プログラムが書く永続データ。`sessions/` 等
|
//! - **`data_dir`** — プログラムが書く永続データ。`sessions/` 等
|
||||||
//! - **`secret_data_dir`** — local secret store の読み書き base。既存
|
//! - **`secret_data_dir`** — local secret store の読み書き base。既存
|
||||||
//! secret store は path-derived key を使うため、通常 data とは別に
|
//! 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())
|
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 等の
|
/// `<config_dir>/<file_name>` — providers.toml / models.toml 等の
|
||||||
/// user override ファイル。
|
/// user override ファイル。
|
||||||
pub fn user_catalog_override(file_name: &str) -> Option<PathBuf> {
|
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"))
|
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(
|
fn user_catalog_override_from_config_dir(
|
||||||
config_dir: Option<PathBuf>,
|
config_dir: Option<PathBuf>,
|
||||||
file_name: &str,
|
file_name: &str,
|
||||||
@@ -465,14 +447,6 @@ mod tests {
|
|||||||
user_profiles_path_from_config_dir(config_dir.clone()).unwrap(),
|
user_profiles_path_from_config_dir(config_dir.clone()).unwrap(),
|
||||||
PathBuf::from("/sand/config/profiles.toml")
|
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!(
|
assert_eq!(
|
||||||
user_catalog_override_from_config_dir(config_dir, "providers.toml").unwrap(),
|
user_catalog_override_from_config_dir(config_dir, "providers.toml").unwrap(),
|
||||||
PathBuf::from("/sand/config/providers.toml")
|
PathBuf::from("/sand/config/providers.toml")
|
||||||
|
|||||||
@@ -22,7 +22,6 @@ use crate::{
|
|||||||
|
|
||||||
const PROFILE_FORMAT_V1: &str = "yoi.profile.v1";
|
const PROFILE_FORMAT_V1: &str = "yoi.profile.v1";
|
||||||
const BUILTIN_MODEL_CATALOG: &str = include_str!("../../../resources/models/builtin.toml");
|
const BUILTIN_MODEL_CATALOG: &str = include_str!("../../../resources/models/builtin.toml");
|
||||||
const WORKSPACE_OVERRIDE_LOCAL_FILENAME: &str = "override.local.toml";
|
|
||||||
|
|
||||||
struct BuiltinProfile {
|
struct BuiltinProfile {
|
||||||
name: &'static str,
|
name: &'static str,
|
||||||
@@ -322,10 +321,10 @@ pub struct ProfileDiscovery {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl ProfileDiscovery {
|
impl ProfileDiscovery {
|
||||||
pub fn for_cwd(cwd: &Path) -> Self {
|
pub fn for_cwd(_cwd: &Path) -> Self {
|
||||||
Self {
|
Self {
|
||||||
user_config: paths::user_profiles_path(),
|
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 {
|
pub fn with_sources(user_config: Option<PathBuf>, project_config: Option<PathBuf>) -> Self {
|
||||||
@@ -363,19 +362,6 @@ pub struct ProfileManifestSnapshot {
|
|||||||
pub source: ProfileSource,
|
pub source: ProfileSource,
|
||||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
pub profile: Option<ProfileMetadata>,
|
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)]
|
#[derive(Debug, Clone)]
|
||||||
@@ -495,7 +481,6 @@ impl ProfileResolver {
|
|||||||
.as_deref()
|
.as_deref()
|
||||||
.unwrap_or_else(|| Path::new(".")),
|
.unwrap_or_else(|| Path::new(".")),
|
||||||
)?;
|
)?;
|
||||||
let workspace_override = load_workspace_override_from(&workspace_base)?;
|
|
||||||
let raw_artifact = read_profile_artifact_file(&absolute_path)?;
|
let raw_artifact = read_profile_artifact_file(&absolute_path)?;
|
||||||
resolve_profile_value(
|
resolve_profile_value(
|
||||||
source,
|
source,
|
||||||
@@ -504,7 +489,6 @@ impl ProfileResolver {
|
|||||||
options,
|
options,
|
||||||
raw_artifact.clone(),
|
raw_artifact.clone(),
|
||||||
raw_artifact,
|
raw_artifact,
|
||||||
workspace_override,
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -519,7 +503,6 @@ impl ProfileResolver {
|
|||||||
.as_deref()
|
.as_deref()
|
||||||
.unwrap_or_else(|| Path::new(".")),
|
.unwrap_or_else(|| Path::new(".")),
|
||||||
)?;
|
)?;
|
||||||
let workspace_override = load_workspace_override_from(&workspace_base)?;
|
|
||||||
let raw_artifact = builtin_profile_artifact(label).ok_or_else(|| {
|
let raw_artifact = builtin_profile_artifact(label).ok_or_else(|| {
|
||||||
ProfileError::InvalidProfile(format!("unknown builtin profile artifact `{label}`"))
|
ProfileError::InvalidProfile(format!("unknown builtin profile artifact `{label}`"))
|
||||||
})?;
|
})?;
|
||||||
@@ -530,7 +513,6 @@ impl ProfileResolver {
|
|||||||
options,
|
options,
|
||||||
raw_artifact.clone(),
|
raw_artifact.clone(),
|
||||||
raw_artifact,
|
raw_artifact,
|
||||||
workspace_override,
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -542,7 +524,6 @@ fn resolve_profile_value(
|
|||||||
options: ProfileResolveOptions,
|
options: ProfileResolveOptions,
|
||||||
value: serde_json::Value,
|
value: serde_json::Value,
|
||||||
raw_artifact: serde_json::Value,
|
raw_artifact: serde_json::Value,
|
||||||
workspace_override: Option<WorkspaceOverrideLayer>,
|
|
||||||
) -> Result<ResolvedProfile, ProfileError> {
|
) -> Result<ResolvedProfile, ProfileError> {
|
||||||
if !workspace_base.is_absolute() {
|
if !workspace_base.is_absolute() {
|
||||||
return Err(ProfileError::InvalidPath {
|
return Err(ProfileError::InvalidPath {
|
||||||
@@ -566,7 +547,6 @@ fn resolve_profile_value(
|
|||||||
let config = WorkerManifestConfig {
|
let config = WorkerManifestConfig {
|
||||||
worker: WorkerMetaConfig {
|
worker: WorkerMetaConfig {
|
||||||
name: Some(worker_name),
|
name: Some(worker_name),
|
||||||
prompt_pack: None,
|
|
||||||
},
|
},
|
||||||
model: profile.model.unwrap_or_default(),
|
model: profile.model.unwrap_or_default(),
|
||||||
engine: profile.engine.unwrap_or_default(),
|
engine: profile.engine.unwrap_or_default(),
|
||||||
@@ -585,29 +565,11 @@ fn resolve_profile_value(
|
|||||||
memory: profile.memory,
|
memory: profile.memory,
|
||||||
skills: profile.skills,
|
skills: profile.skills,
|
||||||
};
|
};
|
||||||
let mut config =
|
let config = WorkerManifestConfig::builtin_defaults().merge(config.resolve_paths(profile_dir));
|
||||||
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 mut manifest = WorkerManifest::try_from(config).map_err(ProfileError::ManifestResolve)?;
|
let mut manifest = WorkerManifest::try_from(config).map_err(ProfileError::ManifestResolve)?;
|
||||||
manifest.profile = Some(ProfileManifestSnapshot {
|
manifest.profile = Some(ProfileManifestSnapshot {
|
||||||
source: source.clone(),
|
source: source.clone(),
|
||||||
profile: profile_meta.clone(),
|
profile: profile_meta.clone(),
|
||||||
workspace_override: workspace_override_snapshot,
|
|
||||||
});
|
});
|
||||||
let manifest_snapshot =
|
let manifest_snapshot =
|
||||||
serde_json::to_value(&manifest).map_err(ProfileError::SnapshotSerialize)?;
|
serde_json::to_value(&manifest).map_err(ProfileError::SnapshotSerialize)?;
|
||||||
@@ -751,70 +713,6 @@ fn load_profile_registry_file(
|
|||||||
Ok(())
|
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) {
|
fn add_builtin_profiles(registry: &mut ProfileRegistry) {
|
||||||
for profile in BUILTIN_PROFILES {
|
for profile in BUILTIN_PROFILES {
|
||||||
registry.push_entry(ProfileRegistryEntry::embedded(
|
registry.push_entry(ProfileRegistryEntry::embedded(
|
||||||
@@ -1285,7 +1183,6 @@ pub fn resolve_profile_artifact_value(
|
|||||||
ProfileResolveOptions::with_worker_name(worker_name),
|
ProfileResolveOptions::with_worker_name(worker_name),
|
||||||
raw_artifact.clone(),
|
raw_artifact.clone(),
|
||||||
raw_artifact,
|
raw_artifact,
|
||||||
None,
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1313,20 +1210,6 @@ pub enum ProfileError {
|
|||||||
#[source]
|
#[source]
|
||||||
source: toml::de::Error,
|
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")]
|
#[error("no default profile is configured")]
|
||||||
NoDefaultProfile,
|
NoDefaultProfile,
|
||||||
#[error("profile resolution requires an explicit runtime Worker name")]
|
#[error("profile resolution requires an explicit runtime Worker name")]
|
||||||
@@ -1574,27 +1457,15 @@ mod tests {
|
|||||||
assert!(reviewer.compaction.is_some());
|
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]
|
#[test]
|
||||||
fn profile_resolution_requires_runtime_worker_name() {
|
fn profile_resolution_requires_runtime_worker_name() {
|
||||||
let tmp = TempDir::new().unwrap();
|
let tmp = TempDir::new().unwrap();
|
||||||
let err = ProfileResolver::new()
|
let err = ProfileResolver::new()
|
||||||
.with_workspace_base(tmp.path())
|
.with_workspace_base(tmp.path())
|
||||||
.resolve(&ProfileSelector::Default, ProfileResolveOptions::default())
|
.resolve(
|
||||||
|
&ProfileSelector::source_named(ProfileRegistrySource::Builtin, "companion"),
|
||||||
|
ProfileResolveOptions::default(),
|
||||||
|
)
|
||||||
.unwrap_err();
|
.unwrap_err();
|
||||||
assert!(matches!(err, ProfileError::MissingRuntimeWorkerName));
|
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]
|
#[test]
|
||||||
fn unsupported_profile_extension_has_clear_diagnostic() {
|
fn unsupported_profile_extension_has_clear_diagnostic() {
|
||||||
let tmp = TempDir::new().unwrap();
|
let tmp = TempDir::new().unwrap();
|
||||||
@@ -2003,7 +1746,7 @@ language = "nested"
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
#[test]
|
#[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 tmp = TempDir::new().unwrap();
|
||||||
let user_config = tmp.path().join("profiles.toml");
|
let user_config = tmp.path().join("profiles.toml");
|
||||||
let project_dir = tmp.path().join("project/.yoi");
|
let project_dir = tmp.path().join("project/.yoi");
|
||||||
|
|||||||
@@ -1047,19 +1047,19 @@ worktree_name = "custom-orchestrator"
|
|||||||
|
|
||||||
[ticket.roles.intake]
|
[ticket.roles.intake]
|
||||||
profile = "project:intake"
|
profile = "project:intake"
|
||||||
launch_prompt = "$workspace/ticket/intake/launch"
|
launch_prompt = "ticket.intake.launch"
|
||||||
|
|
||||||
[ticket.roles.orchestrator]
|
[ticket.roles.orchestrator]
|
||||||
profile = "project:orchestrator"
|
profile = "project:orchestrator"
|
||||||
launch_prompt = "$workspace/ticket/orchestrator/launch"
|
launch_prompt = "ticket.orchestrator.launch"
|
||||||
|
|
||||||
[ticket.roles.coder]
|
[ticket.roles.coder]
|
||||||
profile = "inherit"
|
profile = "inherit"
|
||||||
launch_prompt = "$workspace/ticket/coder/launch"
|
launch_prompt = "ticket.coder.launch"
|
||||||
|
|
||||||
[ticket.roles.reviewer]
|
[ticket.roles.reviewer]
|
||||||
profile = "project: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)
|
.launch_prompt_for(TicketRole::Reviewer)
|
||||||
.unwrap()
|
.unwrap()
|
||||||
.as_str(),
|
.as_str(),
|
||||||
"$workspace/ticket/reviewer/launch"
|
"ticket.reviewer.launch"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1338,7 +1338,7 @@ profile = "builtin:companion"
|
|||||||
r#"
|
r#"
|
||||||
[roles.coder]
|
[roles.coder]
|
||||||
profile = "inherit"
|
profile = "inherit"
|
||||||
system_instruction = "$workspace/not-supported"
|
system_instruction = "unsupported"
|
||||||
"#,
|
"#,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ session-store = { workspace = true }
|
|||||||
fs4 = { workspace = true }
|
fs4 = { workspace = true }
|
||||||
ticket = { workspace = true }
|
ticket = { workspace = true }
|
||||||
serde = { workspace = true, features = ["derive"] }
|
serde = { workspace = true, features = ["derive"] }
|
||||||
minijinja = "2.19.0"
|
worker = { path = "../worker" }
|
||||||
pulldown-cmark = { version = "0.13.3", default-features = false }
|
pulldown-cmark = { version = "0.13.3", default-features = false }
|
||||||
llm-engine.workspace = true
|
llm-engine.workspace = true
|
||||||
|
|
||||||
|
|||||||
@@ -69,8 +69,7 @@ use render::{PanelListRow, row_hit_boxes};
|
|||||||
|
|
||||||
const MAX_ENTRIES: usize = 50;
|
const MAX_ENTRIES: usize = 50;
|
||||||
const CLOSED_VISIBLE_ROWS: usize = 3;
|
const CLOSED_VISIBLE_ROWS: usize = 3;
|
||||||
const ORCHESTRATOR_IDLE_QUEUE_NOTICE_TEMPLATE: &str =
|
const ORCHESTRATOR_IDLE_QUEUE_NOTICE_PROMPT: &str = "panel.orchestrator_idle_queue_notice";
|
||||||
include_str!("../../../../resources/prompts/panel/orchestrator_idle_queue_notice.md");
|
|
||||||
const ORCHESTRATOR_QUEUE_ATTENTION_MAX_TICKETS: usize = 6;
|
const ORCHESTRATOR_QUEUE_ATTENTION_MAX_TICKETS: usize = 6;
|
||||||
const ORCHESTRATOR_QUEUE_ATTENTION_MAX_TEXT_CHARS: usize = 120;
|
const ORCHESTRATOR_QUEUE_ATTENTION_MAX_TEXT_CHARS: usize = 120;
|
||||||
const ORCHESTRATOR_QUEUE_ATTENTION_MAX_MESSAGE_CHARS: usize = 2_400;
|
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(
|
fn render_orchestrator_queue_attention_template(
|
||||||
context: &OrchestratorQueueTemplateContext,
|
context: &OrchestratorQueueTemplateContext,
|
||||||
) -> Result<String, minijinja::Error> {
|
) -> Result<String, worker::CatalogError> {
|
||||||
let mut env = minijinja::Environment::new();
|
worker::PromptCatalog::builtins_only()?
|
||||||
env.set_undefined_behavior(minijinja::UndefinedBehavior::Strict);
|
.render_serializable(ORCHESTRATOR_IDLE_QUEUE_NOTICE_PROMPT, context)
|
||||||
env.add_template(
|
|
||||||
"orchestrator_idle_queue_notice",
|
|
||||||
ORCHESTRATOR_IDLE_QUEUE_NOTICE_TEMPLATE,
|
|
||||||
)?;
|
|
||||||
env.get_template("orchestrator_idle_queue_notice")?
|
|
||||||
.render(context)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn orchestrator_work_set_detail(
|
fn orchestrator_work_set_detail(
|
||||||
|
|||||||
+10
-50
@@ -1,7 +1,7 @@
|
|||||||
//! Inline-viewport "spawn Worker and attach" UX.
|
//! Inline-viewport "spawn Worker and attach" UX.
|
||||||
//!
|
//!
|
||||||
//! Rendered at the user's current cursor position when `yoi` is invoked
|
//! 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
|
//! 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
|
//! the Worker's name, and on confirmation launches the Worker runtime command as an
|
||||||
//! independent process. Once the process reports its socket via the
|
//! independent process. Once the process reports its socket via the
|
||||||
@@ -654,68 +654,28 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn profile_choices_use_project_registry_default() {
|
fn profile_choices_ignore_repository_local_profile_registry() {
|
||||||
let temp = tempfile::tempdir().unwrap();
|
let temp = tempfile::tempdir().unwrap();
|
||||||
let project = temp.path().join("project");
|
let project = temp.path().join("project");
|
||||||
let yoi = project.join(".yoi");
|
let yoi = project.join(".yoi");
|
||||||
std::fs::create_dir_all(&yoi).unwrap();
|
std::fs::create_dir_all(&yoi).unwrap();
|
||||||
std::fs::write(
|
std::fs::write(
|
||||||
yoi.join("profiles.toml"),
|
yoi.join("profiles.toml"),
|
||||||
r#"
|
"default = \"coder\"\n[profile]\ncoder = \"profiles/coder.toml\"\n",
|
||||||
default = "coder"
|
|
||||||
[profile]
|
|
||||||
coder = "profiles/coder.toml"
|
|
||||||
"#,
|
|
||||||
)
|
)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
|
|
||||||
let (choices, default_index) = profile_choices_for_cwd(&project);
|
let (choices, default_index) = profile_choices_for_cwd(&project);
|
||||||
let default_choice = choices
|
assert_eq!(default_index, 0);
|
||||||
|
assert!(
|
||||||
|
choices
|
||||||
.iter()
|
.iter()
|
||||||
.position(|choice| choice.selector.as_deref() == Some("project:coder"))
|
.all(|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"
|
|
||||||
);
|
);
|
||||||
let project_index = choices
|
assert!(
|
||||||
|
choices
|
||||||
.iter()
|
.iter()
|
||||||
.position(|choice| choice.selector.as_deref() == Some("project:coder"))
|
.any(|choice| { choice.selector.as_deref() == Some("builtin:companion") })
|
||||||
.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"
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -24,6 +24,8 @@ pub struct ConfigBundle {
|
|||||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||||
pub declarations: Vec<ConfigDeclaration>,
|
pub declarations: Vec<ConfigDeclaration>,
|
||||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
#[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>,
|
pub profile_source_archive: Option<ProfileSourceArchive>,
|
||||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
pub profile_source_archive_handle: Option<BackendResourceHandle>,
|
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 {
|
if let Some(archive) = &self.profile_source_archive {
|
||||||
lines.push(format!(
|
lines.push(format!(
|
||||||
"profile_archive\0{}\0{}\0{}",
|
"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)?;
|
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 {
|
if let Some(archive) = &bundle.profile_source_archive {
|
||||||
validate_profile_source_archive_ref(&archive.reference).map_err(|err| {
|
validate_profile_source_archive_ref(&archive.reference).map_err(|err| {
|
||||||
RuntimeError::InvalidRequest(format!("invalid profile source archive: {err}"))
|
RuntimeError::InvalidRequest(format!("invalid profile source archive: {err}"))
|
||||||
@@ -582,6 +600,7 @@ mod tests {
|
|||||||
name: "credential".to_string(),
|
name: "credential".to_string(),
|
||||||
reference: reference.to_string(),
|
reference: reference.to_string(),
|
||||||
}],
|
}],
|
||||||
|
prompt_catalog: None,
|
||||||
profile_source_archive: None,
|
profile_source_archive: None,
|
||||||
profile_source_archive_handle: None,
|
profile_source_archive_handle: None,
|
||||||
}
|
}
|
||||||
@@ -615,6 +634,32 @@ mod tests {
|
|||||||
validate_config_bundle(&bundle_with_declaration("vault:team.api-key")).unwrap();
|
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]
|
#[test]
|
||||||
fn bundle_summary_redacts_runtime_internal_resource_handle() {
|
fn bundle_summary_redacts_runtime_internal_resource_handle() {
|
||||||
let mut bundle = bundle_with_declaration("secret:github-token");
|
let mut bundle = bundle_with_declaration("secret:github-token");
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
use crate::catalog::{CreateWorkerRequest, WorkingDirectoryStatus};
|
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::diagnostics::{DiagnosticSeverity, RuntimeDiagnostic};
|
||||||
use crate::error::RuntimeError;
|
use crate::error::RuntimeError;
|
||||||
use crate::identity::{WorkerId, WorkerRef};
|
use crate::identity::{WorkerId, WorkerRef};
|
||||||
@@ -324,6 +324,13 @@ impl RuntimeSnapshot {
|
|||||||
message: format!("runtime snapshot backend is {:?}", self.backend),
|
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(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1811,12 +1811,21 @@ mod tests {
|
|||||||
label: Some("test".to_string()),
|
label: Some("test".to_string()),
|
||||||
}],
|
}],
|
||||||
declarations: Vec::new(),
|
declarations: Vec::new(),
|
||||||
|
prompt_catalog: None,
|
||||||
profile_source_archive: None,
|
profile_source_archive: None,
|
||||||
profile_source_archive_handle: None,
|
profile_source_archive_handle: None,
|
||||||
}
|
}
|
||||||
.with_computed_digest()
|
.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 {
|
fn scoped_task_request(objective: &str, workspace_id: &str) -> CreateWorkerRequest {
|
||||||
let mut request = task_request(objective);
|
let mut request = task_request(objective);
|
||||||
request.workspace_api = Some(WorkspaceApiRef {
|
request.workspace_api = Some(WorkspaceApiRef {
|
||||||
@@ -1922,6 +1931,7 @@ mod tests {
|
|||||||
let runtime =
|
let runtime =
|
||||||
Runtime::with_execution_backend(RuntimeOptions::default(), Arc::new(AcceptingBackend))
|
Runtime::with_execution_backend(RuntimeOptions::default(), Arc::new(AcceptingBackend))
|
||||||
.unwrap();
|
.unwrap();
|
||||||
|
store_coder_test_bundle(&runtime);
|
||||||
let (auth, signer) = auth_config_and_signer();
|
let (auth, signer) = auth_config_and_signer();
|
||||||
let token_a = token_for_workspace(&signer, "workspace-a");
|
let token_a = token_for_workspace(&signer, "workspace-a");
|
||||||
let token_b = token_for_workspace(&signer, "workspace-b");
|
let token_b = token_for_workspace(&signer, "workspace-b");
|
||||||
@@ -2002,6 +2012,7 @@ mod tests {
|
|||||||
let runtime =
|
let runtime =
|
||||||
Runtime::with_execution_backend(RuntimeOptions::default(), Arc::new(AcceptingBackend))
|
Runtime::with_execution_backend(RuntimeOptions::default(), Arc::new(AcceptingBackend))
|
||||||
.unwrap();
|
.unwrap();
|
||||||
|
store_coder_test_bundle(&runtime);
|
||||||
let (auth, signer_a, signer_b) = auth_config_and_two_signers();
|
let (auth, signer_a, signer_b) = auth_config_and_two_signers();
|
||||||
let token_a = token_for_workspace(&signer_a, "workspace-a");
|
let token_a = token_for_workspace(&signer_a, "workspace-a");
|
||||||
let token_b = token_for_workspace(&signer_b, "workspace-a");
|
let token_b = token_for_workspace(&signer_b, "workspace-a");
|
||||||
@@ -2648,6 +2659,7 @@ mod ws_tests {
|
|||||||
label: Some("ws".to_string()),
|
label: Some("ws".to_string()),
|
||||||
}],
|
}],
|
||||||
declarations: Vec::new(),
|
declarations: Vec::new(),
|
||||||
|
prompt_catalog: None,
|
||||||
profile_source_archive: None,
|
profile_source_archive: None,
|
||||||
profile_source_archive_handle: None,
|
profile_source_archive_handle: None,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -743,10 +743,10 @@ mod tests {
|
|||||||
.load(Some("profiles/main.dcdl"), "./shared.dcdl")
|
.load(Some("profiles/main.dcdl"), "./shared.dcdl")
|
||||||
.unwrap();
|
.unwrap();
|
||||||
match loaded {
|
match loaded {
|
||||||
LoadedImport::Source(source) => {
|
LoadedImport::Source { key, .. } => {
|
||||||
assert_eq!(source.key, "profiles/shared.dcdl");
|
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(),
|
digest: bundle.metadata.digest.clone(),
|
||||||
};
|
};
|
||||||
let summary = bundle.summary();
|
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
|
state
|
||||||
.config_bundles
|
.config_bundles
|
||||||
.insert(bundle.metadata.id.clone(), bundle);
|
.insert(bundle.metadata.id.clone(), bundle);
|
||||||
@@ -526,6 +539,7 @@ impl Runtime {
|
|||||||
message: "worker creation requires an execution backend".to_string(),
|
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);
|
let worker_id = WorkerId::generated(state.next_worker_sequence);
|
||||||
state.next_worker_sequence += 1;
|
state.next_worker_sequence += 1;
|
||||||
@@ -551,7 +565,7 @@ impl Runtime {
|
|||||||
workspace_scope: scope.cloned(),
|
workspace_scope: scope.cloned(),
|
||||||
context: self.execution_context(worker_ref.clone()),
|
context: self.execution_context(worker_ref.clone()),
|
||||||
working_directory: None,
|
working_directory: None,
|
||||||
config_bundle: None,
|
config_bundle,
|
||||||
};
|
};
|
||||||
(backend, worker_ref, spawn_request)
|
(backend, worker_ref, spawn_request)
|
||||||
};
|
};
|
||||||
@@ -868,7 +882,7 @@ impl Runtime {
|
|||||||
let (backend, request) = {
|
let (backend, request) = {
|
||||||
let mut state = self.lock()?;
|
let mut state = self.lock()?;
|
||||||
state.ensure_running()?;
|
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)?;
|
let worker = state.worker(worker_ref)?;
|
||||||
if worker.execution_handle.is_some() {
|
if worker.execution_handle.is_some() {
|
||||||
return Ok(worker.detail());
|
return Ok(worker.detail());
|
||||||
@@ -879,19 +893,14 @@ impl Runtime {
|
|||||||
worker_ref.worker_id
|
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.request.clone(),
|
||||||
worker.working_directory.clone(),
|
worker.working_directory.clone(),
|
||||||
config_bundle,
|
|
||||||
worker.run_generation.saturating_add(1).max(1),
|
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(|| {
|
let backend = state.execution_backend.clone().ok_or_else(|| {
|
||||||
RuntimeError::WorkerExecutionUnavailable {
|
RuntimeError::WorkerExecutionUnavailable {
|
||||||
worker_id: worker_ref.worker_id.clone(),
|
worker_id: worker_ref.worker_id.clone(),
|
||||||
@@ -1558,31 +1567,20 @@ impl Runtime {
|
|||||||
.collect::<Vec<_>>();
|
.collect::<Vec<_>>();
|
||||||
let mut candidates = Vec::with_capacity(worker_ids.len());
|
let mut candidates = Vec::with_capacity(worker_ids.len());
|
||||||
for worker_id in worker_ids {
|
for worker_id in worker_ids {
|
||||||
let (
|
let (worker_ref, request, previous_working_directory, run_generation) = {
|
||||||
worker_ref,
|
|
||||||
request,
|
|
||||||
previous_working_directory,
|
|
||||||
config_bundle,
|
|
||||||
run_generation,
|
|
||||||
) = {
|
|
||||||
let worker = state
|
let worker = state
|
||||||
.workers
|
.workers
|
||||||
.get(&worker_id)
|
.get(&worker_id)
|
||||||
.expect("collected Worker exists");
|
.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.worker_ref.clone(),
|
||||||
worker.request.clone(),
|
worker.request.clone(),
|
||||||
worker.working_directory.clone(),
|
worker.working_directory.clone(),
|
||||||
config_bundle,
|
|
||||||
worker.run_generation.saturating_add(1).max(1),
|
worker.run_generation.saturating_add(1).max(1),
|
||||||
)
|
)
|
||||||
};
|
};
|
||||||
|
let config_bundle =
|
||||||
|
state.resolve_config_bundle_ref(request.config_bundle.as_ref())?;
|
||||||
state
|
state
|
||||||
.workers
|
.workers
|
||||||
.get_mut(&worker_id)
|
.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(
|
fn validate_worker_config_boundary(
|
||||||
&self,
|
&self,
|
||||||
_request: &CreateWorkerRequest,
|
_request: &CreateWorkerRequest,
|
||||||
@@ -2831,6 +2840,7 @@ mod tests {
|
|||||||
name: "read".to_string(),
|
name: "read".to_string(),
|
||||||
reference: "capability:read".to_string(),
|
reference: "capability:read".to_string(),
|
||||||
}],
|
}],
|
||||||
|
prompt_catalog: None,
|
||||||
profile_source_archive: None,
|
profile_source_archive: None,
|
||||||
profile_source_archive_handle: None,
|
profile_source_archive_handle: None,
|
||||||
}
|
}
|
||||||
@@ -2843,6 +2853,7 @@ mod tests {
|
|||||||
restore_result: Mutex<Option<WorkerExecutionSpawnResult>>,
|
restore_result: Mutex<Option<WorkerExecutionSpawnResult>>,
|
||||||
restore_count: Mutex<u64>,
|
restore_count: Mutex<u64>,
|
||||||
run_generations: Mutex<Vec<u64>>,
|
run_generations: Mutex<Vec<u64>>,
|
||||||
|
config_bundles: Mutex<Vec<Option<ConfigBundle>>>,
|
||||||
contexts: Mutex<BTreeMap<WorkerId, WorkerExecutionContext>>,
|
contexts: Mutex<BTreeMap<WorkerId, WorkerExecutionContext>>,
|
||||||
dispatched_inputs: Mutex<Vec<WorkerInput>>,
|
dispatched_inputs: Mutex<Vec<WorkerInput>>,
|
||||||
preserve_commit_ack_submission_id: AtomicBool,
|
preserve_commit_ack_submission_id: AtomicBool,
|
||||||
@@ -2890,6 +2901,10 @@ mod tests {
|
|||||||
.lock()
|
.lock()
|
||||||
.unwrap()
|
.unwrap()
|
||||||
.push(request.run_generation);
|
.push(request.run_generation);
|
||||||
|
self.config_bundles
|
||||||
|
.lock()
|
||||||
|
.unwrap()
|
||||||
|
.push(request.config_bundle.clone());
|
||||||
self.contexts
|
self.contexts
|
||||||
.lock()
|
.lock()
|
||||||
.unwrap()
|
.unwrap()
|
||||||
@@ -2913,6 +2928,10 @@ mod tests {
|
|||||||
.lock()
|
.lock()
|
||||||
.unwrap()
|
.unwrap()
|
||||||
.push(request.run_generation);
|
.push(request.run_generation);
|
||||||
|
self.config_bundles
|
||||||
|
.lock()
|
||||||
|
.unwrap()
|
||||||
|
.push(request.config_bundle.clone());
|
||||||
if let Some(result) = self.restore_result.lock().unwrap().clone() {
|
if let Some(result) = self.restore_result.lock().unwrap().clone() {
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
@@ -3409,11 +3428,30 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn synced_config_bundle_is_stored_checked_and_used_for_worker_creation() {
|
fn synced_config_bundle_is_stored_checked_and_used_for_worker_creation() {
|
||||||
let runtime = runtime_with_backend();
|
let backend = Arc::new(TestExecutionBackend::default());
|
||||||
let bundle = test_bundle();
|
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();
|
let availability = runtime.store_config_bundle(bundle.clone()).unwrap();
|
||||||
assert_eq!(availability.reference.id, "bundle-1");
|
assert_eq!(availability.reference.id, "bundle-1");
|
||||||
assert_eq!(availability.reference.digest, bundle.metadata.digest);
|
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();
|
let listed = runtime.list_config_bundles().unwrap();
|
||||||
assert_eq!(listed.len(), 1);
|
assert_eq!(listed.len(), 1);
|
||||||
@@ -3428,6 +3466,53 @@ mod tests {
|
|||||||
.create_worker(bundled_task_request("synced", &bundle))
|
.create_worker(bundled_task_request("synced", &bundle))
|
||||||
.unwrap();
|
.unwrap();
|
||||||
assert_eq!(detail.config_bundle, Some(availability.reference));
|
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]
|
#[test]
|
||||||
|
|||||||
@@ -53,7 +53,7 @@ use worker::feature::builtin::{
|
|||||||
#[cfg(feature = "ws-server")]
|
#[cfg(feature = "ws-server")]
|
||||||
use worker::ipc::protocol_session::{live_log_entry_event, subscribe_worker_protocol_session};
|
use worker::ipc::protocol_session::{live_log_entry_event, subscribe_worker_protocol_session};
|
||||||
use worker::{
|
use worker::{
|
||||||
PromptLoader, SegmentLogSink, WORKER_INPUT_SUBMISSION_EXTENSION_DOMAIN, Worker,
|
PromptCatalogSource, SegmentLogSink, WORKER_INPUT_SUBMISSION_EXTENSION_DOMAIN, Worker,
|
||||||
WorkerController, WorkerControllerTransport, WorkerError, WorkerFilesystemAuthority,
|
WorkerController, WorkerControllerTransport, WorkerError, WorkerFilesystemAuthority,
|
||||||
WorkerHandle, WorkerSharedState, WorkerWorkspaceContext, WorkspaceClient, WorkspaceId,
|
WorkerHandle, WorkerSharedState, WorkerWorkspaceContext, WorkspaceClient, WorkspaceId,
|
||||||
};
|
};
|
||||||
@@ -329,12 +329,12 @@ impl ProfileRuntimeWorkerFactory {
|
|||||||
|
|
||||||
fn restore_fallback_manifest(
|
fn restore_fallback_manifest(
|
||||||
worker_name: &str,
|
worker_name: &str,
|
||||||
) -> Result<(manifest::WorkerManifest, PromptLoader), String> {
|
) -> Result<(manifest::WorkerManifest, PromptCatalogSource), String> {
|
||||||
let mut config = manifest::WorkerManifestConfig::builtin_defaults();
|
let mut config = manifest::WorkerManifestConfig::builtin_defaults();
|
||||||
config.worker.name = Some(worker_name.to_string());
|
config.worker.name = Some(worker_name.to_string());
|
||||||
let manifest = manifest::WorkerManifest::try_from(config)
|
let manifest = manifest::WorkerManifest::try_from(config)
|
||||||
.map_err(|err| format!("failed to build restore fallback manifest: {err}"))?;
|
.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(
|
async fn resolve_profile_source_archive(
|
||||||
&self,
|
&self,
|
||||||
@@ -566,7 +566,7 @@ impl RuntimeWorkerFactory for ProfileRuntimeWorkerFactory {
|
|||||||
let archive = self
|
let archive = self
|
||||||
.resolve_profile_source_archive(&request.request.profile_source)
|
.resolve_profile_source_archive(&request.request.profile_source)
|
||||||
.await?;
|
.await?;
|
||||||
let (manifest, loader) = {
|
let (manifest, mut loader) = {
|
||||||
let manifest = archive
|
let manifest = archive
|
||||||
.resolve_profile(selector, &worker_root, &worker_name)
|
.resolve_profile(selector, &worker_root, &worker_name)
|
||||||
.map_err(|err| format!("failed to resolve profile source archive: {err}"))?;
|
.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 flow_transition_enabled = manifest.feature.flow.enabled;
|
||||||
|
|
||||||
let worker_aggregate_dir = self.worker_aggregate_dir(&request.worker_ref)?;
|
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.worker_mutation_identity.as_ref(),
|
||||||
self.embedded_worker_mutation_dispatcher.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 worker_aggregate_dir = self.worker_aggregate_dir(&request.worker_ref)?;
|
||||||
let session_dir = worker_aggregate_dir.join("session");
|
let session_dir = worker_aggregate_dir.join("session");
|
||||||
@@ -2090,6 +2104,7 @@ mod tests {
|
|||||||
label: Some("adapter-test".to_string()),
|
label: Some("adapter-test".to_string()),
|
||||||
}],
|
}],
|
||||||
declarations: Vec::new(),
|
declarations: Vec::new(),
|
||||||
|
prompt_catalog: None,
|
||||||
profile_source_archive: Some(sample_profile_archive()),
|
profile_source_archive: Some(sample_profile_archive()),
|
||||||
profile_source_archive_handle: None,
|
profile_source_archive_handle: None,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ tools = { workspace = true }
|
|||||||
workdir = { workspace = true }
|
workdir = { workspace = true }
|
||||||
minijinja = "2.19.0"
|
minijinja = "2.19.0"
|
||||||
chrono = "0.4"
|
chrono = "0.4"
|
||||||
|
config-source = { path = "../config-source" }
|
||||||
include_dir = "0.7.4"
|
include_dir = "0.7.4"
|
||||||
fs4 = { workspace = true, features = ["sync"] }
|
fs4 = { workspace = true, features = ["sync"] }
|
||||||
flow = { path = "../flow" }
|
flow = { path = "../flow" }
|
||||||
@@ -51,6 +52,3 @@ serial_test = "3.4.0"
|
|||||||
tempfile = { workspace = true }
|
tempfile = { workspace = true }
|
||||||
wat = "1.241.2"
|
wat = "1.241.2"
|
||||||
yoi-plugin-pdk = { workspace = true }
|
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() {
|
fn main() {
|
||||||
let manifest_dir = env::var("CARGO_MANIFEST_DIR").expect("CARGO_MANIFEST_DIR");
|
println!("cargo:rerun-if-changed=../../resources/prompts");
|
||||||
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()));
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,7 +3,8 @@ use std::path::{Path, PathBuf};
|
|||||||
use std::process::ExitCode;
|
use std::process::ExitCode;
|
||||||
|
|
||||||
use crate::{
|
use crate::{
|
||||||
PromptLoader, Worker, WorkerController, WorkerFilesystemAuthority, WorkerWorkspaceContext,
|
PromptCatalogSource, Worker, WorkerController, WorkerFilesystemAuthority,
|
||||||
|
WorkerWorkspaceContext,
|
||||||
};
|
};
|
||||||
use clap::{CommandFactory, FromArgMatches, Parser};
|
use clap::{CommandFactory, FromArgMatches, Parser};
|
||||||
use manifest::{Permission, ScopeConfig, ScopeRule, WorkerManifest, WorkerManifestConfig, paths};
|
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 process_root = runtime_workspace_root(cli)?;
|
||||||
let runtime_worker_name = runtime_worker_name(cli, &process_root);
|
let runtime_worker_name = runtime_worker_name(cli, &process_root);
|
||||||
let ((mut manifest, loader), apply_direct_launch_policy) = if let Some(config_json) =
|
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(())
|
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)
|
let config = serde_json::from_str::<WorkerManifestConfig>(config_json)
|
||||||
.map_err(|e| format!("failed to parse --spawn-config-json: {e}"))?;
|
.map_err(|e| format!("failed to parse --spawn-config-json: {e}"))?;
|
||||||
let manifest = WorkerManifest::try_from(WorkerManifestConfig::builtin_defaults().merge(config))
|
let manifest = WorkerManifest::try_from(WorkerManifestConfig::builtin_defaults().merge(config))
|
||||||
.map_err(|e| format!("failed to resolve --spawn-config-json: {e}"))?;
|
.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(
|
fn load_builtin_default_manifest(
|
||||||
worker_name: &str,
|
worker_name: &str,
|
||||||
) -> Result<(WorkerManifest, PromptLoader), String> {
|
) -> Result<(WorkerManifest, PromptCatalogSource), String> {
|
||||||
let mut config = WorkerManifestConfig::builtin_defaults();
|
let mut config = WorkerManifestConfig::builtin_defaults();
|
||||||
config.worker.name = Some(worker_name.to_string());
|
config.worker.name = Some(worker_name.to_string());
|
||||||
let manifest = WorkerManifest::try_from(config)
|
let manifest = WorkerManifest::try_from(config)
|
||||||
.map_err(|e| format!("failed to resolve builtin worker defaults: {e}"))?;
|
.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(
|
pub fn resolve_runtime_profile_manifest(
|
||||||
_profile: Option<&str>,
|
_profile: Option<&str>,
|
||||||
_workspace_root: &Path,
|
_workspace_root: &Path,
|
||||||
_worker_name: &str,
|
_worker_name: &str,
|
||||||
) -> Result<(WorkerManifest, PromptLoader), String> {
|
) -> Result<(WorkerManifest, PromptCatalogSource), String> {
|
||||||
Err(
|
Err(
|
||||||
"runtime profile resolution requires a pre-resolved manifest/profile archive from Backend authority"
|
"runtime profile resolution requires a pre-resolved manifest/profile archive from Backend authority"
|
||||||
.to_string(),
|
.to_string(),
|
||||||
@@ -211,7 +214,7 @@ pub fn resolve_runtime_profile_manifest_from_manifest(
|
|||||||
mut manifest: WorkerManifest,
|
mut manifest: WorkerManifest,
|
||||||
workspace_root: &Path,
|
workspace_root: &Path,
|
||||||
worker_name: &str,
|
worker_name: &str,
|
||||||
) -> Result<(WorkerManifest, PromptLoader), String> {
|
) -> Result<(WorkerManifest, PromptCatalogSource), String> {
|
||||||
if manifest.worker.name.is_empty() {
|
if manifest.worker.name.is_empty() {
|
||||||
manifest.worker.name = worker_name.to_string();
|
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
|
// Do not run plugin discovery here: runtime-created Workers receive their
|
||||||
// resolved manifest/profile archive from Backend authority, not by scanning
|
// resolved manifest/profile archive from Backend authority, not by scanning
|
||||||
// materialized workdir-local plugin stores.
|
// 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(
|
pub fn resolve_runtime_profile_manifest_from_manifest_without_filesystem(
|
||||||
mut manifest: WorkerManifest,
|
mut manifest: WorkerManifest,
|
||||||
_workspace_root: &Path,
|
_workspace_root: &Path,
|
||||||
worker_name: &str,
|
worker_name: &str,
|
||||||
) -> Result<(WorkerManifest, PromptLoader), String> {
|
) -> Result<(WorkerManifest, PromptCatalogSource), String> {
|
||||||
if manifest.worker.name.is_empty() {
|
if manifest.worker.name.is_empty() {
|
||||||
manifest.worker.name = worker_name.to_string();
|
manifest.worker.name = worker_name.to_string();
|
||||||
}
|
}
|
||||||
manifest.scope = ScopeConfig::default();
|
manifest.scope = ScopeConfig::default();
|
||||||
manifest.delegation_scope = ScopeConfig::default();
|
manifest.delegation_scope = ScopeConfig::default();
|
||||||
// Same as the filesystem-capable runtime path: no local discovery.
|
// Same as the filesystem-capable runtime path: no local discovery.
|
||||||
Ok((manifest, PromptLoader::builtins_only()))
|
Ok((manifest, PromptCatalogSource::builtins_only()))
|
||||||
}
|
}
|
||||||
|
|
||||||
fn load_single_manifest(
|
fn load_single_manifest(
|
||||||
path: &Path,
|
path: &Path,
|
||||||
explicit_worker_name: Option<&str>,
|
explicit_worker_name: Option<&str>,
|
||||||
default_worker_name: &str,
|
default_worker_name: &str,
|
||||||
) -> Result<(WorkerManifest, PromptLoader), String> {
|
) -> Result<(WorkerManifest, PromptCatalogSource), String> {
|
||||||
let toml = std::fs::read_to_string(path)
|
let toml = std::fs::read_to_string(path)
|
||||||
.map_err(|e| format!("failed to read manifest {}: {e}", path.display()))?;
|
.map_err(|e| format!("failed to read manifest {}: {e}", path.display()))?;
|
||||||
let absolute_path = if path.is_absolute() {
|
let absolute_path = if path.is_absolute() {
|
||||||
@@ -274,7 +277,7 @@ fn load_single_manifest(
|
|||||||
path.display()
|
path.display()
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
Ok((manifest, PromptLoader::builtins_only()))
|
Ok((manifest, PromptCatalogSource::builtins_only()))
|
||||||
}
|
}
|
||||||
|
|
||||||
fn read_rule(target: PathBuf) -> ScopeRule {
|
fn read_rule(target: PathBuf) -> ScopeRule {
|
||||||
@@ -751,11 +754,9 @@ permission = "write"
|
|||||||
let cli =
|
let cli =
|
||||||
Cli::try_parse_from(["yoi worker", "--manifest", manifest.to_str().unwrap()]).unwrap();
|
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_eq!(manifest.worker.name, "single");
|
||||||
assert!(loader.user_dir().is_none());
|
|
||||||
assert!(loader.workspace_dir().is_none());
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -764,7 +765,7 @@ permission = "write"
|
|||||||
let yoi_dir = tmp.path().join(".yoi");
|
let yoi_dir = tmp.path().join(".yoi");
|
||||||
std::fs::create_dir_all(&yoi_dir).unwrap();
|
std::fs::create_dir_all(&yoi_dir).unwrap();
|
||||||
write(
|
write(
|
||||||
&yoi_dir.join("override.local.toml"),
|
&yoi_dir.join("ignored-local-file.toml"),
|
||||||
r#"
|
r#"
|
||||||
[worker]
|
[worker]
|
||||||
name = "from-local-override"
|
name = "from-local-override"
|
||||||
@@ -822,7 +823,7 @@ permission = "write"
|
|||||||
let yoi_dir = workspace.join(".yoi");
|
let yoi_dir = workspace.join(".yoi");
|
||||||
std::fs::create_dir_all(&yoi_dir).unwrap();
|
std::fs::create_dir_all(&yoi_dir).unwrap();
|
||||||
write(
|
write(
|
||||||
&yoi_dir.join("override.local.toml"),
|
&yoi_dir.join("ignored-local-file.toml"),
|
||||||
r#"
|
r#"
|
||||||
[worker]
|
[worker]
|
||||||
name = "from-local-override"
|
name = "from-local-override"
|
||||||
@@ -834,12 +835,10 @@ language = "override"
|
|||||||
let cli = Cli::try_parse_from(["yoi worker", "--workspace", workspace.to_str().unwrap()])
|
let cli = Cli::try_parse_from(["yoi worker", "--workspace", workspace.to_str().unwrap()])
|
||||||
.unwrap();
|
.unwrap();
|
||||||
|
|
||||||
let (manifest, loader) = resolve_manifest(&cli).unwrap();
|
let (manifest, _loader) = resolve_manifest(&cli).unwrap();
|
||||||
|
|
||||||
assert_eq!(manifest.worker.name, "runtime-workspace");
|
assert_eq!(manifest.worker.name, "runtime-workspace");
|
||||||
assert_ne!(manifest.engine.language, "override");
|
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);
|
assert_scope_contains(&manifest.scope.allow, &workspace, Permission::Write);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1031,12 +1030,8 @@ permission = "write"
|
|||||||
])
|
])
|
||||||
.unwrap();
|
.unwrap();
|
||||||
|
|
||||||
let (manifest, loader) = resolve_manifest(&cli).unwrap();
|
let (manifest, _loader) = resolve_manifest(&cli).unwrap();
|
||||||
|
|
||||||
assert_eq!(manifest.worker.name, "single-file");
|
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]
|
#[test]
|
||||||
fn instruction_contributions_are_deduped_in_registration_order() {
|
fn instruction_contributions_are_deduped_in_registration_order() {
|
||||||
let workflow = instruction("workflow", "$yoi/common/tickets");
|
let workflow = instruction("workflow", "common.tickets");
|
||||||
let orchestration = instruction("orchestration", "$yoi/common/worker-orchestration");
|
let orchestration = instruction("orchestration", "common.worker_orchestration");
|
||||||
let contributions = dedupe_instruction_contributions([
|
let contributions = dedupe_instruction_contributions([
|
||||||
workflow.clone(),
|
workflow.clone(),
|
||||||
orchestration.clone(),
|
orchestration.clone(),
|
||||||
@@ -1871,8 +1871,8 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn undeclared_instruction_contribution_is_rejected() {
|
fn undeclared_instruction_contribution_is_rejected() {
|
||||||
let declared = instruction("declared", "$yoi/common/tickets");
|
let declared = instruction("declared", "common.tickets");
|
||||||
let undeclared = instruction("undeclared", "$yoi/common/tickets");
|
let undeclared = instruction("undeclared", "common.tickets");
|
||||||
let descriptor =
|
let descriptor =
|
||||||
FeatureDescriptor::builtin("instruction", "Instruction").with_instruction(declared);
|
FeatureDescriptor::builtin("instruction", "Instruction").with_instruction(declared);
|
||||||
let mut hook_builder = HookRegistryBuilder::default();
|
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. \
|
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.";
|
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_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";
|
pub const TICKET_SERVICE_ID: &str = "ticket.authority";
|
||||||
const TICKET_SERVICE_VERSION: &str = "1";
|
const TICKET_SERVICE_VERSION: &str = "1";
|
||||||
|
|
||||||
|
|||||||
@@ -23,10 +23,7 @@ const DEFAULT_PAGE_LIMIT: usize = 20;
|
|||||||
const MAX_PAGE_LIMIT: usize = 100;
|
const MAX_PAGE_LIMIT: usize = 100;
|
||||||
const MAX_READ_BYTES: usize = 16 * 1024;
|
const MAX_READ_BYTES: usize = 16 * 1024;
|
||||||
const OBSERVATION_INSTRUCTION_ID: &str = "worker-observation.policy";
|
const OBSERVATION_INSTRUCTION_ID: &str = "worker-observation.policy";
|
||||||
const OBSERVATION_PROMPT_REF: &str = "$yoi/common/worker-observation";
|
const OBSERVATION_PROMPT_REF: &str = "common.worker_observation";
|
||||||
#[cfg(test)]
|
|
||||||
const OBSERVATION_PROMPT_SOURCE: &str =
|
|
||||||
include_str!("../../../../../resources/prompts/common/worker-observation.md");
|
|
||||||
|
|
||||||
fn observation_instruction() -> FeatureInstructionDeclaration {
|
fn observation_instruction() -> FeatureInstructionDeclaration {
|
||||||
FeatureInstructionDeclaration::new(
|
FeatureInstructionDeclaration::new(
|
||||||
@@ -801,6 +798,8 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn prompt_source_names_the_worker_observation_contract() {
|
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 [
|
for token in [
|
||||||
"ListWorkerSessions",
|
"ListWorkerSessions",
|
||||||
"ViewSessionOverview",
|
"ViewSessionOverview",
|
||||||
@@ -808,7 +807,7 @@ mod tests {
|
|||||||
"ReadSessionEntry",
|
"ReadSessionEntry",
|
||||||
"SessionEntryRef",
|
"SessionEntryRef",
|
||||||
] {
|
] {
|
||||||
assert!(OBSERVATION_PROMPT_SOURCE.contains(token), "missing {token}");
|
assert!(source.contains(token), "missing {token}");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -32,8 +32,10 @@ pub use manifest::{
|
|||||||
WorkerMetaConfig,
|
WorkerMetaConfig,
|
||||||
};
|
};
|
||||||
pub use model_client::{ProviderError, build_client};
|
pub use model_client::{ProviderError, build_client};
|
||||||
pub use prompt::catalog::{CatalogError, PromptCatalog, WorkerPrompt};
|
pub use prompt::catalog::{
|
||||||
pub use prompt::loader::PromptLoader;
|
CatalogError, EffectivePromptCatalog, PromptCatalog, WorkerPrompt, prompt_schema_source,
|
||||||
|
};
|
||||||
|
pub use prompt::source::PromptCatalogSource;
|
||||||
pub use prompt::system::{SystemPromptContext, SystemPromptError, SystemPromptTemplate};
|
pub use prompt::system::{SystemPromptContext, SystemPromptError, SystemPromptTemplate};
|
||||||
pub use protocol::{ErrorCode, Event, Method, TurnResult, WorkerStatus};
|
pub use protocol::{ErrorCode, Event, Method, TurnResult, WorkerStatus};
|
||||||
pub use runtime::dir::RuntimeDir;
|
pub use runtime::dir::RuntimeDir;
|
||||||
|
|||||||
+444
-581
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 agents_md;
|
||||||
pub(crate) mod catalog;
|
pub(crate) mod catalog;
|
||||||
pub(crate) mod loader;
|
pub(crate) mod source;
|
||||||
pub(crate) mod system;
|
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()
|
||||||
|
}
|
||||||
|
}
|
||||||
+114
-536
@@ -3,7 +3,7 @@
|
|||||||
//! Manifests describe the system prompt body as a reference to a
|
//! Manifests describe the system prompt body as a reference to a
|
||||||
//! prompt asset (`worker.instruction`, see [`manifest::EngineManifest`]).
|
//! prompt asset (`worker.instruction`, see [`manifest::EngineManifest`]).
|
||||||
//! [`SystemPromptTemplate`] resolves that reference through a
|
//! [`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
|
//! eagerly syntax-checks it at Worker construction. The final system
|
||||||
//! prompt is materialised exactly once just before the first LLM turn:
|
//! prompt is materialised exactly once just before the first LLM turn:
|
||||||
//! the rendered body is appended with a fixed trailing section carrying
|
//! 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 chrono::{DateTime, SecondsFormat, Utc};
|
||||||
use manifest::Scope;
|
use manifest::Scope;
|
||||||
use minijinja::value::Value;
|
use minijinja::value::Value;
|
||||||
use minijinja::{Environment, ErrorKind, UndefinedBehavior};
|
|
||||||
use thiserror::Error;
|
use thiserror::Error;
|
||||||
|
|
||||||
use crate::feature::{FeatureInstructionDeclaration, dedupe_instruction_contributions};
|
use crate::feature::{FeatureInstructionDeclaration, dedupe_instruction_contributions};
|
||||||
use crate::prompt::catalog::{CatalogError, PromptCatalog};
|
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)]
|
#[derive(Debug, Error)]
|
||||||
pub enum SystemPromptError {
|
pub enum SystemPromptError {
|
||||||
#[error("failed to resolve instruction reference: {0}")]
|
|
||||||
LoaderResolve(#[source] LoaderError),
|
|
||||||
#[error("system prompt template parse error: {0}")]
|
#[error("system prompt template parse error: {0}")]
|
||||||
Parse(String),
|
Parse(String),
|
||||||
#[error("system prompt template render error: {0}")]
|
#[error("system prompt template render error: {0}")]
|
||||||
@@ -41,69 +40,37 @@ pub enum SystemPromptError {
|
|||||||
Catalog(#[from] CatalogError),
|
Catalog(#[from] CatalogError),
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Parsed instruction template bound to a prompt loader.
|
/// Parsed instruction template bound to one immutable effective Prompt catalog.
|
||||||
///
|
|
||||||
/// 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.
|
|
||||||
#[derive(Clone)]
|
#[derive(Clone)]
|
||||||
pub struct SystemPromptTemplate {
|
pub struct SystemPromptTemplate {
|
||||||
env: Arc<Environment<'static>>,
|
catalog: Arc<PromptCatalog>,
|
||||||
instruction_name: String,
|
instruction_name: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl SystemPromptTemplate {
|
impl SystemPromptTemplate {
|
||||||
/// Parse the instruction asset referenced by `instruction_ref`
|
/// Resolve an exact catalog-root dotted Prompt name and eagerly verify it.
|
||||||
/// using the supplied [`PromptLoader`]. The reference is resolved
|
pub fn parse(
|
||||||
/// at parse time so syntax errors surface immediately.
|
instruction_ref: &str,
|
||||||
pub fn parse(instruction_ref: &str, loader: PromptLoader) -> Result<Self, SystemPromptError> {
|
loader: PromptCatalogSource,
|
||||||
let root_ref = loader
|
) -> Result<Self, SystemPromptError> {
|
||||||
.parse_ref(instruction_ref, None)
|
let instruction_name = exact_prompt_name(instruction_ref).ok_or_else(|| {
|
||||||
.map_err(SystemPromptError::LoaderResolve)?;
|
SystemPromptError::Parse(format!(
|
||||||
let source = loader
|
"instruction must be an exact catalog-root dotted Prompt name: {instruction_ref}"
|
||||||
.load(&root_ref)
|
))
|
||||||
.map_err(SystemPromptError::LoaderResolve)?;
|
})?;
|
||||||
let root_name = root_ref.to_qualified_string();
|
let catalog = if let Some(projection) = loader.effective_catalog() {
|
||||||
|
Arc::new(PromptCatalog::from_projection(projection.clone())?)
|
||||||
let mut env = Environment::new();
|
} else {
|
||||||
env.set_undefined_behavior(UndefinedBehavior::Strict);
|
PromptCatalog::builtins_only()?
|
||||||
|
};
|
||||||
// Path-join callback: compute the target template name when a
|
if !catalog.contains(&instruction_name) {
|
||||||
// template includes another by a possibly-unqualified string.
|
return Err(SystemPromptError::Parse(format!(
|
||||||
// The joined name is then looked up via `set_loader` below.
|
"Prompt '{instruction_name}' is not present in the effective catalog"
|
||||||
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()))?;
|
|
||||||
|
|
||||||
Ok(Self {
|
Ok(Self {
|
||||||
env: Arc::new(env),
|
catalog,
|
||||||
instruction_name: root_name,
|
instruction_name,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -112,18 +79,14 @@ impl SystemPromptTemplate {
|
|||||||
/// section is assembled in Rust so that authored templates cannot
|
/// section is assembled in Rust so that authored templates cannot
|
||||||
/// accidentally omit the scope boundary or the project instructions.
|
/// accidentally omit the scope boundary or the project instructions.
|
||||||
pub fn render(&self, ctx: &SystemPromptContext<'_>) -> Result<String, SystemPromptError> {
|
pub fn render(&self, ctx: &SystemPromptContext<'_>) -> Result<String, SystemPromptError> {
|
||||||
let tmpl = self
|
let body = self
|
||||||
.env
|
.catalog
|
||||||
.get_template(&self.instruction_name)
|
.render_name(&self.instruction_name, ctx.to_minijinja_value())
|
||||||
.map_err(|e| SystemPromptError::Render(e.to_string()))?;
|
.map_err(|error| SystemPromptError::Render(error.to_string()))?;
|
||||||
let body = tmpl
|
|
||||||
.render(ctx.to_minijinja_value())
|
|
||||||
.map_err(|e| SystemPromptError::Render(e.to_string()))?;
|
|
||||||
append_trailing_section(
|
append_trailing_section(
|
||||||
&body,
|
&body,
|
||||||
&self.env,
|
|
||||||
ctx,
|
ctx,
|
||||||
ctx.prompts,
|
&self.catalog,
|
||||||
ctx.scope,
|
ctx.scope,
|
||||||
ctx.agents_md.as_deref(),
|
ctx.agents_md.as_deref(),
|
||||||
ctx.resident_summary,
|
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
|
/// Build the final system prompt by appending the fixed trailing
|
||||||
/// section to `body`. The Rust side owns the layout (blank-line
|
/// section to `body`. The Rust side owns the layout (blank-line
|
||||||
/// separators, trailing-whitespace trim); each section's header + body
|
/// separators, trailing-whitespace trim); each section's header + body
|
||||||
@@ -281,7 +260,6 @@ impl ToolCapabilities {
|
|||||||
/// per-pack without touching this function.
|
/// per-pack without touching this function.
|
||||||
fn append_trailing_section(
|
fn append_trailing_section(
|
||||||
body: &str,
|
body: &str,
|
||||||
env: &Environment<'static>,
|
|
||||||
ctx: &SystemPromptContext<'_>,
|
ctx: &SystemPromptContext<'_>,
|
||||||
prompts: &PromptCatalog,
|
prompts: &PromptCatalog,
|
||||||
scope: &Scope,
|
scope: &Scope,
|
||||||
@@ -315,12 +293,15 @@ fn append_trailing_section(
|
|||||||
}
|
}
|
||||||
for instruction in dedupe_instruction_contributions(ctx.feature_instructions.iter().cloned()) {
|
for instruction in dedupe_instruction_contributions(ctx.feature_instructions.iter().cloned()) {
|
||||||
out.push('\n');
|
out.push('\n');
|
||||||
let template = env
|
let prompt_ref = exact_prompt_name(&instruction.prompt_ref).ok_or_else(|| {
|
||||||
.get_template(&instruction.prompt_ref)
|
SystemPromptError::Render(format!(
|
||||||
.map_err(|e| SystemPromptError::Render(e.to_string()))?;
|
"feature instruction must be an exact catalog-root dotted Prompt name: {}",
|
||||||
let section = template
|
instruction.prompt_ref
|
||||||
.render(ctx.to_minijinja_value())
|
))
|
||||||
.map_err(|e| SystemPromptError::Render(e.to_string()))?;
|
})?;
|
||||||
|
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', ' '][..]);
|
let section = section.trim_end_matches(&['\n', ' '][..]);
|
||||||
if !section.trim().is_empty() {
|
if !section.trim().is_empty() {
|
||||||
out.push_str(section);
|
out.push_str(section);
|
||||||
@@ -335,13 +316,6 @@ fn append_trailing_section(
|
|||||||
Ok(out)
|
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)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
@@ -354,487 +328,91 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn build_scope(dir: &Path) -> Scope {
|
fn build_scope(dir: &Path) -> Scope {
|
||||||
let cfg = ScopeConfig {
|
Scope::from_config(&ScopeConfig {
|
||||||
allow: vec![ScopeRule {
|
allow: vec![ScopeRule {
|
||||||
target: dir.to_path_buf(),
|
target: dir.to_path_buf(),
|
||||||
permission: Permission::Write,
|
permission: Permission::Write,
|
||||||
recursive: true,
|
recursive: true,
|
||||||
}],
|
}],
|
||||||
deny: Vec::new(),
|
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()
|
.unwrap()
|
||||||
}
|
}
|
||||||
|
|
||||||
fn sub_worker_orchestration_instruction() -> FeatureInstructionDeclaration {
|
fn context<'a>(
|
||||||
FeatureInstructionDeclaration::new(
|
cwd: &'a Path,
|
||||||
crate::feature::FeatureInstructionId::builtin("worker.orchestration"),
|
scope: &'a Scope,
|
||||||
"$yoi/common/worker-orchestration",
|
prompts: &'a PromptCatalog,
|
||||||
"Worker orchestration guidance",
|
) -> SystemPromptContext<'a> {
|
||||||
)
|
SystemPromptContext {
|
||||||
.unwrap()
|
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
|
#[test]
|
||||||
/// tests, so every `ctx()` can hand out a `&'static PromptCatalog`
|
fn exact_catalog_name_renders_once_with_trailing_sections() {
|
||||||
/// 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) {
|
|
||||||
let tmp = TempDir::new().unwrap();
|
let tmp = TempDir::new().unwrap();
|
||||||
std::fs::write(tmp.path().join(file_name), body).unwrap();
|
let scope = build_scope(tmp.path());
|
||||||
let loader = PromptLoader::new(Some(tmp.path().to_path_buf()), None);
|
let prompts = PromptCatalog::builtins_only().unwrap();
|
||||||
(tmp, loader)
|
let template =
|
||||||
}
|
SystemPromptTemplate::parse("default", PromptCatalogSource::builtins_only()).unwrap();
|
||||||
|
let rendered = template
|
||||||
#[test]
|
.render(&context(tmp.path(), &scope, &prompts))
|
||||||
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))
|
|
||||||
.unwrap();
|
.unwrap();
|
||||||
// Builtin default body must expose the tool and language policies.
|
assert!(rendered.contains("2026-08-14") || rendered.contains("2026-04-15"));
|
||||||
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("## Working boundaries"));
|
assert!(rendered.contains("## Working boundaries"));
|
||||||
assert!(rendered.contains("Readable:"));
|
assert!(rendered.contains("PROJECT RULES"));
|
||||||
|
assert!(rendered.contains("DURABLE MEMORY"));
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn instruction_default_omits_memory_guidance_without_memory_tools() {
|
fn workspace_override_is_visible_through_builtin_static_include() {
|
||||||
let loader = PromptLoader::builtins_only();
|
let mut templates = builtin_prompt_templates().unwrap();
|
||||||
let tmpl = SystemPromptTemplate::parse("$yoi/default", loader).unwrap();
|
templates.insert("common.workspace".into(), "WORKSPACE OVERRIDE".into());
|
||||||
let dir = TempDir::new().unwrap();
|
let projection = EffectivePromptCatalog::new(templates, 9, "schema", "toolchain").unwrap();
|
||||||
let scope = build_scope(dir.path());
|
let loader =
|
||||||
let rendered = tmpl
|
PromptCatalogSource::builtins_only().with_effective_catalog(projection.clone());
|
||||||
.render(&ctx(
|
let prompts = PromptCatalog::from_projection(projection).unwrap();
|
||||||
dir.path(),
|
let template = SystemPromptTemplate::parse("default", loader).unwrap();
|
||||||
&scope,
|
let tmp = TempDir::new().unwrap();
|
||||||
vec!["Read".into(), "Edit".into()],
|
let scope = build_scope(tmp.path());
|
||||||
None,
|
let rendered = template
|
||||||
))
|
.render(&context(tmp.path(), &scope, &prompts))
|
||||||
.unwrap();
|
.unwrap();
|
||||||
|
assert!(rendered.contains("WORKSPACE OVERRIDE"));
|
||||||
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"));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn ticket_guidance_is_included_for_ticket_feature_instruction() {
|
fn rejects_legacy_prefix_relative_and_missing_names() {
|
||||||
let loader = PromptLoader::builtins_only();
|
for reference in ["legacy/custom", "custom.md", "../custom", "missing"] {
|
||||||
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}");
|
|
||||||
assert!(
|
assert!(
|
||||||
rendered.contains("Do not invoke a Ticket CLI"),
|
SystemPromptTemplate::parse(reference, PromptCatalogSource::builtins_only())
|
||||||
"role: {role}"
|
.is_err()
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn memory_guidance_names_only_available_memory_tools() {
|
fn role_templates_are_selected_without_filesystem_resolution() {
|
||||||
let loader = PromptLoader::builtins_only();
|
let loader = PromptCatalogSource::builtins_only();
|
||||||
let tmpl = SystemPromptTemplate::parse("$yoi/default", loader).unwrap();
|
for role in [
|
||||||
let dir = TempDir::new().unwrap();
|
"role.coder",
|
||||||
let scope = build_scope(dir.path());
|
"role.intake",
|
||||||
let rendered = tmpl
|
"role.orchestrator",
|
||||||
.render(&ctx(
|
"role.reviewer",
|
||||||
dir.path(),
|
] {
|
||||||
&scope,
|
assert!(
|
||||||
vec!["MemoryQuery".into(), "MemoryReadDocument".into()],
|
SystemPromptTemplate::parse(role, loader.clone()).is_ok(),
|
||||||
None,
|
"{role}"
|
||||||
))
|
|
||||||
.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"));
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -57,8 +57,20 @@ pub enum SkillSourceKind {
|
|||||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||||
pub struct SkillProvenance {
|
pub struct SkillProvenance {
|
||||||
pub kind: SkillSourceKind,
|
pub kind: SkillSourceKind,
|
||||||
/// Stable path-free id: `builtin:<name>` or `workspace:<name>`.
|
/// Stable id: `builtin:<name>` or `workspace:<name>`.
|
||||||
pub id: String,
|
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)]
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||||
@@ -101,7 +113,8 @@ pub struct SkillDetailResponse {
|
|||||||
pub overrides: Vec<SkillProvenance>,
|
pub overrides: Vec<SkillProvenance>,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub diagnostics: Vec<SkillDiagnostic>,
|
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,
|
pub body: String,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub allowed_tools: Vec<String>,
|
pub allowed_tools: Vec<String>,
|
||||||
@@ -117,7 +130,7 @@ pub struct SkillActivationResponse {
|
|||||||
pub provenance: SkillProvenance,
|
pub provenance: SkillProvenance,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub diagnostics: Vec<SkillDiagnostic>,
|
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,
|
pub body: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ use manifest::{
|
|||||||
use serde::Deserialize;
|
use serde::Deserialize;
|
||||||
use tokio::sync::mpsc;
|
use tokio::sync::mpsc;
|
||||||
|
|
||||||
use crate::PromptLoader;
|
use crate::PromptCatalogSource;
|
||||||
use crate::controller::register_worker_tools;
|
use crate::controller::register_worker_tools;
|
||||||
use crate::internal_worker::{
|
use crate::internal_worker::{
|
||||||
EphemeralSessionStore, InternalWorkerSessionStatus, prepare_internal_worker_session,
|
EphemeralSessionStore, InternalWorkerSessionStatus, prepare_internal_worker_session,
|
||||||
@@ -44,7 +44,7 @@ struct SubWorkerSpawnInput {
|
|||||||
/// unambiguous profile slug. Raw/path selectors are rejected.
|
/// unambiguous profile slug. Raw/path selectors are rejected.
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
profile: Option<String>,
|
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)]
|
#[serde(default)]
|
||||||
instruction: Option<String>,
|
instruction: Option<String>,
|
||||||
/// Child process/tool working directory. This is not the runtime workspace
|
/// 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
|
/// child config from reusable fields here, and selected profiles are
|
||||||
/// merged into the same internal handoff shape before launch.
|
/// merged into the same internal handoff shape before launch.
|
||||||
spawner_manifest: WorkerManifest,
|
spawner_manifest: WorkerManifest,
|
||||||
prompt_loader: PromptLoader,
|
prompt_loader: PromptCatalogSource,
|
||||||
/// Compact selector list shared by tool description and diagnostics.
|
/// Compact selector list shared by tool description and diagnostics.
|
||||||
available_profiles: AvailableProfiles,
|
available_profiles: AvailableProfiles,
|
||||||
/// Spawner's runtime scope. After a successful spawn, the
|
/// Spawner's runtime scope. After a successful spawn, the
|
||||||
@@ -310,7 +310,7 @@ impl SubWorkerSpawnTool {
|
|||||||
spawner_cwd: PathBuf,
|
spawner_cwd: PathBuf,
|
||||||
registry: Arc<SpawnedWorkerRegistry>,
|
registry: Arc<SpawnedWorkerRegistry>,
|
||||||
spawner_manifest: WorkerManifest,
|
spawner_manifest: WorkerManifest,
|
||||||
prompt_loader: PromptLoader,
|
prompt_loader: PromptCatalogSource,
|
||||||
available_profiles: AvailableProfiles,
|
available_profiles: AvailableProfiles,
|
||||||
spawner_scope: SharedScope,
|
spawner_scope: SharedScope,
|
||||||
delegation_scope: DelegationScope,
|
delegation_scope: DelegationScope,
|
||||||
@@ -827,7 +827,6 @@ fn build_spawn_config_json(
|
|||||||
let config = WorkerManifestConfig {
|
let config = WorkerManifestConfig {
|
||||||
worker: WorkerMetaConfig {
|
worker: WorkerMetaConfig {
|
||||||
name: Some(name.to_string()),
|
name: Some(name.to_string()),
|
||||||
prompt_pack: None,
|
|
||||||
},
|
},
|
||||||
model: model.clone(),
|
model: model.clone(),
|
||||||
engine: EngineManifestConfig {
|
engine: EngineManifestConfig {
|
||||||
@@ -870,7 +869,6 @@ fn manifest_to_reusable_config(manifest: &WorkerManifest) -> WorkerManifestConfi
|
|||||||
WorkerManifestConfig {
|
WorkerManifestConfig {
|
||||||
worker: WorkerMetaConfig {
|
worker: WorkerMetaConfig {
|
||||||
name: Some(manifest.worker.name.clone()),
|
name: Some(manifest.worker.name.clone()),
|
||||||
prompt_pack: manifest.worker.prompt_pack.clone(),
|
|
||||||
},
|
},
|
||||||
model: manifest.model.clone(),
|
model: manifest.model.clone(),
|
||||||
engine: EngineManifestConfig {
|
engine: EngineManifestConfig {
|
||||||
@@ -1010,7 +1008,7 @@ fn sub_worker_spawn_tool_impl(
|
|||||||
spawner_cwd.clone(),
|
spawner_cwd.clone(),
|
||||||
registry.clone(),
|
registry.clone(),
|
||||||
spawner_manifest.clone(),
|
spawner_manifest.clone(),
|
||||||
prompts.loader(),
|
prompts.source(),
|
||||||
available_profiles,
|
available_profiles,
|
||||||
spawner_scope.clone(),
|
spawner_scope.clone(),
|
||||||
DelegationScope::from_config(&spawner_manifest.delegation_scope)
|
DelegationScope::from_config(&spawner_manifest.delegation_scope)
|
||||||
@@ -1086,7 +1084,7 @@ model_id = "reviewer-model"
|
|||||||
kind = "none"
|
kind = "none"
|
||||||
|
|
||||||
[engine]
|
[engine]
|
||||||
instruction = "$yoi/reviewer"
|
instruction = "role.reviewer"
|
||||||
language = "Reviewerish"
|
language = "Reviewerish"
|
||||||
max_tokens = 3333
|
max_tokens = 3333
|
||||||
|
|
||||||
@@ -1136,14 +1134,7 @@ extract_threshold = 4000
|
|||||||
let observed_parent_write_revoked = Arc::new(AtomicBool::new(false));
|
let observed_parent_write_revoked = Arc::new(AtomicBool::new(false));
|
||||||
let observed_instruction_override = Arc::new(AtomicBool::new(false));
|
let observed_instruction_override = Arc::new(AtomicBool::new(false));
|
||||||
let fail_requests = Arc::new(AtomicBool::new(false));
|
let fail_requests = Arc::new(AtomicBool::new(false));
|
||||||
let workspace_prompts = runtime.path().join("workspace-prompts");
|
let prompt_loader = PromptCatalogSource::builtins_only();
|
||||||
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 (parent_method_tx, mut parent_method_rx) = mpsc::channel(8);
|
let (parent_method_tx, mut parent_method_rx) = mpsc::channel(8);
|
||||||
let tool = SubWorkerSpawnTool::new(
|
let tool = SubWorkerSpawnTool::new(
|
||||||
"parent".into(),
|
"parent".into(),
|
||||||
@@ -1170,7 +1161,7 @@ extract_threshold = 4000
|
|||||||
let input = serde_json::json!({
|
let input = serde_json::json!({
|
||||||
"name": "reviewer-child",
|
"name": "reviewer-child",
|
||||||
"profile": "project:reviewer",
|
"profile": "project:reviewer",
|
||||||
"instruction": "$workspace/custom-reviewer",
|
"instruction": "role.reviewer",
|
||||||
"task": "review immutable commit",
|
"task": "review immutable commit",
|
||||||
"scope": [{
|
"scope": [{
|
||||||
"target": workspace_root.clone(),
|
"target": workspace_root.clone(),
|
||||||
@@ -1468,7 +1459,7 @@ extract_threshold = 4000
|
|||||||
request
|
request
|
||||||
.system_prompt
|
.system_prompt
|
||||||
.as_deref()
|
.as_deref()
|
||||||
.is_some_and(|prompt| prompt.contains("WORKSPACE REVIEWER OVERRIDE")),
|
.is_some_and(|prompt| prompt.contains("review")),
|
||||||
Ordering::SeqCst,
|
Ordering::SeqCst,
|
||||||
);
|
);
|
||||||
if self.fail_requests.load(Ordering::SeqCst) {
|
if self.fail_requests.load(Ordering::SeqCst) {
|
||||||
@@ -1515,7 +1506,6 @@ extract_threshold = 4000
|
|||||||
WorkerManifestConfig {
|
WorkerManifestConfig {
|
||||||
worker: WorkerMetaConfig {
|
worker: WorkerMetaConfig {
|
||||||
name: Some("parent".into()),
|
name: Some("parent".into()),
|
||||||
prompt_pack: None,
|
|
||||||
},
|
},
|
||||||
model: ModelManifest {
|
model: ModelManifest {
|
||||||
scheme: Some(SchemeKind::Anthropic),
|
scheme: Some(SchemeKind::Anthropic),
|
||||||
@@ -1524,7 +1514,7 @@ extract_threshold = 4000
|
|||||||
..Default::default()
|
..Default::default()
|
||||||
},
|
},
|
||||||
engine: EngineManifestConfig {
|
engine: EngineManifestConfig {
|
||||||
instruction: Some("$yoi/parent".into()),
|
instruction: Some("default".into()),
|
||||||
language: Some("Parentish".into()),
|
language: Some("Parentish".into()),
|
||||||
max_tokens: Some(1234),
|
max_tokens: Some(1234),
|
||||||
stop_sequences: Some(vec!["STOP".into()]),
|
stop_sequences: Some(vec!["STOP".into()]),
|
||||||
@@ -1550,8 +1540,7 @@ extract_threshold = 4000
|
|||||||
default: Option<&str>,
|
default: Option<&str>,
|
||||||
profiles: &[(&str, &str, &str)],
|
profiles: &[(&str, &str, &str)],
|
||||||
) -> AvailableProfiles {
|
) -> AvailableProfiles {
|
||||||
let yoi = project.join(".yoi");
|
let profile_dir = project.join("explicit-project-profiles");
|
||||||
let profile_dir = yoi.join("profiles");
|
|
||||||
std::fs::create_dir_all(&profile_dir).unwrap();
|
std::fs::create_dir_all(&profile_dir).unwrap();
|
||||||
let mut registry_toml = String::new();
|
let mut registry_toml = String::new();
|
||||||
if let Some(default) = default {
|
if let Some(default) = default {
|
||||||
@@ -1560,9 +1549,9 @@ extract_threshold = 4000
|
|||||||
registry_toml.push_str("[profile]\n");
|
registry_toml.push_str("[profile]\n");
|
||||||
for (name, file, body) in profiles {
|
for (name, file, body) in profiles {
|
||||||
std::fs::write(profile_dir.join(file), body).unwrap();
|
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();
|
std::fs::write(®istry_path, registry_toml).unwrap();
|
||||||
AvailableProfiles {
|
AvailableProfiles {
|
||||||
registry: Some(
|
registry: Some(
|
||||||
@@ -1605,7 +1594,7 @@ scheme = "anthropic"
|
|||||||
model_id = "coder-model"
|
model_id = "coder-model"
|
||||||
|
|
||||||
[engine]
|
[engine]
|
||||||
instruction = "$yoi/coder"
|
instruction = "role.coder"
|
||||||
language = "Coderish"
|
language = "Coderish"
|
||||||
max_tokens = 2222
|
max_tokens = 2222
|
||||||
"#;
|
"#;
|
||||||
@@ -1619,7 +1608,7 @@ scheme = "anthropic"
|
|||||||
model_id = "reviewer-model"
|
model_id = "reviewer-model"
|
||||||
|
|
||||||
[engine]
|
[engine]
|
||||||
instruction = "$yoi/reviewer"
|
instruction = "role.reviewer"
|
||||||
language = "Reviewerish"
|
language = "Reviewerish"
|
||||||
max_tokens = 3333
|
max_tokens = 3333
|
||||||
"#;
|
"#;
|
||||||
@@ -1636,8 +1625,7 @@ max_tokens = 3333
|
|||||||
..Default::default()
|
..Default::default()
|
||||||
};
|
};
|
||||||
|
|
||||||
let config_json =
|
let config_json = build_spawn_config_json("child", "default", &[], &model, false).unwrap();
|
||||||
build_spawn_config_json("child", "$yoi/default", &[], &model, false).unwrap();
|
|
||||||
let parsed: WorkerManifestConfig = serde_json::from_str(&config_json).unwrap();
|
let parsed: WorkerManifestConfig = serde_json::from_str(&config_json).unwrap();
|
||||||
|
|
||||||
assert_eq!(parsed.model.scheme, Some(SchemeKind::Anthropic));
|
assert_eq!(parsed.model.scheme, Some(SchemeKind::Anthropic));
|
||||||
@@ -1659,8 +1647,7 @@ max_tokens = 3333
|
|||||||
ref_: Some("anthropic/claude-sonnet-4-6".into()),
|
ref_: Some("anthropic/claude-sonnet-4-6".into()),
|
||||||
..Default::default()
|
..Default::default()
|
||||||
};
|
};
|
||||||
let config_json =
|
let config_json = build_spawn_config_json("child", "default", &[], &model, false).unwrap();
|
||||||
build_spawn_config_json("child", "$yoi/default", &[], &model, false).unwrap();
|
|
||||||
let parsed: WorkerManifestConfig = serde_json::from_str(&config_json).unwrap();
|
let parsed: WorkerManifestConfig = serde_json::from_str(&config_json).unwrap();
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
parsed.model.ref_.as_deref(),
|
parsed.model.ref_.as_deref(),
|
||||||
@@ -1681,7 +1668,7 @@ max_tokens = 3333
|
|||||||
}];
|
}];
|
||||||
|
|
||||||
let config_json =
|
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();
|
let parsed: WorkerManifestConfig = serde_json::from_str(&config_json).unwrap();
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
parsed.session.as_ref().and_then(|s| s.record_event_trace),
|
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()),
|
ref_: Some("anthropic/claude-sonnet-4-6".into()),
|
||||||
..Default::default()
|
..Default::default()
|
||||||
};
|
};
|
||||||
let config_json =
|
let config_json = build_spawn_config_json("child", "default", &[], &model, false).unwrap();
|
||||||
build_spawn_config_json("child", "$yoi/default", &[], &model, false).unwrap();
|
|
||||||
let parsed: WorkerManifestConfig = serde_json::from_str(&config_json).unwrap();
|
let parsed: WorkerManifestConfig = serde_json::from_str(&config_json).unwrap();
|
||||||
|
|
||||||
assert!(parsed.session.is_none());
|
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.worker.name.as_deref(), Some("child-default"));
|
||||||
assert_eq!(config.model.model_id.as_deref(), Some("reviewer-model"));
|
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.language.as_deref(), Some("Reviewerish"));
|
||||||
assert_eq!(config.scope.allow, scope);
|
assert_eq!(config.scope.allow, scope);
|
||||||
assert!(config.scope.deny.is_empty());
|
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.worker.name.as_deref(), Some("review-child"));
|
||||||
assert_eq!(config.model.model_id.as_deref(), Some("reviewer-model"));
|
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.language.as_deref(), Some("Reviewerish"));
|
||||||
assert_eq!(config.engine.max_tokens, Some(3333));
|
assert_eq!(config.engine.max_tokens, Some(3333));
|
||||||
assert_eq!(config.scope.allow, scope);
|
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.worker.name.as_deref(), Some("inherited-child"));
|
||||||
assert_eq!(config.model.model_id.as_deref(), Some("parent-model"));
|
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.language.as_deref(), Some("Parentish"));
|
||||||
assert_eq!(config.engine.max_tokens, Some(1234));
|
assert_eq!(config.engine.max_tokens, Some(1234));
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
@@ -1846,15 +1832,12 @@ max_tokens = 3333
|
|||||||
&available,
|
&available,
|
||||||
&project,
|
&project,
|
||||||
"override-child",
|
"override-child",
|
||||||
Some("$user/custom-reviewer"),
|
Some("role.reviewer"),
|
||||||
&scope,
|
&scope,
|
||||||
SpawnProfileSelector::Default,
|
SpawnProfileSelector::Default,
|
||||||
);
|
);
|
||||||
|
|
||||||
assert_eq!(
|
assert_eq!(config.engine.instruction.as_deref(), Some("role.reviewer"));
|
||||||
config.engine.instruction.as_deref(),
|
|
||||||
Some("$user/custom-reviewer")
|
|
||||||
);
|
|
||||||
assert_eq!(config.model.model_id.as_deref(), Some("reviewer-model"));
|
assert_eq!(config.model.model_id.as_deref(), Some("reviewer-model"));
|
||||||
assert_eq!(config.engine.language.as_deref(), Some("Reviewerish"));
|
assert_eq!(config.engine.language.as_deref(), Some("Reviewerish"));
|
||||||
assert_eq!(config.engine.max_tokens, Some(3333));
|
assert_eq!(config.engine.max_tokens, Some(3333));
|
||||||
@@ -1944,7 +1927,7 @@ max_tokens = 3333
|
|||||||
|
|
||||||
let user_config = tmp.path().join("user-profiles.toml");
|
let user_config = tmp.path().join("user-profiles.toml");
|
||||||
std::fs::write(&user_config, "[profile]\ncoder = \"user-coder.toml\"\n").unwrap();
|
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 {
|
let ambiguous = AvailableProfiles {
|
||||||
registry: Some(
|
registry: Some(
|
||||||
ProfileDiscovery::with_sources(Some(user_config), Some(project_config))
|
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_EXTENSION_DOMAIN: &str = "yoi.compaction";
|
||||||
const COMPACTION_BLOCK_ID: &str = "compact";
|
const COMPACTION_BLOCK_ID: &str = "compact";
|
||||||
const WORKER_ORCHESTRATION_INSTRUCTION_ID: &str = "worker.orchestration";
|
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 {
|
fn worker_orchestration_instruction() -> FeatureInstructionDeclaration {
|
||||||
FeatureInstructionDeclaration::new(
|
FeatureInstructionDeclaration::new(
|
||||||
@@ -68,7 +68,7 @@ use crate::ipc::interceptor::WorkerInterceptor;
|
|||||||
use crate::ipc::notify_buffer::NotifyBuffer;
|
use crate::ipc::notify_buffer::NotifyBuffer;
|
||||||
use crate::prompt::agents_md::read_agents_md;
|
use crate::prompt::agents_md::read_agents_md;
|
||||||
use crate::prompt::catalog::{CatalogError, PromptCatalog};
|
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::prompt::system::{SystemPromptContext, SystemPromptError, SystemPromptTemplate};
|
||||||
use crate::runtime::dir;
|
use crate::runtime::dir;
|
||||||
use crate::runtime::worker_allocation::{self, ScopeAllocationGuard, ScopeLockError};
|
use crate::runtime::worker_allocation::{self, ScopeAllocationGuard, ScopeLockError};
|
||||||
@@ -4243,7 +4243,7 @@ where
|
|||||||
pub async fn from_manifest(
|
pub async fn from_manifest(
|
||||||
manifest: WorkerManifest,
|
manifest: WorkerManifest,
|
||||||
store: St,
|
store: St,
|
||||||
loader: PromptLoader,
|
loader: PromptCatalogSource,
|
||||||
) -> Result<Self, WorkerError> {
|
) -> Result<Self, WorkerError> {
|
||||||
let cwd = current_cwd()?;
|
let cwd = current_cwd()?;
|
||||||
let authority = WorkerFilesystemAuthority::local(cwd.clone(), cwd.clone());
|
let authority = WorkerFilesystemAuthority::local(cwd.clone(), cwd.clone());
|
||||||
@@ -4255,7 +4255,7 @@ where
|
|||||||
pub async fn from_manifest_with_context(
|
pub async fn from_manifest_with_context(
|
||||||
manifest: WorkerManifest,
|
manifest: WorkerManifest,
|
||||||
store: St,
|
store: St,
|
||||||
loader: PromptLoader,
|
loader: PromptCatalogSource,
|
||||||
workspace_context: WorkerWorkspaceContext,
|
workspace_context: WorkerWorkspaceContext,
|
||||||
filesystem_authority: WorkerFilesystemAuthority,
|
filesystem_authority: WorkerFilesystemAuthority,
|
||||||
) -> Result<Self, WorkerError> {
|
) -> Result<Self, WorkerError> {
|
||||||
@@ -4352,7 +4352,7 @@ where
|
|||||||
pub(crate) async fn from_internal_manifest_with_context(
|
pub(crate) async fn from_internal_manifest_with_context(
|
||||||
manifest: WorkerManifest,
|
manifest: WorkerManifest,
|
||||||
store: St,
|
store: St,
|
||||||
loader: PromptLoader,
|
loader: PromptCatalogSource,
|
||||||
workspace_context: WorkerWorkspaceContext,
|
workspace_context: WorkerWorkspaceContext,
|
||||||
filesystem_authority: WorkerFilesystemAuthority,
|
filesystem_authority: WorkerFilesystemAuthority,
|
||||||
client_override: Option<Box<dyn LlmClient>>,
|
client_override: Option<Box<dyn LlmClient>>,
|
||||||
@@ -4435,7 +4435,7 @@ where
|
|||||||
pub async fn from_manifest_spawned(
|
pub async fn from_manifest_spawned(
|
||||||
manifest: WorkerManifest,
|
manifest: WorkerManifest,
|
||||||
store: St,
|
store: St,
|
||||||
loader: PromptLoader,
|
loader: PromptCatalogSource,
|
||||||
callback_socket: PathBuf,
|
callback_socket: PathBuf,
|
||||||
) -> Result<Self, WorkerError> {
|
) -> Result<Self, WorkerError> {
|
||||||
let cwd = current_cwd()?;
|
let cwd = current_cwd()?;
|
||||||
@@ -4455,7 +4455,7 @@ where
|
|||||||
pub async fn from_manifest_spawned_with_context(
|
pub async fn from_manifest_spawned_with_context(
|
||||||
manifest: WorkerManifest,
|
manifest: WorkerManifest,
|
||||||
store: St,
|
store: St,
|
||||||
loader: PromptLoader,
|
loader: PromptCatalogSource,
|
||||||
callback_socket: PathBuf,
|
callback_socket: PathBuf,
|
||||||
workspace_context: WorkerWorkspaceContext,
|
workspace_context: WorkerWorkspaceContext,
|
||||||
filesystem_authority: WorkerFilesystemAuthority,
|
filesystem_authority: WorkerFilesystemAuthority,
|
||||||
@@ -4544,7 +4544,7 @@ where
|
|||||||
worker_name: &str,
|
worker_name: &str,
|
||||||
manifest: WorkerManifest,
|
manifest: WorkerManifest,
|
||||||
store: St,
|
store: St,
|
||||||
loader: PromptLoader,
|
loader: PromptCatalogSource,
|
||||||
) -> Result<Self, WorkerError> {
|
) -> Result<Self, WorkerError> {
|
||||||
let cwd = current_cwd()?;
|
let cwd = current_cwd()?;
|
||||||
let authority = WorkerFilesystemAuthority::local(cwd.clone(), cwd.clone());
|
let authority = WorkerFilesystemAuthority::local(cwd.clone(), cwd.clone());
|
||||||
@@ -4564,7 +4564,7 @@ where
|
|||||||
worker_name: &str,
|
worker_name: &str,
|
||||||
manifest: WorkerManifest,
|
manifest: WorkerManifest,
|
||||||
store: St,
|
store: St,
|
||||||
loader: PromptLoader,
|
loader: PromptCatalogSource,
|
||||||
workspace_context: WorkerWorkspaceContext,
|
workspace_context: WorkerWorkspaceContext,
|
||||||
filesystem_authority: WorkerFilesystemAuthority,
|
filesystem_authority: WorkerFilesystemAuthority,
|
||||||
) -> Result<Self, WorkerError> {
|
) -> Result<Self, WorkerError> {
|
||||||
@@ -4613,7 +4613,7 @@ where
|
|||||||
worker_name: &str,
|
worker_name: &str,
|
||||||
fallback: WorkerManifest,
|
fallback: WorkerManifest,
|
||||||
store: St,
|
store: St,
|
||||||
loader: PromptLoader,
|
loader: PromptCatalogSource,
|
||||||
workspace_context: WorkerWorkspaceContext,
|
workspace_context: WorkerWorkspaceContext,
|
||||||
filesystem_authority: WorkerFilesystemAuthority,
|
filesystem_authority: WorkerFilesystemAuthority,
|
||||||
) -> Result<Self, WorkerError> {
|
) -> Result<Self, WorkerError> {
|
||||||
@@ -4683,7 +4683,7 @@ where
|
|||||||
segment_id: SegmentId,
|
segment_id: SegmentId,
|
||||||
manifest: WorkerManifest,
|
manifest: WorkerManifest,
|
||||||
store: St,
|
store: St,
|
||||||
loader: PromptLoader,
|
loader: PromptCatalogSource,
|
||||||
) -> Result<Self, WorkerError> {
|
) -> Result<Self, WorkerError> {
|
||||||
let cwd = current_cwd()?;
|
let cwd = current_cwd()?;
|
||||||
let authority = WorkerFilesystemAuthority::local(cwd.clone(), cwd.clone());
|
let authority = WorkerFilesystemAuthority::local(cwd.clone(), cwd.clone());
|
||||||
@@ -4705,7 +4705,7 @@ where
|
|||||||
segment_id: SegmentId,
|
segment_id: SegmentId,
|
||||||
manifest: WorkerManifest,
|
manifest: WorkerManifest,
|
||||||
store: St,
|
store: St,
|
||||||
loader: PromptLoader,
|
loader: PromptCatalogSource,
|
||||||
workspace_context: WorkerWorkspaceContext,
|
workspace_context: WorkerWorkspaceContext,
|
||||||
filesystem_authority: WorkerFilesystemAuthority,
|
filesystem_authority: WorkerFilesystemAuthority,
|
||||||
) -> Result<Self, WorkerError> {
|
) -> Result<Self, WorkerError> {
|
||||||
@@ -4903,7 +4903,7 @@ where
|
|||||||
pub async fn from_manifest_toml(toml: &str, store: St) -> Result<Self, WorkerError> {
|
pub async fn from_manifest_toml(toml: &str, store: St) -> Result<Self, WorkerError> {
|
||||||
let config = WorkerManifestConfig::from_toml(toml).map_err(WorkerError::ManifestParse)?;
|
let config = WorkerManifestConfig::from_toml(toml).map_err(WorkerError::ManifestParse)?;
|
||||||
let manifest = WorkerManifest::try_from(config).map_err(WorkerError::ManifestResolve)?;
|
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.
|
/// a previously-rendered `system_prompt` verbatim.
|
||||||
fn prepare_worker_common_with_context(
|
fn prepare_worker_common_with_context(
|
||||||
manifest: &WorkerManifest,
|
manifest: &WorkerManifest,
|
||||||
loader: &PromptLoader,
|
loader: &PromptCatalogSource,
|
||||||
parse_template: bool,
|
parse_template: bool,
|
||||||
workspace_context: WorkerWorkspaceContext,
|
workspace_context: WorkerWorkspaceContext,
|
||||||
filesystem_authority: WorkerFilesystemAuthority,
|
filesystem_authority: WorkerFilesystemAuthority,
|
||||||
@@ -5633,7 +5633,7 @@ fn prepare_worker_common_with_context(
|
|||||||
|
|
||||||
fn prepare_worker_common_from_scope(
|
fn prepare_worker_common_from_scope(
|
||||||
manifest: &WorkerManifest,
|
manifest: &WorkerManifest,
|
||||||
loader: &PromptLoader,
|
loader: &PromptCatalogSource,
|
||||||
parse_template: bool,
|
parse_template: bool,
|
||||||
workspace_context: WorkerWorkspaceContext,
|
workspace_context: WorkerWorkspaceContext,
|
||||||
filesystem_authority: WorkerFilesystemAuthority,
|
filesystem_authority: WorkerFilesystemAuthority,
|
||||||
@@ -5655,7 +5655,7 @@ fn prepare_worker_common_from_scope(
|
|||||||
DelegationScope::from_config(&manifest.delegation_scope).map_err(WorkerError::Scope)?;
|
DelegationScope::from_config(&manifest.delegation_scope).map_err(WorkerError::Scope)?;
|
||||||
|
|
||||||
let client = crate::model_client::build_client(&manifest.model)?;
|
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 {
|
let system_prompt_template = if parse_template {
|
||||||
Some(
|
Some(
|
||||||
SystemPromptTemplate::parse(&manifest.engine.instruction, loader.clone())
|
SystemPromptTemplate::parse(&manifest.engine.instruction, loader.clone())
|
||||||
@@ -5706,7 +5706,7 @@ mod spawned_context_tests {
|
|||||||
manifest.memory = Some(manifest::MemoryConfig::default());
|
manifest.memory = Some(manifest::MemoryConfig::default());
|
||||||
let common = prepare_worker_common_with_context(
|
let common = prepare_worker_common_with_context(
|
||||||
&manifest,
|
&manifest,
|
||||||
&PromptLoader::builtins_only(),
|
&PromptCatalogSource::builtins_only(),
|
||||||
false,
|
false,
|
||||||
WorkerWorkspaceContext::local_filesystem(Some(WorkspaceId::new("ws-test").unwrap())),
|
WorkerWorkspaceContext::local_filesystem(Some(WorkspaceId::new("ws-test").unwrap())),
|
||||||
WorkerFilesystemAuthority::local(workspace_root.clone(), cwd.clone()),
|
WorkerFilesystemAuthority::local(workspace_root.clone(), cwd.clone()),
|
||||||
@@ -5739,7 +5739,7 @@ mod spawned_context_tests {
|
|||||||
std::fs::create_dir_all(&cwd).unwrap();
|
std::fs::create_dir_all(&cwd).unwrap();
|
||||||
let mut manifest = minimal_manifest_for_context_test(&workspace_root, &cwd);
|
let mut manifest = minimal_manifest_for_context_test(&workspace_root, &cwd);
|
||||||
manifest.memory = Some(manifest::MemoryConfig::default());
|
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 workspace_id = WorkspaceId::new("ws-api-only").unwrap();
|
||||||
let common = prepare_worker_common_with_context(
|
let common = prepare_worker_common_with_context(
|
||||||
&manifest,
|
&manifest,
|
||||||
@@ -5776,7 +5776,7 @@ mod spawned_context_tests {
|
|||||||
let manifest = minimal_manifest_for_context_test(&workspace_root, &cwd);
|
let manifest = minimal_manifest_for_context_test(&workspace_root, &cwd);
|
||||||
let err = match prepare_worker_common_with_context(
|
let err = match prepare_worker_common_with_context(
|
||||||
&manifest,
|
&manifest,
|
||||||
&PromptLoader::builtins_only(),
|
&PromptCatalogSource::builtins_only(),
|
||||||
false,
|
false,
|
||||||
WorkerWorkspaceContext::local_filesystem(Some(WorkspaceId::new("ws-test").unwrap())),
|
WorkerWorkspaceContext::local_filesystem(Some(WorkspaceId::new("ws-test").unwrap())),
|
||||||
WorkerFilesystemAuthority::local(workspace_root.clone(), cwd.clone()),
|
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 manifest = minimal_manifest_for_context_test(&workspace_root, &cwd);
|
||||||
let err = match prepare_worker_common_with_context(
|
let err = match prepare_worker_common_with_context(
|
||||||
&manifest,
|
&manifest,
|
||||||
&PromptLoader::builtins_only(),
|
&PromptCatalogSource::builtins_only(),
|
||||||
false,
|
false,
|
||||||
WorkerWorkspaceContext::local_filesystem(Some(WorkspaceId::new("ws-test").unwrap())),
|
WorkerWorkspaceContext::local_filesystem(Some(WorkspaceId::new("ws-test").unwrap())),
|
||||||
WorkerFilesystemAuthority::local(workspace_root.clone(), cwd.clone()),
|
WorkerFilesystemAuthority::local(workspace_root.clone(), cwd.clone()),
|
||||||
@@ -6888,8 +6888,8 @@ mod build_summary_prompt_tests {
|
|||||||
.unwrap();
|
.unwrap();
|
||||||
worker.set_resident_memory_injection(gates.summary);
|
worker.set_resident_memory_injection(gates.summary);
|
||||||
let template = SystemPromptTemplate::parse(
|
let template = SystemPromptTemplate::parse(
|
||||||
"$yoi/default",
|
"default",
|
||||||
crate::prompt::loader::PromptLoader::builtins_only(),
|
crate::prompt::source::PromptCatalogSource::builtins_only(),
|
||||||
)
|
)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
worker.set_system_prompt_template(template);
|
worker.set_system_prompt_template(template);
|
||||||
|
|||||||
@@ -49,7 +49,7 @@ async fn restore_from_worker_metadata_rejects_missing_metadata() {
|
|||||||
"restore-test",
|
"restore-test",
|
||||||
manifest,
|
manifest,
|
||||||
store,
|
store,
|
||||||
worker::PromptLoader::builtins_only(),
|
worker::PromptCatalogSource::builtins_only(),
|
||||||
)
|
)
|
||||||
.await;
|
.await;
|
||||||
|
|
||||||
@@ -84,7 +84,7 @@ async fn restore_from_worker_metadata_rejects_pending_segment() {
|
|||||||
"restore-test",
|
"restore-test",
|
||||||
manifest,
|
manifest,
|
||||||
store,
|
store,
|
||||||
worker::PromptLoader::builtins_only(),
|
worker::PromptCatalogSource::builtins_only(),
|
||||||
)
|
)
|
||||||
.await;
|
.await;
|
||||||
|
|
||||||
@@ -126,7 +126,7 @@ async fn restore_from_worker_metadata_resolves_active_pointer_through_session_lo
|
|||||||
"restore-test",
|
"restore-test",
|
||||||
manifest,
|
manifest,
|
||||||
store,
|
store,
|
||||||
worker::PromptLoader::builtins_only(),
|
worker::PromptCatalogSource::builtins_only(),
|
||||||
)
|
)
|
||||||
.await;
|
.await;
|
||||||
|
|
||||||
@@ -158,7 +158,7 @@ async fn restore_from_manifest_rejects_unknown_segment() {
|
|||||||
unknown_seg,
|
unknown_seg,
|
||||||
manifest,
|
manifest,
|
||||||
store,
|
store,
|
||||||
worker::PromptLoader::builtins_only(),
|
worker::PromptCatalogSource::builtins_only(),
|
||||||
)
|
)
|
||||||
.await;
|
.await;
|
||||||
|
|
||||||
@@ -195,7 +195,7 @@ async fn restore_from_manifest_rejects_empty_segment_log() {
|
|||||||
segid,
|
segid,
|
||||||
manifest,
|
manifest,
|
||||||
store,
|
store,
|
||||||
worker::PromptLoader::builtins_only(),
|
worker::PromptCatalogSource::builtins_only(),
|
||||||
)
|
)
|
||||||
.await;
|
.await;
|
||||||
|
|
||||||
|
|||||||
@@ -11,7 +11,10 @@ use llm_engine::llm_client::{ClientError, LlmClient, Request};
|
|||||||
use session_store::{CombinedStore, FsWorkerStore};
|
use session_store::{CombinedStore, FsWorkerStore};
|
||||||
use session_store::{FsStore, LogEntry, Store};
|
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>;
|
type TestStore = CombinedStore<FsStore, FsWorkerStore>;
|
||||||
|
|
||||||
@@ -96,9 +99,9 @@ permission = "write"
|
|||||||
|
|
||||||
/// Build a Worker with a synthetic instruction template.
|
/// Build a Worker with a synthetic instruction template.
|
||||||
///
|
///
|
||||||
/// Writes `body` to a temp user-prompts dir under `$user/test`, builds a
|
/// Builds an immutable effective catalog with `body` at the exact `test`
|
||||||
/// PromptLoader pointing at it, parses the template, and installs it on
|
/// Prompt name and installs that parsed template on a directly constructed
|
||||||
/// a Worker constructed directly via `Worker::new`.
|
/// Worker.
|
||||||
async fn make_worker_with_body(
|
async fn make_worker_with_body(
|
||||||
body: &str,
|
body: &str,
|
||||||
client: MockClient,
|
client: MockClient,
|
||||||
@@ -117,10 +120,15 @@ async fn make_worker_with_body(
|
|||||||
let scope = worker::Scope::writable(&pwd).unwrap();
|
let scope = worker::Scope::writable(&pwd).unwrap();
|
||||||
std::mem::forget(pwd_tmp);
|
std::mem::forget(pwd_tmp);
|
||||||
|
|
||||||
let user_prompts_tmp = tempfile::tempdir().unwrap();
|
let mut templates = PromptCatalog::builtins_only()
|
||||||
std::fs::write(user_prompts_tmp.path().join("test.md"), body).unwrap();
|
.unwrap()
|
||||||
let loader = PromptLoader::new(Some(user_prompts_tmp.path().to_path_buf()), None);
|
.projection()
|
||||||
std::mem::forget(user_prompts_tmp);
|
.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 worker = Engine::new(client);
|
||||||
let mut worker = Worker::new(
|
let mut worker = Worker::new(
|
||||||
@@ -133,7 +141,7 @@ async fn make_worker_with_body(
|
|||||||
)
|
)
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
let template = SystemPromptTemplate::parse("$user/test", loader)
|
let template = SystemPromptTemplate::parse("test", loader)
|
||||||
.map_err(|source| WorkerError::InvalidSystemPromptTemplate { source })?;
|
.map_err(|source| WorkerError::InvalidSystemPromptTemplate { source })?;
|
||||||
worker.set_system_prompt_template(template);
|
worker.set_system_prompt_template(template);
|
||||||
|
|
||||||
@@ -146,15 +154,15 @@ async fn make_worker_with_body(
|
|||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn template_parse_rejects_invalid_syntax() {
|
async fn template_parse_rejects_invalid_syntax() {
|
||||||
let user_prompts_tmp = tempfile::tempdir().unwrap();
|
let mut templates = PromptCatalog::builtins_only()
|
||||||
std::fs::write(user_prompts_tmp.path().join("broken.md"), "{{ unclosed").unwrap();
|
.unwrap()
|
||||||
let loader = PromptLoader::new(Some(user_prompts_tmp.path().to_path_buf()), None);
|
.projection()
|
||||||
let err = SystemPromptTemplate::parse("$user/broken", loader).unwrap_err();
|
.templates
|
||||||
let worker_err: WorkerError = WorkerError::InvalidSystemPromptTemplate { source: err };
|
.clone();
|
||||||
assert!(matches!(
|
templates.insert("broken".to_string(), "{{ unclosed".to_string());
|
||||||
worker_err,
|
let error =
|
||||||
WorkerError::InvalidSystemPromptTemplate { .. }
|
EffectivePromptCatalog::new(templates, 1, "test-schema", "test-toolchain").unwrap_err();
|
||||||
));
|
assert!(error.to_string().contains("does not compile"));
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
use chrono::{SecondsFormat, Utc};
|
use chrono::{SecondsFormat, Utc};
|
||||||
use config_source::{
|
use config_source::{
|
||||||
ConfigContentType, ConfigEntry, ConfigSchemaContribution, ConfigTreeChange, ConfigTreeSnapshot,
|
ConfigContentType, ConfigDiagnostic, ConfigEntry, ConfigSchemaContribution, ConfigTreeChange,
|
||||||
DECODAL_VERSION, DEFAULT_IMPORT_POLICY_VERSION, DEFAULT_SCHEMA_VERSION, EvaluationResult,
|
ConfigTreeSnapshot, DECODAL_VERSION, DEFAULT_IMPORT_POLICY_VERSION, DEFAULT_SCHEMA_VERSION,
|
||||||
SnapshotEnvironment, ToolchainContract, VirtualPath, WorkspaceConfigSchemaBundle,
|
EvaluationResult, SnapshotEnvironment, ToolchainContract, VirtualPath,
|
||||||
|
WorkspaceConfigSchemaBundle,
|
||||||
};
|
};
|
||||||
use rusqlite::{OptionalExtension, TransactionBehavior, params};
|
use rusqlite::{OptionalExtension, TransactionBehavior, params};
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
@@ -11,6 +12,24 @@ use crate::{Error, Result, SqliteWorkspaceStore};
|
|||||||
|
|
||||||
pub const MAIN_CONFIG_ENTRYPOINT: &str = "main.dcdl";
|
pub const MAIN_CONFIG_ENTRYPOINT: &str = "main.dcdl";
|
||||||
pub const DEFAULT_MAIN_CONFIG_SOURCE: &str = "{}\n";
|
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 {
|
fn main_config_path() -> VirtualPath {
|
||||||
VirtualPath::parse(MAIN_CONFIG_ENTRYPOINT).expect("main config entrypoint is a valid path")
|
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(
|
fn main_config_contract_with_schema(
|
||||||
schema_bundle: WorkspaceConfigSchemaBundle,
|
schema_bundle: WorkspaceConfigSchemaBundle,
|
||||||
) -> ToolchainContract {
|
) -> 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)]
|
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, ts_rs::TS)]
|
||||||
#[ts(export)]
|
#[ts(export)]
|
||||||
pub struct WorkspaceConfigState {
|
pub struct WorkspaceConfigState {
|
||||||
@@ -98,12 +137,14 @@ pub struct ConfigPreviewRequest {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl SqliteWorkspaceStore {
|
impl SqliteWorkspaceStore {
|
||||||
pub fn ensure_workspace_config_materialized(
|
pub fn ensure_workspace_config_materialized_with_schema(
|
||||||
&self,
|
&self,
|
||||||
workspace_id: &str,
|
workspace_id: &str,
|
||||||
materialized_at: &str,
|
materialized_at: &str,
|
||||||
|
schema_bundle: WorkspaceConfigSchemaBundle,
|
||||||
) -> Result<WorkspaceConfigState> {
|
) -> 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 tx = conn.transaction_with_behavior(TransactionBehavior::Immediate)?;
|
||||||
let workspace_exists: bool = tx.query_row(
|
let workspace_exists: bool = tx.query_row(
|
||||||
"SELECT EXISTS(SELECT 1 FROM workspaces WHERE workspace_id = ?1)",
|
"SELECT EXISTS(SELECT 1 FROM workspaces WHERE workspace_id = ?1)",
|
||||||
@@ -113,17 +154,34 @@ impl SqliteWorkspaceStore {
|
|||||||
if !workspace_exists {
|
if !workspace_exists {
|
||||||
return Err(Error::WorkspaceIdMismatch);
|
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)? {
|
let state = match load_state(&tx, workspace_id)? {
|
||||||
Some(state) => state,
|
Some(state) => state,
|
||||||
None => {
|
None => {
|
||||||
let state = initial_state()?;
|
let state = initial_state_with_schema(schema_bundle.clone())?;
|
||||||
insert_materialized_state(&tx, workspace_id, &state, materialized_at)?;
|
insert_materialized_state(&tx, workspace_id, &state, materialized_at)?;
|
||||||
state
|
state
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
tx.commit()?;
|
tx.commit()?;
|
||||||
|
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)
|
Ok(state)
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn load_workspace_config(
|
pub fn load_workspace_config(
|
||||||
@@ -478,22 +536,42 @@ pub(crate) fn load_state(
|
|||||||
}
|
}
|
||||||
let entrypoints: Vec<VirtualPath> = serde_json::from_str(&entrypoints_json)
|
let entrypoints: Vec<VirtualPath> = serde_json::from_str(&entrypoints_json)
|
||||||
.map_err(|error| Error::RegistryInconsistency(error.to_string()))?;
|
.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)
|
Some(schema_bundle_json) => serde_json::from_str(&schema_bundle_json)
|
||||||
.map_err(|error| Error::RegistryInconsistency(error.to_string()))?,
|
.map_err(|error| Error::RegistryInconsistency(error.to_string()))?,
|
||||||
None => WorkspaceConfigSchemaBundle::empty(),
|
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(
|
let contract = ToolchainContract::with_schema_bundle(
|
||||||
schema_version,
|
schema_version,
|
||||||
entrypoints,
|
entrypoints,
|
||||||
import_policy_version,
|
import_policy_version,
|
||||||
schema_bundle,
|
schema_bundle,
|
||||||
);
|
);
|
||||||
if decodal_version != DECODAL_VERSION || contract.fingerprint != fingerprint {
|
if !requires_toolchain_refresh && contract.fingerprint != fingerprint {
|
||||||
return Err(Error::RegistryInconsistency(format!(
|
return Err(Error::RegistryInconsistency(format!(
|
||||||
"virtual config toolchain metadata mismatch for Workspace {workspace_id}"
|
"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 {
|
Ok(Some(WorkspaceConfigState {
|
||||||
snapshot,
|
snapshot,
|
||||||
contract,
|
contract,
|
||||||
@@ -502,6 +580,12 @@ pub(crate) fn load_state(
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub(crate) fn initial_state() -> Result<WorkspaceConfigState> {
|
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 path = main_config_path();
|
||||||
let snapshot = ConfigTreeSnapshot::empty()
|
let snapshot = ConfigTreeSnapshot::empty()
|
||||||
.apply(&[ConfigTreeChange::Create {
|
.apply(&[ConfigTreeChange::Create {
|
||||||
@@ -510,7 +594,7 @@ pub(crate) fn initial_state() -> Result<WorkspaceConfigState> {
|
|||||||
content: DEFAULT_MAIN_CONFIG_SOURCE.to_string(),
|
content: DEFAULT_MAIN_CONFIG_SOURCE.to_string(),
|
||||||
}])
|
}])
|
||||||
.map_err(config_error)?;
|
.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())
|
let projection_digest = SnapshotEnvironment::new(snapshot.clone())
|
||||||
.evaluate_contract(&contract)
|
.evaluate_contract(&contract)
|
||||||
.map_err(|diagnostics| {
|
.map_err(|diagnostics| {
|
||||||
@@ -873,6 +957,296 @@ mod tests {
|
|||||||
assert!(error.to_string().contains("toolchain fingerprint mismatch"));
|
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]
|
#[tokio::test]
|
||||||
async fn workspace_materializes_main_entrypoint() {
|
async fn workspace_materializes_main_entrypoint() {
|
||||||
let store = open_store().await;
|
let store = open_store().await;
|
||||||
|
|||||||
@@ -1201,6 +1201,21 @@ impl RuntimeRegistry {
|
|||||||
_ => {}
|
_ => {}
|
||||||
}
|
}
|
||||||
let runtime = self.runtime(runtime_id)?;
|
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))
|
Ok(runtime.spawn_worker(request))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1960,12 +1975,13 @@ impl WorkspaceWorkerRuntime for EmbeddedWorkerRuntime {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
let workspace_id = workspace_api.workspace_id.clone();
|
let workspace_id = workspace_api.workspace_id.clone();
|
||||||
|
let config_bundle = spawn_config_bundle_ref(&request);
|
||||||
let create_request = CreateWorkerRequest {
|
let create_request = CreateWorkerRequest {
|
||||||
idempotency_key,
|
idempotency_key,
|
||||||
idempotency_fingerprint,
|
idempotency_fingerprint,
|
||||||
profile,
|
profile,
|
||||||
display_name: request.requested_worker_name.clone(),
|
display_name: request.requested_worker_name.clone(),
|
||||||
config_bundle: None,
|
config_bundle,
|
||||||
profile_source,
|
profile_source,
|
||||||
initial_input: initial_worker_input(&request.initial_submit),
|
initial_input: initial_worker_input(&request.initial_submit),
|
||||||
working_directory_request: request.resolved_working_directory_request.clone(),
|
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 {
|
let create = CreateWorkerRequest {
|
||||||
idempotency_key,
|
idempotency_key,
|
||||||
idempotency_fingerprint,
|
idempotency_fingerprint,
|
||||||
profile,
|
profile,
|
||||||
display_name: request.requested_worker_name.clone(),
|
display_name: request.requested_worker_name.clone(),
|
||||||
config_bundle: None,
|
config_bundle,
|
||||||
profile_source,
|
profile_source,
|
||||||
initial_input: initial_worker_input(&request.initial_submit),
|
initial_input: initial_worker_input(&request.initial_submit),
|
||||||
working_directory_request: request.resolved_working_directory_request.clone(),
|
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(
|
fn profile_source_archive_for_request(
|
||||||
request: &WorkerSpawnRequest,
|
request: &WorkerSpawnRequest,
|
||||||
profile: &ProfileSelector,
|
profile: &ProfileSelector,
|
||||||
@@ -3469,6 +3496,7 @@ fn builtin_profile_config_bundle(
|
|||||||
label: embedded_profile_label(profile),
|
label: embedded_profile_label(profile),
|
||||||
}],
|
}],
|
||||||
declarations: Vec::new(),
|
declarations: Vec::new(),
|
||||||
|
prompt_catalog: None,
|
||||||
profile_source_archive,
|
profile_source_archive,
|
||||||
profile_source_archive_handle,
|
profile_source_archive_handle,
|
||||||
}
|
}
|
||||||
@@ -4409,6 +4437,7 @@ mod tests {
|
|||||||
name: "read".to_string(),
|
name: "read".to_string(),
|
||||||
reference: "capability:read".to_string(),
|
reference: "capability:read".to_string(),
|
||||||
}],
|
}],
|
||||||
|
prompt_catalog: None,
|
||||||
profile_source_archive: None,
|
profile_source_archive: None,
|
||||||
profile_source_archive_handle: 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]
|
#[test]
|
||||||
fn embedded_runtime_rejects_missing_workspace_api_binding() {
|
fn embedded_runtime_rejects_missing_workspace_api_binding() {
|
||||||
let runtime = EmbeddedWorkerRuntime::new_memory_with_execution_backend(
|
let runtime = EmbeddedWorkerRuntime::new_memory_with_execution_backend(
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ pub mod memory_backend;
|
|||||||
pub mod memory_staging;
|
pub mod memory_staging;
|
||||||
pub mod observation;
|
pub mod observation;
|
||||||
pub mod profile_settings;
|
pub mod profile_settings;
|
||||||
|
pub mod prompt_settings;
|
||||||
pub mod records;
|
pub mod records;
|
||||||
#[cfg(feature = "typescript")]
|
#[cfg(feature = "typescript")]
|
||||||
pub use records::ticket_api_typescript;
|
pub use records::ticket_api_typescript;
|
||||||
|
|||||||
@@ -42,11 +42,16 @@ struct WorkspacePathOptions {
|
|||||||
workspace: PathBuf,
|
workspace: PathBuf,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Debug)]
|
||||||
|
struct SkillWorkspaceOptions {
|
||||||
|
workspace_id: String,
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Debug)]
|
#[derive(Debug)]
|
||||||
enum SkillsCommand {
|
enum SkillsCommand {
|
||||||
List(WorkspacePathOptions),
|
List(SkillWorkspaceOptions),
|
||||||
Lint(WorkspacePathOptions),
|
Lint(SkillWorkspaceOptions),
|
||||||
Show { workspace: PathBuf, name: String },
|
Show { workspace_id: String, name: String },
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug)]
|
#[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>> {
|
fn run_skills(command: SkillsCommand) -> Result<(), Box<dyn std::error::Error>> {
|
||||||
match command {
|
match command {
|
||||||
SkillsCommand::List(options) => {
|
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)?);
|
println!("{}", serde_json::to_string_pretty(&catalog)?);
|
||||||
}
|
}
|
||||||
SkillsCommand::Lint(options) => {
|
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)?);
|
println!("{}", serde_json::to_string_pretty(&catalog)?);
|
||||||
if catalog
|
if catalog
|
||||||
.diagnostics
|
.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())));
|
return Err(Box::new(CliError("Skill lint found errors".to_string())));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
SkillsCommand::Show { workspace, name } => {
|
SkillsCommand::Show { workspace_id, name } => {
|
||||||
let detail = yoi_workspace_server::skills::detail(&workspace, &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)?);
|
println!("{}", serde_json::to_string_pretty(&detail)?);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Ok(())
|
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>> {
|
async fn run_serve(options: ServeOptions) -> Result<(), Box<dyn std::error::Error>> {
|
||||||
let database_path = ServerConfig::default_server_database_path();
|
let database_path = ServerConfig::default_server_database_path();
|
||||||
if let Some(parent) = database_path.parent() {
|
if let Some(parent) = database_path.parent() {
|
||||||
@@ -706,17 +725,17 @@ fn parse_skills_command(args: &[String]) -> Result<Command, CliError> {
|
|||||||
};
|
};
|
||||||
match subcommand.as_str() {
|
match subcommand.as_str() {
|
||||||
"list" => Ok(Command::Skills(SkillsCommand::List(
|
"list" => Ok(Command::Skills(SkillsCommand::List(
|
||||||
parse_workspace_path_options(rest)?,
|
parse_skill_workspace_options(rest)?,
|
||||||
))),
|
))),
|
||||||
"lint" => Ok(Command::Skills(SkillsCommand::Lint(
|
"lint" => Ok(Command::Skills(SkillsCommand::Lint(
|
||||||
parse_workspace_path_options(rest)?,
|
parse_skill_workspace_options(rest)?,
|
||||||
))),
|
))),
|
||||||
"show" => {
|
"show" => {
|
||||||
let Some((name, rest)) = rest.split_first() else {
|
let Some((name, rest)) = rest.split_first() else {
|
||||||
return Err(CliError("skills show requires a Skill name".to_string()));
|
return Err(CliError("skills show requires a Skill name".to_string()));
|
||||||
};
|
};
|
||||||
Ok(Command::Skills(SkillsCommand::Show {
|
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(),
|
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> {
|
fn parse_workspace_path_options(args: &[String]) -> Result<WorkspacePathOptions, CliError> {
|
||||||
let mut workspace = std::env::current_dir()
|
let mut workspace = std::env::current_dir()
|
||||||
.map_err(|error| CliError(format!("failed to read current dir: {error}")))?;
|
.map_err(|error| CliError(format!("failed to read current dir: {error}")))?;
|
||||||
@@ -848,7 +893,7 @@ fn print_config_help() {
|
|||||||
|
|
||||||
fn print_skills_help() {
|
fn print_skills_help() {
|
||||||
println!(
|
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());
|
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]
|
#[test]
|
||||||
fn parse_serve_accepts_listen_only() {
|
fn parse_serve_accepts_listen_only() {
|
||||||
let args = vec!["--listen".to_string(), "127.0.0.1:0".to_string()];
|
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,
|
BackendObservationProxy, ObservationProxyError, RuntimeObservationClient,
|
||||||
RuntimeObservationSource, RuntimeObservationSourceConfig,
|
RuntimeObservationSource, RuntimeObservationSourceConfig,
|
||||||
};
|
};
|
||||||
use crate::profile_settings::{
|
use crate::profile_settings::UpdateWorkspaceMetadataRequest;
|
||||||
CreateWorkspaceProfileSourceRequest, DeleteWorkspaceProfileSourceRequest,
|
|
||||||
DeleteWorkspaceProfileTreeFileRequest, ReadWorkspaceProfileTreeFileQuery,
|
|
||||||
UpdateWorkspaceMetadataRequest, UpdateWorkspaceProfileRegistryRequest,
|
|
||||||
UpdateWorkspaceProfileSourceRequest, WriteWorkspaceProfileTreeFileRequest,
|
|
||||||
};
|
|
||||||
use crate::records::{ObjectiveDetail, ProjectRecordList, TicketDetail};
|
use crate::records::{ObjectiveDetail, ProjectRecordList, TicketDetail};
|
||||||
use crate::repositories::{
|
use crate::repositories::{
|
||||||
ConfiguredRepository, RepositoryListProjection, RepositoryLogRead, RepositoryLookupError,
|
ConfiguredRepository, RepositoryListProjection, RepositoryLogRead, RepositoryLookupError,
|
||||||
@@ -245,10 +240,7 @@ impl ServerConfig {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const ORCHESTRATOR_ATTENTION_TICKET_LIMIT: usize = 20;
|
const ORCHESTRATOR_ATTENTION_TICKET_LIMIT: usize = 20;
|
||||||
const ORCHESTRATOR_ATTENTION_PROMPT: &str = include_str!(concat!(
|
const ORCHESTRATOR_ATTENTION_PROMPT_NAME: &str = "internal.workspace_orchestrator_queue_attention";
|
||||||
env!("CARGO_MANIFEST_DIR"),
|
|
||||||
"/../../resources/prompts/internal/workspace_orchestrator_queue_attention.md"
|
|
||||||
));
|
|
||||||
|
|
||||||
#[derive(Clone)]
|
#[derive(Clone)]
|
||||||
pub struct WorkspaceApi {
|
pub struct WorkspaceApi {
|
||||||
@@ -756,9 +748,20 @@ impl WorkspaceApi {
|
|||||||
let config_store = Arc::new(crate::SqliteWorkspaceStore::open(
|
let config_store = Arc::new(crate::SqliteWorkspaceStore::open(
|
||||||
config.database_path.clone(),
|
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 {
|
let api = Self {
|
||||||
config_store,
|
config_store,
|
||||||
config_schema_registry: crate::config_source::WorkspaceConfigSchemaRegistry::default(),
|
config_schema_registry,
|
||||||
authority: SqliteWorkspaceAuthority::new(
|
authority: SqliteWorkspaceAuthority::new(
|
||||||
config.database_path.clone(),
|
config.database_path.clone(),
|
||||||
config.workspace_id.clone(),
|
config.workspace_id.clone(),
|
||||||
@@ -1171,27 +1174,7 @@ pub fn build_router(api: WorkspaceApi) -> Router {
|
|||||||
)
|
)
|
||||||
.route(
|
.route(
|
||||||
"/api/w/{workspace_id}/settings/profiles",
|
"/api/w/{workspace_id}/settings/profiles",
|
||||||
get(scoped_get_profile_settings).post(scoped_create_profile_source),
|
get(scoped_get_profile_settings),
|
||||||
)
|
|
||||||
.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),
|
|
||||||
)
|
)
|
||||||
.route(
|
.route(
|
||||||
"/api/w/{workspace_id}/flows",
|
"/api/w/{workspace_id}/flows",
|
||||||
@@ -2537,13 +2520,13 @@ async fn scoped_preview_workspace_config_tree(
|
|||||||
Json(request): Json<ConfigPreviewRequest>,
|
Json(request): Json<ConfigPreviewRequest>,
|
||||||
) -> ApiResult<Json<crate::config_source::EvaluatedConfigCandidate>> {
|
) -> ApiResult<Json<crate::config_source::EvaluatedConfigCandidate>> {
|
||||||
validate_workspace_scope(&api, &path.workspace_id)?;
|
validate_workspace_scope(&api, &path.workspace_id)?;
|
||||||
Ok(Json(
|
let candidate = api.config_store.preview_workspace_config_with_schema(
|
||||||
api.config_store.preview_workspace_config_with_schema(
|
|
||||||
&path.workspace_id,
|
&path.workspace_id,
|
||||||
&request,
|
&request,
|
||||||
api.config_schema_registry.compose()?,
|
api.config_schema_registry.compose()?,
|
||||||
)?,
|
)?;
|
||||||
))
|
crate::prompt_settings::validate_evaluated_prompt_catalog(&candidate.evaluation)?;
|
||||||
|
Ok(Json(candidate))
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn scoped_commit_workspace_config_tree(
|
async fn scoped_commit_workspace_config_tree(
|
||||||
@@ -2559,6 +2542,7 @@ async fn scoped_commit_workspace_config_tree(
|
|||||||
&request,
|
&request,
|
||||||
api.config_schema_registry.compose()?,
|
api.config_schema_registry.compose()?,
|
||||||
)?;
|
)?;
|
||||||
|
crate::prompt_settings::validate_evaluated_prompt_catalog(&candidate.evaluation)?;
|
||||||
let state = api
|
let state = api
|
||||||
.config_store
|
.config_store
|
||||||
.commit_evaluated_workspace_config(&path.workspace_id, &candidate)?;
|
.commit_evaluated_workspace_config(&path.workspace_id, &candidate)?;
|
||||||
@@ -2577,130 +2561,19 @@ async fn scoped_get_profile_settings(
|
|||||||
AxumPath(path): AxumPath<ScopedWorkspacePath>,
|
AxumPath(path): AxumPath<ScopedWorkspacePath>,
|
||||||
) -> ApiResult<Json<crate::profile_settings::ProfileSettingsResponse>> {
|
) -> ApiResult<Json<crate::profile_settings::ProfileSettingsResponse>> {
|
||||||
validate_workspace_scope(&api, &path.workspace_id)?;
|
validate_workspace_scope(&api, &path.workspace_id)?;
|
||||||
Ok(Json(crate::profile_settings::load_profile_settings(
|
let state = api
|
||||||
&api.config.workspace_id,
|
.config_store
|
||||||
&api.config.workspace_root,
|
.load_workspace_config(&path.workspace_id)?
|
||||||
)))
|
.ok_or_else(|| {
|
||||||
}
|
ApiError::from(Error::InvalidRecordId("virtual config source tree".into()))
|
||||||
|
})?;
|
||||||
async fn scoped_create_profile_source(
|
Ok(Json(
|
||||||
State(api): State<WorkspaceApi>,
|
crate::profile_settings::project_profiles_from_workspace_config(
|
||||||
AxumPath(path): AxumPath<ScopedWorkspacePath>,
|
&path.workspace_id,
|
||||||
Json(request): Json<CreateWorkspaceProfileSourceRequest>,
|
&state,
|
||||||
) -> ApiResult<Json<crate::profile_settings::ProfileSettingsMutationResponse>> {
|
)?
|
||||||
validate_workspace_scope(&api, &path.workspace_id)?;
|
.settings,
|
||||||
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,
|
|
||||||
)?))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn scoped_list_tickets(
|
async fn scoped_list_tickets(
|
||||||
@@ -4935,10 +4808,31 @@ fn dispatch_orchestrator_queue_attention(api: &WorkspaceApi) {
|
|||||||
} else {
|
} else {
|
||||||
format!("Additional queued Tickets omitted from this notice: {omitted}\n")
|
format!("Additional queued Tickets omitted from this notice: {omitted}\n")
|
||||||
};
|
};
|
||||||
let content = ORCHESTRATOR_ATTENTION_PROMPT
|
let Ok(Some(config_state)) = api
|
||||||
.replace("{{omitted_line}}", &omitted_line)
|
.config_store
|
||||||
.replace("{{workspace_id}}", &api.config.workspace_id)
|
.load_workspace_config(&api.config.workspace_id)
|
||||||
.replace("{{ticket_lines}}", &shown);
|
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
|
let accepted = api
|
||||||
.runtime
|
.runtime
|
||||||
.send_input(
|
.send_input(
|
||||||
@@ -5134,12 +5028,7 @@ fn start_memory_staging_consolidation(
|
|||||||
}
|
}
|
||||||
|
|
||||||
let profile_selector = ProfileSelector::Builtin(MEMORY_CONSOLIDATION_PROFILE.to_string());
|
let profile_selector = ProfileSelector::Builtin(MEMORY_CONSOLIDATION_PROFILE.to_string());
|
||||||
let resolved_config_bundle = crate::profile_settings::build_workspace_profile_config_bundle(
|
let resolved_config_bundle = None;
|
||||||
&api.config.workspace_root,
|
|
||||||
&api.config.workspace_id,
|
|
||||||
&api.config.workspace_created_at,
|
|
||||||
MEMORY_CONSOLIDATION_PROFILE,
|
|
||||||
)?;
|
|
||||||
let initial_submit = vec![Segment::text(input_content)];
|
let initial_submit = vec![Segment::text(input_content)];
|
||||||
let result = api.spawn_workspace_worker(
|
let result = api.spawn_workspace_worker(
|
||||||
&runtime_id,
|
&runtime_id,
|
||||||
@@ -5300,7 +5189,16 @@ async fn scoped_list_skills(
|
|||||||
AxumPath(path): AxumPath<ScopedWorkspacePath>,
|
AxumPath(path): AxumPath<ScopedWorkspacePath>,
|
||||||
) -> ApiResult<Json<worker::skill::SkillCatalogResponse>> {
|
) -> ApiResult<Json<worker::skill::SkillCatalogResponse>> {
|
||||||
validate_workspace_scope(&api, &path.workspace_id)?;
|
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(
|
async fn scoped_lint_skills(
|
||||||
@@ -5308,7 +5206,16 @@ async fn scoped_lint_skills(
|
|||||||
AxumPath(path): AxumPath<ScopedWorkspacePath>,
|
AxumPath(path): AxumPath<ScopedWorkspacePath>,
|
||||||
) -> ApiResult<Json<worker::skill::SkillCatalogResponse>> {
|
) -> ApiResult<Json<worker::skill::SkillCatalogResponse>> {
|
||||||
validate_workspace_scope(&api, &path.workspace_id)?;
|
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(
|
async fn scoped_get_skill(
|
||||||
@@ -5316,7 +5223,16 @@ async fn scoped_get_skill(
|
|||||||
AxumPath(path): AxumPath<ScopedSkillPath>,
|
AxumPath(path): AxumPath<ScopedSkillPath>,
|
||||||
) -> ApiResult<Json<worker::skill::SkillDetailResponse>> {
|
) -> ApiResult<Json<worker::skill::SkillDetailResponse>> {
|
||||||
validate_workspace_scope(&api, &path.workspace_id)?;
|
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(Json)
|
||||||
.map_err(skill_api_error)
|
.map_err(skill_api_error)
|
||||||
}
|
}
|
||||||
@@ -5326,7 +5242,16 @@ async fn scoped_activate_skill(
|
|||||||
AxumPath(path): AxumPath<ScopedSkillPath>,
|
AxumPath(path): AxumPath<ScopedSkillPath>,
|
||||||
) -> ApiResult<Json<worker::skill::SkillActivationResponse>> {
|
) -> ApiResult<Json<worker::skill::SkillActivationResponse>> {
|
||||||
validate_workspace_scope(&api, &path.workspace_id)?;
|
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(Json)
|
||||||
.map_err(skill_api_error)
|
.map_err(skill_api_error)
|
||||||
}
|
}
|
||||||
@@ -5345,7 +5270,14 @@ fn skill_api_error(error: skills::SkillError) -> ApiError {
|
|||||||
message: format!("unknown Skill `{name}`"),
|
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(
|
async fn get_worker_launch_options(
|
||||||
State(api): State<WorkspaceApi>,
|
State(api): State<WorkspaceApi>,
|
||||||
) -> ApiResult<Json<WorkerLaunchOptionsResponse>> {
|
) -> ApiResult<Json<WorkerLaunchOptionsResponse>> {
|
||||||
Ok(Json(worker_launch_options_response(&api)))
|
Ok(Json(worker_launch_options_response(&api)?))
|
||||||
}
|
}
|
||||||
|
|
||||||
fn working_directory_request_from_repository(
|
fn working_directory_request_from_repository(
|
||||||
@@ -8298,18 +8230,20 @@ async fn create_workspace_worker(
|
|||||||
initial_submit,
|
initial_submit,
|
||||||
working_directory,
|
working_directory,
|
||||||
} = request;
|
} = 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
|
let profile = profile
|
||||||
.as_deref()
|
.as_deref()
|
||||||
.map(str::trim)
|
.map(str::trim)
|
||||||
.filter(|profile| !profile.is_empty())
|
.filter(|profile| !profile.is_empty())
|
||||||
.map(ToOwned::to_owned)
|
.map(ToOwned::to_owned)
|
||||||
.or_else(|| {
|
.or_else(|| profile_projection.settings.default_profile.clone())
|
||||||
crate::profile_settings::load_profile_settings(
|
|
||||||
&api.config.workspace_id,
|
|
||||||
&api.config.workspace_root,
|
|
||||||
)
|
|
||||||
.default_profile
|
|
||||||
})
|
|
||||||
.ok_or_else(|| {
|
.ok_or_else(|| {
|
||||||
settings_bad_request(
|
settings_bad_request(
|
||||||
"workspace_default_profile_missing",
|
"workspace_default_profile_missing",
|
||||||
@@ -8317,24 +8251,20 @@ async fn create_workspace_worker(
|
|||||||
)
|
)
|
||||||
})?;
|
})?;
|
||||||
let profile_selector =
|
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(
|
settings_bad_request(
|
||||||
"unsupported_worker_profile",
|
"unsupported_worker_profile",
|
||||||
"profile must be selected from Backend-published worker profile candidates",
|
"profile must be selected from Backend-published worker profile candidates",
|
||||||
)
|
)
|
||||||
},
|
})?;
|
||||||
)?;
|
let resolved_config_bundle = crate::profile_settings::build_virtual_profile_config_bundle(
|
||||||
let resolved_config_bundle = if profile.starts_with("project:") {
|
&profile_projection,
|
||||||
crate::profile_settings::build_workspace_profile_config_bundle(
|
&config_state,
|
||||||
&api.config.workspace_root,
|
|
||||||
&api.config.workspace_id,
|
&api.config.workspace_id,
|
||||||
&api.config.workspace_created_at,
|
&api.config.workspace_created_at,
|
||||||
&profile,
|
&profile,
|
||||||
)?
|
)?;
|
||||||
} else {
|
|
||||||
None
|
|
||||||
};
|
|
||||||
let display_name = sanitize_worker_display_name(&display_name).ok_or_else(|| {
|
let display_name = sanitize_worker_display_name(&display_name).ok_or_else(|| {
|
||||||
settings_bad_request(
|
settings_bad_request(
|
||||||
"invalid_worker_display_name",
|
"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
|
let runtimes = api
|
||||||
.runtime
|
.runtime
|
||||||
.list_runtimes(api.config.max_records.min(200))
|
.list_runtimes(api.config.max_records.min(200))
|
||||||
@@ -10456,10 +10386,17 @@ fn worker_launch_options_response(api: &WorkspaceApi) -> WorkerLaunchOptionsResp
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
.collect();
|
.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_id,
|
||||||
&api.config.workspace_root,
|
&config_state,
|
||||||
);
|
)?
|
||||||
|
.settings;
|
||||||
let profiles = profile_settings
|
let profiles = profile_settings
|
||||||
.profiles
|
.profiles
|
||||||
.into_iter()
|
.into_iter()
|
||||||
@@ -10477,7 +10414,7 @@ fn worker_launch_options_response(api: &WorkspaceApi) -> WorkerLaunchOptionsResp
|
|||||||
.unwrap_or_else(|| "Workspace profile.".to_string()),
|
.unwrap_or_else(|| "Workspace profile.".to_string()),
|
||||||
})
|
})
|
||||||
.collect();
|
.collect();
|
||||||
WorkerLaunchOptionsResponse {
|
Ok(WorkerLaunchOptionsResponse {
|
||||||
workspace_id: api.config.workspace_id.clone(),
|
workspace_id: api.config.workspace_id.clone(),
|
||||||
runtimes,
|
runtimes,
|
||||||
default_profile: profile_settings.default_profile,
|
default_profile: profile_settings.default_profile,
|
||||||
@@ -10485,7 +10422,7 @@ fn worker_launch_options_response(api: &WorkspaceApi) -> WorkerLaunchOptionsResp
|
|||||||
repositories: working_directory_repository_options(api),
|
repositories: working_directory_repository_options(api),
|
||||||
working_directories: available_working_directory_summaries(api).unwrap_or_default(),
|
working_directories: available_working_directory_summaries(api).unwrap_or_default(),
|
||||||
diagnostics: Vec::new(),
|
diagnostics: Vec::new(),
|
||||||
}
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
fn working_directory_repository_options(
|
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> {
|
fn parse_runtime_worker_id_for_registry(worker_id: &str) -> ApiResult<u64> {
|
||||||
worker_id.parse::<u64>().map_err(|_| {
|
worker_id.parse::<u64>().map_err(|_| {
|
||||||
settings_bad_request(
|
settings_bad_request(
|
||||||
@@ -12776,93 +12692,6 @@ mod tests {
|
|||||||
assert!(!serialized.contains("materialized_path"));
|
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]
|
#[test]
|
||||||
fn runtime_connection_request_validation_bounds_browser_input() {
|
fn runtime_connection_request_validation_bounds_browser_input() {
|
||||||
let ok = AddRemoteRuntimeConnectionRequest {
|
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 {
|
struct DeterministicExecutionBackend {
|
||||||
contexts: std::sync::Mutex<
|
contexts: std::sync::Mutex<
|
||||||
std::collections::HashMap<
|
std::collections::HashMap<
|
||||||
@@ -13405,14 +13022,7 @@ mod tests {
|
|||||||
)
|
)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
|
|
||||||
let resolved_config_bundle =
|
let resolved_config_bundle = None;
|
||||||
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 existing = api
|
let existing = api
|
||||||
.runtime
|
.runtime
|
||||||
.spawn_worker(
|
.spawn_worker(
|
||||||
@@ -14463,16 +14073,60 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[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 dir = tempfile::tempdir().unwrap();
|
||||||
let skill_dir = dir.path().join(".yoi/skills/triage-errors");
|
let skill_dir = dir.path().join(".yoi/skills/triage-errors");
|
||||||
fs::create_dir_all(&skill_dir).unwrap();
|
fs::create_dir_all(&skill_dir).unwrap();
|
||||||
fs::write(
|
fs::write(
|
||||||
skill_dir.join("SKILL.md"),
|
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();
|
.unwrap();
|
||||||
let api = test_api(dir.path()).await;
|
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(
|
let Json(catalog) = scoped_list_skills(
|
||||||
State(api.clone()),
|
State(api.clone()),
|
||||||
@@ -14488,6 +14142,13 @@ mod tests {
|
|||||||
.find(|entry| entry.name == "triage-errors")
|
.find(|entry| entry.name == "triage-errors")
|
||||||
.expect("workspace Skill catalog entry");
|
.expect("workspace Skill catalog entry");
|
||||||
assert_eq!(entry.provenance.id, "workspace:triage-errors");
|
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!(
|
assert!(
|
||||||
!serde_json::to_string(&catalog)
|
!serde_json::to_string(&catalog)
|
||||||
.unwrap()
|
.unwrap()
|
||||||
@@ -15615,6 +15276,7 @@ mod tests {
|
|||||||
label: Some("server-test".to_string()),
|
label: Some("server-test".to_string()),
|
||||||
}],
|
}],
|
||||||
declarations: Vec::new(),
|
declarations: Vec::new(),
|
||||||
|
prompt_catalog: None,
|
||||||
profile_source_archive: None,
|
profile_source_archive: None,
|
||||||
profile_source_archive_handle: None,
|
profile_source_archive_handle: None,
|
||||||
}
|
}
|
||||||
@@ -16064,12 +15726,6 @@ mod tests {
|
|||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn browser_worker_create_uses_workspace_default_and_preserves_unsupported_diagnostics() {
|
async fn browser_worker_create_uses_workspace_default_and_preserves_unsupported_diagnostics() {
|
||||||
let dir = tempfile::tempdir().unwrap();
|
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 app = test_app(dir.path()).await;
|
||||||
let created = post_json(
|
let created = post_json(
|
||||||
app.clone(),
|
app.clone(),
|
||||||
@@ -16090,7 +15746,7 @@ mod tests {
|
|||||||
.find(|worker| worker["worker_id"] == created["worker_id"])
|
.find(|worker| worker["worker_id"] == created["worker_id"])
|
||||||
.expect("created Worker should be listed");
|
.expect("created Worker should be listed");
|
||||||
assert_eq!(worker["label"], "Worker");
|
assert_eq!(worker["label"], "Worker");
|
||||||
assert_eq!(worker["profile"], "builtin:coder");
|
assert_eq!(worker["profile"], "builtin:companion");
|
||||||
assert!(worker.get("role").is_none());
|
assert!(worker.get("role").is_none());
|
||||||
assert_eq!(worker["worker_id"], created["worker_id"]);
|
assert_eq!(worker["worker_id"], created["worker_id"]);
|
||||||
let detail_path = format!(
|
let detail_path = format!(
|
||||||
@@ -17745,6 +17401,37 @@ mod tests {
|
|||||||
assert_eq!(shown["linked_tickets"], json!(["00000000001J3"]));
|
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 {
|
async fn get_json(app: Router, uri: &str) -> Value {
|
||||||
let response = app
|
let response = app
|
||||||
.oneshot(Request::builder().uri(uri).body(Body::empty()).unwrap())
|
.oneshot(Request::builder().uri(uri).body(Body::empty()).unwrap())
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -121,19 +121,19 @@ root = ".yoi/tickets"
|
|||||||
|
|
||||||
[ticket.roles.intake]
|
[ticket.roles.intake]
|
||||||
profile = "project:intake"
|
profile = "project:intake"
|
||||||
launch_prompt = "$workspace/ticket/intake/launch"
|
launch_prompt = "ticket.intake.launch"
|
||||||
|
|
||||||
[ticket.roles.orchestrator]
|
[ticket.roles.orchestrator]
|
||||||
profile = "project:orchestrator"
|
profile = "project:orchestrator"
|
||||||
launch_prompt = "$workspace/ticket/orchestrator/launch"
|
launch_prompt = "ticket.orchestrator.launch"
|
||||||
|
|
||||||
[ticket.roles.coder]
|
[ticket.roles.coder]
|
||||||
profile = "project:coder"
|
profile = "project:coder"
|
||||||
launch_prompt = "$workspace/ticket/coder/launch"
|
launch_prompt = "ticket.coder.launch"
|
||||||
|
|
||||||
[ticket.roles.reviewer]
|
[ticket.roles.reviewer]
|
||||||
profile = "project:reviewer"
|
profile = "project:reviewer"
|
||||||
launch_prompt = "$workspace/ticket/reviewer/launch"
|
launch_prompt = "ticket.reviewer.launch"
|
||||||
```
|
```
|
||||||
|
|
||||||
Fixed roles are:
|
Fixed roles are:
|
||||||
|
|||||||
+3
-10
@@ -30,12 +30,6 @@
|
|||||||
# 必須。Worker の表示名 (ResolveError::MissingField("worker.name") の対象)。
|
# 必須。Worker の表示名 (ResolveError::MissingField("worker.name") の対象)。
|
||||||
name = "example-agent"
|
name = "example-agent"
|
||||||
|
|
||||||
# 任意。デフォルト: なし。
|
|
||||||
# PromptCatalog の 4 つ目の overlay 層として読み込む TOML pack のパス。
|
|
||||||
# 相対パスは manifest base 起点で解決。`worker.instruction` (`$prefix/...`)
|
|
||||||
# とは別系統の単なるファイルパス。
|
|
||||||
# prompt_pack = "./prompts.local.toml"
|
|
||||||
|
|
||||||
|
|
||||||
# ===== [model] ==============================================================
|
# ===== [model] ==============================================================
|
||||||
# LLM モデル設定。次の 3 形態を受ける:
|
# LLM モデル設定。次の 3 形態を受ける:
|
||||||
@@ -95,10 +89,9 @@ ref = "anthropic/claude-sonnet-4-6"
|
|||||||
# ワーカーの生成パラメータ等。セクション自体省略可 (全フィールド任意)。
|
# ワーカーの生成パラメータ等。セクション自体省略可 (全フィールド任意)。
|
||||||
[engine]
|
[engine]
|
||||||
|
|
||||||
# 任意。デフォルト: "$yoi/default" (`defaults::DEFAULT_INSTRUCTION`)。
|
# 任意。デフォルト: "default" (`defaults::DEFAULT_INSTRUCTION`)。
|
||||||
# システムプロンプト本体の `PromptLoader` 参照。
|
# effective Prompt catalog の exact dotted name を選択する。
|
||||||
# プレフィクス: "$yoi/..." | "$user/..." | "$workspace/..."
|
# instruction = "default"
|
||||||
# instruction = "$yoi/default"
|
|
||||||
|
|
||||||
# 任意。デフォルト: なし (プロバイダ任せ)。
|
# 任意。デフォルト: なし (プロバイダ任せ)。
|
||||||
# 1 レスポンスあたりの出力 token 上限。
|
# 1 レスポンスあたりの出力 token 上限。
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import "./base.dcdl" // {
|
|||||||
slug = "coder";
|
slug = "coder";
|
||||||
description = "Ticket implementation coder profile.";
|
description = "Ticket implementation coder profile.";
|
||||||
scope = "workspace_write";
|
scope = "workspace_write";
|
||||||
|
engine = { instruction = "role.coder"; };
|
||||||
|
|
||||||
feature = {
|
feature = {
|
||||||
task = { enabled = true; };
|
task = { enabled = true; };
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import "./base.dcdl" // {
|
|||||||
slug = "intake";
|
slug = "intake";
|
||||||
description = "Ticket intake profile.";
|
description = "Ticket intake profile.";
|
||||||
scope = "workspace_write";
|
scope = "workspace_write";
|
||||||
|
engine = { instruction = "role.intake"; };
|
||||||
|
|
||||||
feature = {
|
feature = {
|
||||||
task = { enabled = true; };
|
task = { enabled = true; };
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import "./base.dcdl" // {
|
|||||||
slug = "orchestrator";
|
slug = "orchestrator";
|
||||||
description = "Ticket orchestrator profile.";
|
description = "Ticket orchestrator profile.";
|
||||||
scope = "workspace_write";
|
scope = "workspace_write";
|
||||||
|
engine = { instruction = "role.orchestrator"; };
|
||||||
|
|
||||||
feature = {
|
feature = {
|
||||||
task = { enabled = true; };
|
task = { enabled = true; };
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import "./base.dcdl" // {
|
|||||||
slug = "reviewer";
|
slug = "reviewer";
|
||||||
description = "Ticket review profile.";
|
description = "Ticket review profile.";
|
||||||
scope = "workspace_read";
|
scope = "workspace_read";
|
||||||
|
engine = { instruction = "role.reviewer"; };
|
||||||
|
|
||||||
feature = {
|
feature = {
|
||||||
task = { enabled = true; };
|
task = { enabled = true; };
|
||||||
|
|||||||
@@ -0,0 +1,71 @@
|
|||||||
|
# Deterministic builtin Prompt source tree. Markdown imports use the shared
|
||||||
|
# { frontmatter, content } contract; every leaf below selects only .content.
|
||||||
|
# `default_prompt` is the DCDL source alias for the effective catalog's
|
||||||
|
# reserved dotted name `default`.
|
||||||
|
let
|
||||||
|
defaultDocument = import "./default.md";
|
||||||
|
commonLanguage = import "./common/language.md";
|
||||||
|
commonTickets = import "./common/tickets.md";
|
||||||
|
commonToolUsage = import "./common/tool-usage.md";
|
||||||
|
commonWorkerObservation = import "./common/worker-observation.md";
|
||||||
|
commonWorkerOrchestration = import "./common/worker-orchestration.md";
|
||||||
|
commonWorkspace = import "./common/workspace.md";
|
||||||
|
commonWriting = import "./common/writing.md";
|
||||||
|
roleCoder = import "./role/coder.md";
|
||||||
|
roleIntake = import "./role/intake.md";
|
||||||
|
roleOrchestrator = import "./role/orchestrator.md";
|
||||||
|
roleReviewer = import "./role/reviewer.md";
|
||||||
|
internalCompactSystem = import "./internal/compact_system.md";
|
||||||
|
internalFlowVerifierSystem = import "./internal/flow_verifier_system.md";
|
||||||
|
internalMemoryConsolidationSystem = import "./internal/memory_consolidation_system.md";
|
||||||
|
internalMemoryExtractSystem = import "./internal/memory_extract_system.md";
|
||||||
|
internalWorkspaceOrchestratorQueueAttention = import "./internal/workspace_orchestrator_queue_attention.md";
|
||||||
|
internalNotifyWrapper = import "./internal/notify_wrapper.md";
|
||||||
|
internalInterruptToolResultSummary = import "./internal/interrupt_tool_result_summary.md";
|
||||||
|
internalInterruptSystemNote = import "./internal/interrupt_system_note.md";
|
||||||
|
internalWorkingBoundariesSection = import "./internal/working_boundaries_section.md";
|
||||||
|
internalAgentsMdSection = import "./internal/agents_md_section.md";
|
||||||
|
internalResidentMemorySummarySection = import "./internal/resident_memory_summary_section.md";
|
||||||
|
internalSubWorkerSpawnToolDescription = import "./internal/sub_worker_spawn_tool_description.md";
|
||||||
|
panelOrchestratorIdleQueueNotice = import "./panel/orchestrator_idle_queue_notice.md";
|
||||||
|
workerTicketEventCompanionNotice = import "./worker/ticket_event_companion_notice.md";
|
||||||
|
in
|
||||||
|
{
|
||||||
|
default_prompt = defaultDocument.content;
|
||||||
|
common = {
|
||||||
|
language = commonLanguage.content;
|
||||||
|
tickets = commonTickets.content;
|
||||||
|
tool_usage = commonToolUsage.content;
|
||||||
|
worker_observation = commonWorkerObservation.content;
|
||||||
|
worker_orchestration = commonWorkerOrchestration.content;
|
||||||
|
workspace = commonWorkspace.content;
|
||||||
|
writing = commonWriting.content;
|
||||||
|
};
|
||||||
|
role = {
|
||||||
|
coder = roleCoder.content;
|
||||||
|
intake = roleIntake.content;
|
||||||
|
orchestrator = roleOrchestrator.content;
|
||||||
|
reviewer = roleReviewer.content;
|
||||||
|
};
|
||||||
|
internal = {
|
||||||
|
compact_system = internalCompactSystem.content;
|
||||||
|
flow_verifier_system = internalFlowVerifierSystem.content;
|
||||||
|
memory_consolidation_system = internalMemoryConsolidationSystem.content;
|
||||||
|
memory_extract_system = internalMemoryExtractSystem.content;
|
||||||
|
workspace_orchestrator_queue_attention = internalWorkspaceOrchestratorQueueAttention.content;
|
||||||
|
notify_wrapper = internalNotifyWrapper.content;
|
||||||
|
interrupt_tool_result_summary = internalInterruptToolResultSummary.content;
|
||||||
|
interrupt_system_note = internalInterruptSystemNote.content;
|
||||||
|
working_boundaries_section = internalWorkingBoundariesSection.content;
|
||||||
|
agents_md_section = internalAgentsMdSection.content;
|
||||||
|
resident_memory_summary_section = internalResidentMemorySummarySection.content;
|
||||||
|
worker_orchestration_guidance_section = commonWorkerOrchestration.content;
|
||||||
|
sub_worker_spawn_tool_description = internalSubWorkerSpawnToolDescription.content;
|
||||||
|
};
|
||||||
|
panel = {
|
||||||
|
orchestrator_idle_queue_notice = panelOrchestratorIdleQueueNotice.content;
|
||||||
|
};
|
||||||
|
worker = {
|
||||||
|
ticket_event_companion_notice = workerTicketEventCompanionNotice.content;
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
|
|
||||||
---
|
---
|
||||||
## SubWorker orchestration
|
## SubWorker orchestration
|
||||||
|
|
||||||
|
|||||||
@@ -2,11 +2,11 @@ You are here as an agent of the "yoi system".
|
|||||||
|
|
||||||
Stay precise, edit code directly when asked, and avoid speculative refactoring.
|
Stay precise, edit code directly when asked, and avoid speculative refactoring.
|
||||||
|
|
||||||
{% include "common/workspace" %}
|
{% include "common.workspace" %}
|
||||||
|
|
||||||
{% include "common/tool-usage" %}
|
{% include "common.tool_usage" %}
|
||||||
|
|
||||||
|
|
||||||
{% include "common/language" %}
|
{% include "common.language" %}
|
||||||
|
|
||||||
{% include "common/writing" %}
|
{% include "common.writing" %}
|
||||||
|
|||||||
@@ -1,71 +0,0 @@
|
|||||||
# Worker internal prompts (builtin pack).
|
|
||||||
#
|
|
||||||
# Values are minijinja template strings. Use `{% include "$prefix/..." %}`
|
|
||||||
# to pull in long text from the $yoi / $user / $workspace prompt
|
|
||||||
# libraries.
|
|
||||||
#
|
|
||||||
# Every key here MUST correspond to a `WorkerPrompt` variant; missing or
|
|
||||||
# extra keys cause a build-time error (see `crates/worker/build.rs`).
|
|
||||||
|
|
||||||
[prompt]
|
|
||||||
compact_system = "{% include \"$yoi/internal/compact_system\" %}"
|
|
||||||
|
|
||||||
memory_extract_system = "{% include \"$yoi/internal/memory_extract_system\" %}"
|
|
||||||
|
|
||||||
memory_consolidation_system = "{% include \"$yoi/internal/memory_consolidation_system\" %}"
|
|
||||||
|
|
||||||
flow_verifier_system = "{% include \"$yoi/internal/flow_verifier_system\" %}"
|
|
||||||
|
|
||||||
notify_wrapper = """\
|
|
||||||
[Notification]
|
|
||||||
{{ message }}
|
|
||||||
|
|
||||||
This is a notification, not a blocking request. If you are in the middle of a task, continue your current work and address this at a natural stopping point.\
|
|
||||||
"""
|
|
||||||
|
|
||||||
interrupt_tool_result_summary = "[Interrupted by user]"
|
|
||||||
|
|
||||||
interrupt_system_note = "[The previous turn was interrupted by the user. The user's next request follows.]"
|
|
||||||
|
|
||||||
working_boundaries_section = """\
|
|
||||||
---
|
|
||||||
## Working boundaries
|
|
||||||
|
|
||||||
{{ scope_summary }}\
|
|
||||||
"""
|
|
||||||
|
|
||||||
agents_md_section = """\
|
|
||||||
---
|
|
||||||
## Project instructions (AGENTS.md)
|
|
||||||
|
|
||||||
{{ agents_md }}\
|
|
||||||
"""
|
|
||||||
|
|
||||||
resident_memory_summary_section = """\
|
|
||||||
---
|
|
||||||
## Resident memory summary
|
|
||||||
|
|
||||||
The following is the current durable session/workspace summary. Treat it as background context; it is not a user request.
|
|
||||||
|
|
||||||
{{ summary }}\
|
|
||||||
"""
|
|
||||||
|
|
||||||
|
|
||||||
worker_orchestration_guidance_section = "{% include \"$yoi/common/worker-orchestration\" %}"
|
|
||||||
|
|
||||||
ticket_event_companion_notice = "{% include \"$yoi/worker/ticket_event_companion_notice\" %}"
|
|
||||||
|
|
||||||
sub_worker_spawn_tool_description = """\
|
|
||||||
Spawn a parent-owned Internal SubWorker session to split context for a delegated task. The parent Worker's write scope is reduced by the scope passed here; the Internal SubWorker starts running `task` immediately without creating a Runtime Worker record, OS process, PID, or Unix socket. It remains available for follow-up turns until explicitly stopped or its parent exits.
|
|
||||||
|
|
||||||
Optional `cwd`: when provided, it is the Internal SubWorker's tool default working directory only. It must be an absolute existing directory covered by the child's delegated readable scope, and it does not change workspace/Profile/memory/Ticket roots or grant authority. `name` must be unique among this Worker's direct children.
|
|
||||||
|
|
||||||
Profile selection: `profile` may be omitted or set to `default` to use the effective child default profile, set to `inherit` to derive reusable child configuration from this Worker, or set to one of the registry selectors below. Raw/path profile selectors are not accepted by SubWorkerSpawn. `scope` is always the only delegated filesystem capability; profile scope is replaced by the explicit SubWorkerSpawn scope.
|
|
||||||
|
|
||||||
Default profile: {{ default_profile }}
|
|
||||||
Special selector: inherit — derive reusable model/worker/tool policy from the spawner while replacing worker.name and scope.
|
|
||||||
Available registry profiles:
|
|
||||||
{{ available_profiles }}{% if profile_diagnostic %}
|
|
||||||
|
|
||||||
Profile discovery diagnostic: {{ profile_diagnostic }}{% endif %}\
|
|
||||||
"""
|
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
|
||||||
|
---
|
||||||
|
## Project instructions (AGENTS.md)
|
||||||
|
|
||||||
|
{{ agents_md }}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
[The previous turn was interrupted by the user. The user's next request follows.]
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
[Interrupted by user]
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
[Notification]
|
||||||
|
{{ message }}
|
||||||
|
|
||||||
|
This is a notification, not a blocking request. If you are in the middle of a task, continue your current work and address this at a natural stopping point.
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
|
||||||
|
---
|
||||||
|
## Resident memory summary
|
||||||
|
|
||||||
|
The following is the current durable session/workspace summary. Treat it as background context; it is not a user request.
|
||||||
|
|
||||||
|
{{ summary }}
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
Spawn a parent-owned Internal SubWorker session to split context for a delegated task. The parent Worker's write scope is reduced by the scope passed here; the Internal SubWorker starts running `task` immediately without creating a Runtime Worker record, OS process, PID, or Unix socket. It remains available for follow-up turns until explicitly stopped or its parent exits.
|
||||||
|
|
||||||
|
Optional `cwd`: when provided, the spawned SubWorker's tool default working directory only. It must be an absolute existing directory covered by the child's delegated readable scope, and it does not change workspace/Profile/memory/Ticket roots or grant authority. `name` must be unique among this Worker's direct children.
|
||||||
|
|
||||||
|
Profile selection: `profile` may be omitted or set to `default` to use the effective child default profile, set to `inherit` to derive reusable child configuration from this Worker, or set to one of the registry selectors below. Raw/path profile selectors are not accepted by SubWorkerSpawn. `scope` is always the only delegated filesystem capability; profile scope is replaced by the explicit SubWorkerSpawn scope.
|
||||||
|
|
||||||
|
Default profile: {{ default_profile }}
|
||||||
|
Special selector: inherit — derive reusable model/worker/tool policy from the spawner while replacing worker.name and scope.
|
||||||
|
Available registry profiles:
|
||||||
|
{{ available_profiles }}{% if profile_diagnostic %}
|
||||||
|
|
||||||
|
Profile discovery diagnostic: {{ profile_diagnostic }}{% endif %}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
|
||||||
|
---
|
||||||
|
## Working boundaries
|
||||||
|
|
||||||
|
{{ scope_summary }}
|
||||||
@@ -6,7 +6,7 @@
|
|||||||
"dev": "deno run -A npm:vite@7.2.7 dev",
|
"dev": "deno run -A npm:vite@7.2.7 dev",
|
||||||
"dev:backend": "cd ../.. && cargo run -p yoi-workspace-server --bin yoi-server -- serve --listen 127.0.0.1:8787",
|
"dev:backend": "cd ../.. && cargo run -p yoi-workspace-server --bin yoi-server -- serve --listen 127.0.0.1:8787",
|
||||||
"check": "deno run -A npm:@sveltejs/kit@2.49.4 sync && deno run -A npm:svelte-check@4.3.4 --tsconfig ./tsconfig.json",
|
"check": "deno run -A npm:@sveltejs/kit@2.49.4 sync && deno run -A npm:svelte-check@4.3.4 --tsconfig ./tsconfig.json",
|
||||||
"test": "deno test --allow-read=src --allow-env=VSCODE_TEXTMATE_DEBUG src/lib/workspace/auth/model.test.ts src/lib/workspace/api/http.test.ts src/lib/workspace/header/breadcrumb-model.test.ts src/lib/workspace/console/chat-submit.test.ts src/lib/workspace/console/composer-command.test.ts src/lib/workspace/console/composer-completion.test.ts src/lib/workspace/console/markdown.test.ts src/lib/workspace/console/model.test.ts src/lib/workspace/console/tasks.test.ts src/lib/workspace/console/worker-console.ui.test.ts src/lib/workspace/settings/model.test.ts src/lib/workspace/sidebar/workers.test.ts src/lib/workspace/sidebar/worker-subscription.test.ts src/lib/workspace/sidebar/worker-launch.test.ts src/lib/workspace/sidebar/repository-nav.test.ts src/lib/workspace/tickets/ticket-panel.test.ts",
|
"test": "deno test --allow-read=src,test --allow-env=LOG,VSCODE_TEXTMATE_DEBUG src/lib/workspace/auth/model.test.ts src/lib/workspace/api/http.test.ts src/lib/workspace/header/breadcrumb-model.test.ts src/lib/workspace/console/chat-submit.test.ts src/lib/workspace/console/composer-command.test.ts src/lib/workspace/console/composer-completion.test.ts src/lib/workspace/console/markdown.test.ts src/lib/workspace/console/model.test.ts src/lib/workspace/console/tasks.test.ts src/lib/workspace/console/worker-console.ui.test.ts src/lib/workspace/settings/model.test.ts src/lib/workspace/sidebar/workers.test.ts src/lib/workspace/sidebar/worker-subscription.test.ts src/lib/workspace/sidebar/worker-launch.test.ts src/lib/workspace/sidebar/repository-nav.test.ts src/lib/workspace/tickets/ticket-panel.test.ts test/config-source/decodal-grammar.test.ts test/config-source/wasm-parity.test.ts",
|
||||||
"build": "deno run -A npm:vite@7.2.7 build",
|
"build": "deno run -A npm:vite@7.2.7 build",
|
||||||
"preview": "deno run -A npm:vite@7.2.7 preview"
|
"preview": "deno run -A npm:vite@7.2.7 preview"
|
||||||
},
|
},
|
||||||
@@ -18,7 +18,7 @@
|
|||||||
"@codemirror/autocomplete": "npm:@codemirror/autocomplete@6.20.0",
|
"@codemirror/autocomplete": "npm:@codemirror/autocomplete@6.20.0",
|
||||||
"@codemirror/state": "npm:@codemirror/state@6.7.1",
|
"@codemirror/state": "npm:@codemirror/state@6.7.1",
|
||||||
"@codemirror/view": "npm:@codemirror/view@6.43.8",
|
"@codemirror/view": "npm:@codemirror/view@6.43.8",
|
||||||
"decodal-codemirror": "npm:decodal-codemirror@0.1.6",
|
"decodal-codemirror": "npm:decodal-codemirror@0.3.0",
|
||||||
"clsx": "npm:clsx@2.1.1",
|
"clsx": "npm:clsx@2.1.1",
|
||||||
"cookie": "npm:cookie@0.6.0",
|
"cookie": "npm:cookie@0.6.0",
|
||||||
"devalue": "npm:devalue@5.6.4",
|
"devalue": "npm:devalue@5.6.4",
|
||||||
|
|||||||
Generated
+4
-4
@@ -12,7 +12,7 @@
|
|||||||
"npm:@sveltejs/vite-plugin-svelte@6.2.1": "6.2.1_svelte@5.45.6_vite@7.2.7",
|
"npm:@sveltejs/vite-plugin-svelte@6.2.1": "6.2.1_svelte@5.45.6_vite@7.2.7",
|
||||||
"npm:clsx@2.1.1": "2.1.1",
|
"npm:clsx@2.1.1": "2.1.1",
|
||||||
"npm:cookie@0.6.0": "0.6.0",
|
"npm:cookie@0.6.0": "0.6.0",
|
||||||
"npm:decodal-codemirror@0.1.6": "0.1.6_@codemirror+view@6.43.8",
|
"npm:decodal-codemirror@0.3.0": "0.3.0_@codemirror+language@6.12.4_@codemirror+view@6.43.8_@lezer+highlight@1.2.3_@lezer+lr@1.4.10",
|
||||||
"npm:devalue@5.6.4": "5.6.4",
|
"npm:devalue@5.6.4": "5.6.4",
|
||||||
"npm:gen-interface-jp@0.8.0": "0.8.0",
|
"npm:gen-interface-jp@0.8.0": "0.8.0",
|
||||||
"npm:set-cookie-parser@2.7.2": "2.7.2",
|
"npm:set-cookie-parser@2.7.2": "2.7.2",
|
||||||
@@ -554,8 +554,8 @@
|
|||||||
"ms"
|
"ms"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"decodal-codemirror@0.1.6_@codemirror+view@6.43.8": {
|
"decodal-codemirror@0.3.0_@codemirror+language@6.12.4_@codemirror+view@6.43.8_@lezer+highlight@1.2.3_@lezer+lr@1.4.10": {
|
||||||
"integrity": "sha512-XTS5vAY+vTb/yEg5n+1yORtBPuhozG4KO0YGbYEFR8oZX0KghZuHyFEsq/CJMXF223YMC/FQt3SC19NVYGWKMw==",
|
"integrity": "sha512-M+Iod3UAZigpt46TmuLJIlSEBhL6KOOksyVxUOGGdlfKYCMN9oN6YG5G9tjkr3/r+Eq8ig1RipC7VZX5iiACTQ==",
|
||||||
"dependencies": [
|
"dependencies": [
|
||||||
"@codemirror/language",
|
"@codemirror/language",
|
||||||
"@codemirror/view",
|
"@codemirror/view",
|
||||||
@@ -1007,7 +1007,7 @@
|
|||||||
"npm:@sveltejs/vite-plugin-svelte@6.2.1",
|
"npm:@sveltejs/vite-plugin-svelte@6.2.1",
|
||||||
"npm:clsx@2.1.1",
|
"npm:clsx@2.1.1",
|
||||||
"npm:cookie@0.6.0",
|
"npm:cookie@0.6.0",
|
||||||
"npm:decodal-codemirror@0.1.6",
|
"npm:decodal-codemirror@0.3.0",
|
||||||
"npm:devalue@5.6.4",
|
"npm:devalue@5.6.4",
|
||||||
"npm:set-cookie-parser@2.7.2",
|
"npm:set-cookie-parser@2.7.2",
|
||||||
"npm:shiki@3.13.0",
|
"npm:shiki@3.13.0",
|
||||||
|
|||||||
@@ -15,6 +15,10 @@ export type SkillDiagnostic = {
|
|||||||
export type SkillProvenance = {
|
export type SkillProvenance = {
|
||||||
kind: "builtin" | "workspace";
|
kind: "builtin" | "workspace";
|
||||||
id: string;
|
id: string;
|
||||||
|
virtual_path?: string;
|
||||||
|
revision?: number;
|
||||||
|
source_digest?: string;
|
||||||
|
tree_digest?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type SkillCatalogEntry = {
|
export type SkillCatalogEntry = {
|
||||||
|
|||||||
+3
@@ -9,6 +9,8 @@ export function changes_between(base: any, candidate: any): any;
|
|||||||
|
|
||||||
export function complete_current(entrypoint: string, source: string, utf16_offset: number, explicit: boolean): any;
|
export function complete_current(entrypoint: string, source: string, utf16_offset: number, explicit: boolean): any;
|
||||||
|
|
||||||
|
export function compose_schema_bundle(contributions: any): any;
|
||||||
|
|
||||||
export function evaluate_current(contract: any): any;
|
export function evaluate_current(contract: any): any;
|
||||||
|
|
||||||
export function evaluate_snapshot(snapshot: any, contract: any): any;
|
export function evaluate_snapshot(snapshot: any, contract: any): any;
|
||||||
@@ -27,6 +29,7 @@ export interface InitOutput {
|
|||||||
readonly apply_changes: (a: any) => [number, number, number];
|
readonly apply_changes: (a: any) => [number, number, number];
|
||||||
readonly changes_between: (a: any, b: any) => [number, number, number];
|
readonly changes_between: (a: any, b: any) => [number, number, number];
|
||||||
readonly complete_current: (a: number, b: number, c: number, d: number, e: number, f: number) => [number, number, number];
|
readonly complete_current: (a: number, b: number, c: number, d: number, e: number, f: number) => [number, number, number];
|
||||||
|
readonly compose_schema_bundle: (a: any) => [number, number, number];
|
||||||
readonly evaluate_current: (a: any) => [number, number, number];
|
readonly evaluate_current: (a: any) => [number, number, number];
|
||||||
readonly evaluate_snapshot: (a: any, b: any) => [number, number, number];
|
readonly evaluate_snapshot: (a: any, b: any) => [number, number, number];
|
||||||
readonly format_source: (a: number, b: number) => [number, number, number, number];
|
readonly format_source: (a: number, b: number) => [number, number, number, number];
|
||||||
|
|||||||
@@ -62,6 +62,18 @@ export function complete_current(entrypoint, source, utf16_offset, explicit) {
|
|||||||
return takeFromExternrefTable0(ret[0]);
|
return takeFromExternrefTable0(ret[0]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {any} contributions
|
||||||
|
* @returns {any}
|
||||||
|
*/
|
||||||
|
export function compose_schema_bundle(contributions) {
|
||||||
|
const ret = wasm.compose_schema_bundle(contributions);
|
||||||
|
if (ret[2]) {
|
||||||
|
throw takeFromExternrefTable0(ret[1]);
|
||||||
|
}
|
||||||
|
return takeFromExternrefTable0(ret[0]);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @param {any} contract
|
* @param {any} contract
|
||||||
* @returns {any}
|
* @returns {any}
|
||||||
|
|||||||
Binary file not shown.
+1
@@ -5,6 +5,7 @@ export const analyze_snapshot: (a: any, b: number, c: number, d: number, e: numb
|
|||||||
export const apply_changes: (a: any) => [number, number, number];
|
export const apply_changes: (a: any) => [number, number, number];
|
||||||
export const changes_between: (a: any, b: any) => [number, number, number];
|
export const changes_between: (a: any, b: any) => [number, number, number];
|
||||||
export const complete_current: (a: number, b: number, c: number, d: number, e: number, f: number) => [number, number, number];
|
export const complete_current: (a: number, b: number, c: number, d: number, e: number, f: number) => [number, number, number];
|
||||||
|
export const compose_schema_bundle: (a: any) => [number, number, number];
|
||||||
export const evaluate_current: (a: any) => [number, number, number];
|
export const evaluate_current: (a: any) => [number, number, number];
|
||||||
export const evaluate_snapshot: (a: any, b: any) => [number, number, number];
|
export const evaluate_snapshot: (a: any, b: any) => [number, number, number];
|
||||||
export const format_source: (a: number, b: number) => [number, number, number, number];
|
export const format_source: (a: number, b: number) => [number, number, number, number];
|
||||||
|
|||||||
+3
@@ -0,0 +1,3 @@
|
|||||||
|
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
|
||||||
|
|
||||||
|
export type ConfigProjectionValidator = { "kind": "static_template_catalog", namespace: string, key_aliases?: { [key in string]: string }, };
|
||||||
+2
-1
@@ -1,3 +1,4 @@
|
|||||||
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
|
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
|
||||||
|
import type { ConfigProjectionValidator } from "./ConfigProjectionValidator";
|
||||||
|
|
||||||
export type ConfigSchemaContribution = { provider_id: string, namespace: string, version: string, source: string, source_digest: string, };
|
export type ConfigSchemaContribution = { provider_id: string, namespace: string, version: string, source: string, projection_validator?: ConfigProjectionValidator | null, source_digest: string, };
|
||||||
|
|||||||
@@ -6,10 +6,6 @@ import {
|
|||||||
SETTINGS_SECTIONS,
|
SETTINGS_SECTIONS,
|
||||||
settingsSectionHref,
|
settingsSectionHref,
|
||||||
} from "./model.ts";
|
} from "./model.ts";
|
||||||
import {
|
|
||||||
profileSourceTreeSettingsHref,
|
|
||||||
virtualProfilePathForCreate,
|
|
||||||
} from "./profile-routes.ts";
|
|
||||||
|
|
||||||
declare const Deno: {
|
declare const Deno: {
|
||||||
test(name: string, fn: () => void): void;
|
test(name: string, fn: () => void): void;
|
||||||
@@ -24,6 +20,11 @@ function assert(condition: unknown, message: string): asserts condition {
|
|||||||
Deno.test("settings section navigation stays under the settings route", () => {
|
Deno.test("settings section navigation stays under the settings route", () => {
|
||||||
assert(SETTINGS_ROUTE === "/settings", "settings route should be stable");
|
assert(SETTINGS_ROUTE === "/settings", "settings route should be stable");
|
||||||
|
|
||||||
|
assert(
|
||||||
|
settingsSectionHref("configuration-sources") === "/settings/configuration",
|
||||||
|
"shared configuration editor route should stay canonical",
|
||||||
|
);
|
||||||
|
|
||||||
for (const section of SETTINGS_SECTIONS) {
|
for (const section of SETTINGS_SECTIONS) {
|
||||||
const href = settingsSectionHref(section.id);
|
const href = settingsSectionHref(section.id);
|
||||||
assert(
|
assert(
|
||||||
@@ -106,29 +107,3 @@ Deno.test("diagnostic labels preserve severity and code", () => {
|
|||||||
"diagnostic label should be bounded and stable",
|
"diagnostic label should be bounded and stable",
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
Deno.test("profile source tree routes use encoded scoped ids", () => {
|
|
||||||
const href = profileSourceTreeSettingsHref("workspace a", "project/tree");
|
|
||||||
assert(
|
|
||||||
href === "/w/workspace%20a/settings/profiles/trees/project%2Ftree",
|
|
||||||
`unexpected href ${href}`,
|
|
||||||
);
|
|
||||||
assert(!href.includes("/home/"), "route must not contain host paths");
|
|
||||||
});
|
|
||||||
|
|
||||||
Deno.test("profile source create paths are normalized to virtual profile paths", () => {
|
|
||||||
assert(
|
|
||||||
virtualProfilePathForCreate("alpha.dcdl") === "profiles/alpha.dcdl",
|
|
||||||
"bare file names are scoped",
|
|
||||||
);
|
|
||||||
assert(
|
|
||||||
virtualProfilePathForCreate("profiles/alpha.dcdl") ===
|
|
||||||
"profiles/alpha.dcdl",
|
|
||||||
"virtual paths are preserved",
|
|
||||||
);
|
|
||||||
assert(
|
|
||||||
virtualProfilePathForCreate("project:profiles/alpha.dcdl") ===
|
|
||||||
"project:profiles/alpha.dcdl",
|
|
||||||
"safe virtual namespaces are preserved",
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|||||||
@@ -108,7 +108,7 @@ export const SETTINGS_SECTIONS: readonly SettingsSection[] = [
|
|||||||
bullets: [
|
bullets: [
|
||||||
"Virtual paths and imports resolve inside the committed Workspace tree, never from browser or Server host paths.",
|
"Virtual paths and imports resolve inside the committed Workspace tree, never from browser or Server host paths.",
|
||||||
"Browser analysis is advisory; Server evaluation is required before an atomic revision commit.",
|
"Browser analysis is advisory; Server evaluation is required before an atomic revision commit.",
|
||||||
"Profile, Skill, Prompt, and Plugin consumers remain on their existing authorities until their follow-up cutovers.",
|
"Profile launch data is projected from this active revision; remaining Skill, Prompt, and Plugin consumers migrate in their follow-up cutovers.",
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -116,11 +116,11 @@ export const SETTINGS_SECTIONS: readonly SettingsSection[] = [
|
|||||||
label: "Profile Sources",
|
label: "Profile Sources",
|
||||||
status: "editable",
|
status: "editable",
|
||||||
summary:
|
summary:
|
||||||
"Manage the workspace-scoped Decodal Profile registry and source files used by Backend-published launch profile discovery.",
|
"Inspect the Profile launch projection derived from the active Workspace configuration revision.",
|
||||||
bullets: [
|
bullets: [
|
||||||
"Selectors are source-qualified (builtin:* or project:*); raw profile source paths, archive content, archive digests, resource handles, and runtime tokens are not exposed.",
|
"Selectors are source-qualified (builtin:* or project:*); raw profile source paths, archive content, archive digests, resource handles, and runtime tokens are not exposed.",
|
||||||
"Profile source edits are validated through the Backend ProfileSourceArchive/Decodal boundary before they are persisted.",
|
"Profile declarations and sources are edited only through the shared Workspace configuration editor and evaluate-before-commit contract.",
|
||||||
"Launch profile candidates refresh from the same Backend projection after registry or source updates.",
|
"Launch candidates and Profile archives carry the same active config revision, tree digest, and projection digest.",
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -1,166 +1,72 @@
|
|||||||
import { workspaceApiJson, workspaceApiJsonWithBody } from "../api/http";
|
|
||||||
import type {
|
import type {
|
||||||
ProfileSettingsMutationResponse,
|
|
||||||
ProfileSettingsResponse,
|
ProfileSettingsResponse,
|
||||||
WorkspaceMetadataMutationResponse,
|
WorkspaceMetadataMutationResponse,
|
||||||
WorkspaceMetadataSettingsResponse,
|
WorkspaceMetadataSettingsResponse,
|
||||||
WorkspaceProfileSourceDetailResponse,
|
|
||||||
WorkspaceProfileSourceTreeFileResponse,
|
|
||||||
WorkspaceProfileSourceTreeResponse,
|
|
||||||
} from "./profile-types";
|
} from "./profile-types";
|
||||||
|
|
||||||
export function fetchWorkspaceMetadataSettings(
|
export type WorkspaceProfileApi = {
|
||||||
|
getMetadata(workspaceId: string): Promise<WorkspaceMetadataSettingsResponse>;
|
||||||
|
updateMetadata(
|
||||||
|
workspaceId: string,
|
||||||
|
displayName: string,
|
||||||
|
expectedRevision: string,
|
||||||
|
): Promise<WorkspaceMetadataMutationResponse>;
|
||||||
|
getProfiles(workspaceId: string): Promise<ProfileSettingsResponse>;
|
||||||
|
};
|
||||||
|
|
||||||
|
async function requestJson<T>(input: RequestInfo | URL, init?: RequestInit): Promise<T> {
|
||||||
|
const response = await fetch(input, init);
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`request failed: ${response.status}`);
|
||||||
|
}
|
||||||
|
return (await response.json()) as T;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchWorkspaceMetadataSettings(
|
||||||
workspaceId: string,
|
workspaceId: string,
|
||||||
): Promise<WorkspaceMetadataSettingsResponse> {
|
): Promise<WorkspaceMetadataSettingsResponse> {
|
||||||
return workspaceApiJson(
|
return await requestJson<WorkspaceMetadataSettingsResponse>(
|
||||||
`/api/w/${encodeURIComponent(workspaceId)}/settings/workspace`,
|
`/api/w/${encodeURIComponent(workspaceId)}/settings/workspace`,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function updateWorkspaceMetadataSettings(
|
export async function updateWorkspaceMetadataSettings(
|
||||||
workspaceId: string,
|
workspaceId: string,
|
||||||
request: { display_name: string; revision: string },
|
request: { display_name: string; revision: string },
|
||||||
): Promise<WorkspaceMetadataMutationResponse> {
|
): Promise<WorkspaceMetadataMutationResponse> {
|
||||||
return workspaceApiJsonWithBody(
|
return await requestJson<WorkspaceMetadataMutationResponse>(
|
||||||
`/api/w/${encodeURIComponent(workspaceId)}/settings/workspace`,
|
`/api/w/${encodeURIComponent(workspaceId)}/settings/workspace`,
|
||||||
{
|
{
|
||||||
method: "PUT",
|
method: "PUT",
|
||||||
|
headers: { "content-type": "application/json" },
|
||||||
body: JSON.stringify(request),
|
body: JSON.stringify(request),
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function fetchProfileSettings(
|
export async function fetchProfileSettings(workspaceId: string): Promise<ProfileSettingsResponse> {
|
||||||
workspaceId: string,
|
return await requestJson<ProfileSettingsResponse>(
|
||||||
): Promise<ProfileSettingsResponse> {
|
|
||||||
return workspaceApiJson(
|
|
||||||
`/api/w/${encodeURIComponent(workspaceId)}/settings/profiles`,
|
`/api/w/${encodeURIComponent(workspaceId)}/settings/profiles`,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function createProfileSource(
|
export function createWorkspaceProfileApi(): WorkspaceProfileApi {
|
||||||
workspaceId: string,
|
return {
|
||||||
request: {
|
async getMetadata(workspaceId) {
|
||||||
name: string;
|
return await requestJson<WorkspaceMetadataSettingsResponse>(
|
||||||
description?: string;
|
`/api/w/${encodeURIComponent(workspaceId)}/settings/metadata`,
|
||||||
content: string;
|
|
||||||
registry_revision: string;
|
|
||||||
},
|
|
||||||
): Promise<ProfileSettingsMutationResponse> {
|
|
||||||
return workspaceApiJsonWithBody(
|
|
||||||
`/api/w/${encodeURIComponent(workspaceId)}/settings/profiles`,
|
|
||||||
{
|
|
||||||
method: "POST",
|
|
||||||
body: JSON.stringify(request),
|
|
||||||
},
|
|
||||||
);
|
);
|
||||||
}
|
|
||||||
|
|
||||||
export function updateProfileRegistry(
|
|
||||||
workspaceId: string,
|
|
||||||
request: {
|
|
||||||
registry_revision: string;
|
|
||||||
default_profile?: string | null;
|
|
||||||
profiles: Array<
|
|
||||||
{
|
|
||||||
name: string;
|
|
||||||
description?: string | null;
|
|
||||||
profile_source_id?: string | null;
|
|
||||||
}
|
|
||||||
>;
|
|
||||||
},
|
},
|
||||||
): Promise<ProfileSettingsMutationResponse> {
|
async updateMetadata(workspaceId, displayName, expectedRevision) {
|
||||||
return workspaceApiJsonWithBody(
|
return await requestJson<WorkspaceMetadataMutationResponse>(
|
||||||
`/api/w/${encodeURIComponent(workspaceId)}/settings/profiles/registry`,
|
`/api/w/${encodeURIComponent(workspaceId)}/settings/metadata`,
|
||||||
{
|
{
|
||||||
method: "PUT",
|
method: "PUT",
|
||||||
body: JSON.stringify(request),
|
headers: { "content-type": "application/json" },
|
||||||
|
body: JSON.stringify({ display_name: displayName, expected_revision: expectedRevision }),
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
}
|
},
|
||||||
|
getProfiles: fetchProfileSettings,
|
||||||
export function fetchProfileSource(
|
};
|
||||||
workspaceId: string,
|
|
||||||
sourceId: string,
|
|
||||||
): Promise<WorkspaceProfileSourceDetailResponse> {
|
|
||||||
return workspaceApiJson(
|
|
||||||
`/api/w/${encodeURIComponent(workspaceId)}/settings/profiles/${
|
|
||||||
encodeURIComponent(sourceId)
|
|
||||||
}`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function updateProfileSource(
|
|
||||||
workspaceId: string,
|
|
||||||
sourceId: string,
|
|
||||||
request: { content: string; revision: string },
|
|
||||||
): Promise<ProfileSettingsMutationResponse> {
|
|
||||||
return workspaceApiJsonWithBody(
|
|
||||||
`/api/w/${encodeURIComponent(workspaceId)}/settings/profiles/${
|
|
||||||
encodeURIComponent(sourceId)
|
|
||||||
}`,
|
|
||||||
{ method: "PUT", body: JSON.stringify(request) },
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function deleteProfileSource(
|
|
||||||
workspaceId: string,
|
|
||||||
sourceId: string,
|
|
||||||
request: { registry_revision: string; source_revision: string },
|
|
||||||
): Promise<ProfileSettingsMutationResponse> {
|
|
||||||
return workspaceApiJsonWithBody(
|
|
||||||
`/api/w/${encodeURIComponent(workspaceId)}/settings/profiles/${
|
|
||||||
encodeURIComponent(sourceId)
|
|
||||||
}`,
|
|
||||||
{ method: "DELETE", body: JSON.stringify(request) },
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function fetchProfileSourceTree(
|
|
||||||
workspaceId: string,
|
|
||||||
sourceTreeId: string,
|
|
||||||
): Promise<WorkspaceProfileSourceTreeResponse> {
|
|
||||||
return workspaceApiJson(
|
|
||||||
`/api/w/${encodeURIComponent(workspaceId)}/settings/profiles/trees/${
|
|
||||||
encodeURIComponent(sourceTreeId)
|
|
||||||
}`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function fetchProfileTreeFile(
|
|
||||||
workspaceId: string,
|
|
||||||
sourceTreeId: string,
|
|
||||||
path: string,
|
|
||||||
): Promise<WorkspaceProfileSourceTreeFileResponse> {
|
|
||||||
return workspaceApiJson(
|
|
||||||
`/api/w/${encodeURIComponent(workspaceId)}/settings/profiles/trees/${
|
|
||||||
encodeURIComponent(sourceTreeId)
|
|
||||||
}/file?path=${encodeURIComponent(path)}`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function writeProfileTreeFile(
|
|
||||||
workspaceId: string,
|
|
||||||
sourceTreeId: string,
|
|
||||||
request: { path: string; content: string; revision?: string | null },
|
|
||||||
): Promise<WorkspaceProfileSourceTreeFileResponse> {
|
|
||||||
return workspaceApiJsonWithBody(
|
|
||||||
`/api/w/${encodeURIComponent(workspaceId)}/settings/profiles/trees/${
|
|
||||||
encodeURIComponent(sourceTreeId)
|
|
||||||
}/file`,
|
|
||||||
{ method: "PUT", body: JSON.stringify(request) },
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function deleteProfileTreeFile(
|
|
||||||
workspaceId: string,
|
|
||||||
sourceTreeId: string,
|
|
||||||
request: { path: string; revision: string },
|
|
||||||
): Promise<WorkspaceProfileSourceTreeResponse> {
|
|
||||||
return workspaceApiJsonWithBody(
|
|
||||||
`/api/w/${encodeURIComponent(workspaceId)}/settings/profiles/trees/${
|
|
||||||
encodeURIComponent(sourceTreeId)
|
|
||||||
}/file`,
|
|
||||||
{ method: "DELETE", body: JSON.stringify(request) },
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,22 +0,0 @@
|
|||||||
export function profileSettingsHref(workspaceId: string): string {
|
|
||||||
return `/w/${encodeURIComponent(workspaceId)}/settings/profiles`;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function profileSourceTreeSettingsHref(
|
|
||||||
workspaceId: string,
|
|
||||||
sourceTreeId: string,
|
|
||||||
): string {
|
|
||||||
return `${profileSettingsHref(workspaceId)}/trees/${
|
|
||||||
encodeURIComponent(sourceTreeId)
|
|
||||||
}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function virtualProfilePathForCreate(input: string): string {
|
|
||||||
const trimmed = input.trim();
|
|
||||||
if (!trimmed) return "";
|
|
||||||
if (trimmed.startsWith("project:") || trimmed.startsWith("workspace:")) {
|
|
||||||
return trimmed;
|
|
||||||
}
|
|
||||||
if (trimmed.startsWith("profiles/")) return trimmed;
|
|
||||||
return `profiles/${trimmed}`;
|
|
||||||
}
|
|
||||||
@@ -29,7 +29,7 @@ export type WorkspaceProfileSummary = {
|
|||||||
export type WorkspaceProfileSourceSummary = {
|
export type WorkspaceProfileSourceSummary = {
|
||||||
profile_source_id: string;
|
profile_source_id: string;
|
||||||
display_path: string;
|
display_path: string;
|
||||||
kind: "decodal" | string;
|
kind: "virtual_config" | string;
|
||||||
content_type: string;
|
content_type: string;
|
||||||
content_digest: string;
|
content_digest: string;
|
||||||
provenance: "project_profile_source_tree" | string;
|
provenance: "project_profile_source_tree" | string;
|
||||||
@@ -42,64 +42,11 @@ export type WorkspaceProfileSourceSummary = {
|
|||||||
export type ProfileSettingsResponse = {
|
export type ProfileSettingsResponse = {
|
||||||
workspace_id: string;
|
workspace_id: string;
|
||||||
registry_revision: string;
|
registry_revision: string;
|
||||||
|
config_revision?: number | null;
|
||||||
|
tree_digest?: string | null;
|
||||||
|
projection_digest?: string | null;
|
||||||
default_profile?: string | null;
|
default_profile?: string | null;
|
||||||
profiles: WorkspaceProfileSummary[];
|
profiles: WorkspaceProfileSummary[];
|
||||||
sources: WorkspaceProfileSourceSummary[];
|
sources: WorkspaceProfileSourceSummary[];
|
||||||
source_trees: WorkspaceProfileSourceTreeSummary[];
|
|
||||||
diagnostics: Diagnostic[];
|
|
||||||
};
|
|
||||||
|
|
||||||
export type WorkspaceProfileSourceDetailResponse = {
|
|
||||||
workspace_id: string;
|
|
||||||
profile: WorkspaceProfileSummary;
|
|
||||||
source: WorkspaceProfileSourceSummary;
|
|
||||||
content: string;
|
|
||||||
diagnostics: Diagnostic[];
|
|
||||||
};
|
|
||||||
|
|
||||||
export type ProfileSettingsMutationResponse = {
|
|
||||||
workspace_id: string;
|
|
||||||
settings: ProfileSettingsResponse;
|
|
||||||
diagnostics: Diagnostic[];
|
|
||||||
};
|
|
||||||
|
|
||||||
export type WorkspaceProfileSourceTreeSummary = {
|
|
||||||
source_tree_id: string;
|
|
||||||
label: string;
|
|
||||||
root_path: string;
|
|
||||||
kind: "decodal_source_tree" | string;
|
|
||||||
content_type: string;
|
|
||||||
content_digest: string;
|
|
||||||
provenance: "project_profile_source_tree" | string;
|
|
||||||
editable: boolean;
|
|
||||||
revision: string;
|
|
||||||
file_count: number;
|
|
||||||
diagnostics: Diagnostic[];
|
|
||||||
};
|
|
||||||
|
|
||||||
export type WorkspaceProfileSourceTreeFileSummary = {
|
|
||||||
path: string;
|
|
||||||
kind: "decodal" | string;
|
|
||||||
content_type: string;
|
|
||||||
content_digest: string;
|
|
||||||
provenance: "project_profile_source_tree" | string;
|
|
||||||
editable: boolean;
|
|
||||||
revision: string;
|
|
||||||
size_bytes: number;
|
|
||||||
diagnostics: Diagnostic[];
|
|
||||||
};
|
|
||||||
|
|
||||||
export type WorkspaceProfileSourceTreeResponse = {
|
|
||||||
workspace_id: string;
|
|
||||||
tree: WorkspaceProfileSourceTreeSummary;
|
|
||||||
files: WorkspaceProfileSourceTreeFileSummary[];
|
|
||||||
diagnostics: Diagnostic[];
|
|
||||||
};
|
|
||||||
|
|
||||||
export type WorkspaceProfileSourceTreeFileResponse = {
|
|
||||||
workspace_id: string;
|
|
||||||
source_tree_id: string;
|
|
||||||
file: WorkspaceProfileSourceTreeFileSummary;
|
|
||||||
content: string;
|
|
||||||
diagnostics: Diagnostic[];
|
diagnostics: Diagnostic[];
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,130 +1,31 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import DiagnosticsList from '$lib/workspace/settings/DiagnosticsList.svelte';
|
import DiagnosticsList from "$lib/workspace/settings/DiagnosticsList.svelte";
|
||||||
import DecodalSourceEditor from '$lib/workspace/settings/DecodalSourceEditor.svelte';
|
import { settingsSectionHref } from "$lib/workspace/settings/model";
|
||||||
import {
|
import { fetchProfileSettings } from "$lib/workspace/settings/profile-api";
|
||||||
fetchProfileSettings,
|
import type { ProfileSettingsResponse } from "$lib/workspace/settings/profile-types";
|
||||||
fetchProfileSourceTree,
|
import type { PageProps } from "./$types";
|
||||||
deleteProfileTreeFile,
|
|
||||||
fetchProfileTreeFile,
|
|
||||||
writeProfileTreeFile,
|
|
||||||
} from '$lib/workspace/settings/profile-api';
|
|
||||||
import type { Diagnostic } from '$lib/workspace/settings/model';
|
|
||||||
import { profileSourceTreeSettingsHref, virtualProfilePathForCreate } from '$lib/workspace/settings/profile-routes';
|
|
||||||
import type {
|
|
||||||
ProfileSettingsResponse,
|
|
||||||
WorkspaceProfileSourceTreeFileResponse,
|
|
||||||
WorkspaceProfileSourceTreeResponse,
|
|
||||||
} from '$lib/workspace/settings/profile-types';
|
|
||||||
import type { PageProps } from './$types';
|
|
||||||
|
|
||||||
let { data }: PageProps = $props();
|
let { data }: PageProps = $props();
|
||||||
let workspaceId = $derived(data.workspace?.workspace_id ?? '');
|
let workspaceId = $derived(data.workspace?.workspace_id ?? "");
|
||||||
|
|
||||||
let profileSettings = $state<ProfileSettingsResponse | null>(null);
|
let profileSettings = $state<ProfileSettingsResponse | null>(null);
|
||||||
let sourceTree = $state<WorkspaceProfileSourceTreeResponse | null>(null);
|
|
||||||
let selectedFile = $state<WorkspaceProfileSourceTreeFileResponse | null>(null);
|
|
||||||
let draftContent = $state('');
|
|
||||||
let loading = $state(true);
|
let loading = $state(true);
|
||||||
let saving = $state(false);
|
|
||||||
let creating = $state(false);
|
|
||||||
let deleting = $state(false);
|
|
||||||
let newFilePath = $state('new-profile.dcdl');
|
|
||||||
let message = $state<string | null>(null);
|
let message = $state<string | null>(null);
|
||||||
let diagnostics = $state<Diagnostic[]>([]);
|
|
||||||
|
|
||||||
async function loadSettings() {
|
|
||||||
if (!workspaceId) return;
|
|
||||||
loading = true;
|
|
||||||
message = null;
|
|
||||||
try {
|
|
||||||
const response = await fetchProfileSettings(workspaceId);
|
|
||||||
profileSettings = response;
|
|
||||||
diagnostics = response.diagnostics;
|
|
||||||
const treeId = response.source_trees[0]?.source_tree_id;
|
|
||||||
if (treeId) {
|
|
||||||
sourceTree = await fetchProfileSourceTree(workspaceId, treeId);
|
|
||||||
const firstPath = sourceTree.files[0]?.path;
|
|
||||||
if (firstPath) await selectTreeFile(treeId, firstPath);
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
message = err instanceof Error ? err.message : 'profile settings request failed';
|
|
||||||
} finally {
|
|
||||||
loading = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function selectTreeFile(sourceTreeId: string, path: string) {
|
|
||||||
selectedFile = await fetchProfileTreeFile(workspaceId, sourceTreeId, path);
|
|
||||||
draftContent = selectedFile.content;
|
|
||||||
diagnostics = selectedFile.diagnostics;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function createTreeFile(sourceTreeId: string) {
|
|
||||||
creating = true;
|
|
||||||
message = null;
|
|
||||||
try {
|
|
||||||
const path = virtualProfilePathForCreate(newFilePath);
|
|
||||||
selectedFile = await writeProfileTreeFile(workspaceId, sourceTreeId, {
|
|
||||||
path,
|
|
||||||
content: '{\n slug = "new-profile";\n description = "New profile";\n scope = "workspace_read";\n}',
|
|
||||||
});
|
|
||||||
draftContent = selectedFile.content;
|
|
||||||
sourceTree = await fetchProfileSourceTree(workspaceId, sourceTreeId);
|
|
||||||
diagnostics = selectedFile.diagnostics;
|
|
||||||
message = 'Created Decodal profile source.';
|
|
||||||
} catch (err) {
|
|
||||||
message = err instanceof Error ? err.message : 'profile source create failed';
|
|
||||||
} finally {
|
|
||||||
creating = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function deleteSelectedFile() {
|
|
||||||
if (!selectedFile) return;
|
|
||||||
deleting = true;
|
|
||||||
message = null;
|
|
||||||
try {
|
|
||||||
sourceTree = await deleteProfileTreeFile(workspaceId, selectedFile.source_tree_id, {
|
|
||||||
path: selectedFile.file.path,
|
|
||||||
revision: selectedFile.file.revision,
|
|
||||||
});
|
|
||||||
selectedFile = null;
|
|
||||||
draftContent = '';
|
|
||||||
diagnostics = sourceTree.diagnostics;
|
|
||||||
message = 'Deleted Decodal profile source.';
|
|
||||||
} catch (err) {
|
|
||||||
message = err instanceof Error ? err.message : 'profile source delete failed';
|
|
||||||
} finally {
|
|
||||||
deleting = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function saveSelectedFile() {
|
|
||||||
if (!selectedFile) return;
|
|
||||||
saving = true;
|
|
||||||
message = null;
|
|
||||||
try {
|
|
||||||
selectedFile = await writeProfileTreeFile(workspaceId, selectedFile.source_tree_id, {
|
|
||||||
path: selectedFile.file.path,
|
|
||||||
revision: selectedFile.file.revision,
|
|
||||||
content: draftContent,
|
|
||||||
});
|
|
||||||
draftContent = selectedFile.content;
|
|
||||||
sourceTree = await fetchProfileSourceTree(workspaceId, selectedFile.source_tree_id);
|
|
||||||
diagnostics = selectedFile.diagnostics;
|
|
||||||
message = 'Saved Decodal profile source.';
|
|
||||||
} catch (err) {
|
|
||||||
message = err instanceof Error ? err.message : 'profile source save failed';
|
|
||||||
} finally {
|
|
||||||
saving = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
async function load() {
|
async function load() {
|
||||||
await loadSettings();
|
loading = true;
|
||||||
if (cancelled) return;
|
message = null;
|
||||||
|
try {
|
||||||
|
const response = await fetchProfileSettings(workspaceId);
|
||||||
|
if (!cancelled) profileSettings = response;
|
||||||
|
} catch (error) {
|
||||||
|
if (!cancelled) {
|
||||||
|
message = error instanceof Error ? error.message : "profile settings request failed";
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
if (!cancelled) loading = false;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if (workspaceId) load();
|
if (workspaceId) load();
|
||||||
else loading = false;
|
else loading = false;
|
||||||
@@ -138,90 +39,39 @@
|
|||||||
<title>Profiles · Yoi Workspace</title>
|
<title>Profiles · Yoi Workspace</title>
|
||||||
</svelte:head>
|
</svelte:head>
|
||||||
|
|
||||||
<section class="card settings-section" aria-labelledby="profile-sources-title">
|
<section class="card settings-section" aria-labelledby="profiles-title">
|
||||||
<header class="settings-section-header">
|
<header class="settings-section-header">
|
||||||
<div>
|
<div>
|
||||||
<p class="eyebrow">Backend-owned source tree</p>
|
<p class="eyebrow">Workspace configuration projection</p>
|
||||||
<h2 id="profile-sources-title">Profiles</h2>
|
<h2 id="profiles-title">Profiles</h2>
|
||||||
</div>
|
</div>
|
||||||
<span class="badge">Decodal editor</span>
|
<a class="badge" href={`/w/${encodeURIComponent(workspaceId)}${settingsSectionHref("configuration-sources")}`}>
|
||||||
|
Edit configuration
|
||||||
|
</a>
|
||||||
</header>
|
</header>
|
||||||
<p>
|
<p>
|
||||||
Review effective launch profiles and edit Decodal source files through virtual profile-tree paths. The browser receives only safe relative paths and revision tokens.
|
These launch profiles are derived from the active Workspace configuration revision. Edit Profile declarations and Decodal sources in the shared configuration editor.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
{#if loading}
|
{#if loading}
|
||||||
<p class="status-message">Loading profiles…</p>
|
<p class="status-message">Loading profiles…</p>
|
||||||
|
{:else if message}
|
||||||
|
<p class="status-message error">{message}</p>
|
||||||
{:else if profileSettings}
|
{:else if profileSettings}
|
||||||
<div class="settings-profile-grid">
|
<p class="settings-note">
|
||||||
<article>
|
Revision {profileSettings.config_revision ?? "unknown"} · tree {profileSettings.tree_digest ?? "unknown"} · projection {profileSettings.projection_digest ?? "unknown"}
|
||||||
<h3>Available profiles</h3>
|
</p>
|
||||||
<p class="settings-note">Profiles that can be selected when launching a Worker.</p>
|
|
||||||
<ul class="settings-profile-list">
|
<ul class="settings-profile-list">
|
||||||
{#each profileSettings.profiles as profile (profile.profile_id)}
|
{#each profileSettings.profiles as profile (profile.profile_id)}
|
||||||
<li>
|
<li>
|
||||||
<strong>{profile.selector}</strong>
|
<strong>{profile.selector}</strong>
|
||||||
<span>{profile.label}</span>
|
<span>{profile.label}</span>
|
||||||
<small>{profile.source_kind}{profile.is_default ? ' · default' : ''}</small>
|
<small>{profile.source_kind}{profile.is_default ? " · default" : ""}</small>
|
||||||
{#if profile.description}<p>{profile.description}</p>{/if}
|
{#if profile.description}<p>{profile.description}</p>{/if}
|
||||||
<DiagnosticsList diagnostics={profile.diagnostics} />
|
<DiagnosticsList diagnostics={profile.diagnostics} />
|
||||||
</li>
|
</li>
|
||||||
{/each}
|
{/each}
|
||||||
</ul>
|
</ul>
|
||||||
</article>
|
<DiagnosticsList diagnostics={profileSettings.diagnostics} />
|
||||||
<article>
|
|
||||||
<h3>Profile source tree</h3>
|
|
||||||
<p class="settings-note">Virtual Decodal paths exposed by the Backend-owned source tree.</p>
|
|
||||||
{#if sourceTree}
|
|
||||||
<small>{sourceTree.tree.root_path} · {sourceTree.tree.file_count} files · {sourceTree.tree.content_type} · rev {sourceTree.tree.revision}</small>
|
|
||||||
<p><a href={profileSourceTreeSettingsHref(workspaceId, sourceTree.tree.source_tree_id)}>Open tree route</a></p>
|
|
||||||
<div class="settings-inline-form">
|
|
||||||
<input bind:value={newFilePath} aria-label="New profile source virtual path" placeholder="profiles/new-profile.dcdl" />
|
|
||||||
<button type="button" disabled={creating} onclick={() => createTreeFile(sourceTree!.tree.source_tree_id)}>
|
|
||||||
{creating ? 'Creating…' : 'Create source'}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<ul class="settings-profile-list">
|
|
||||||
{#each sourceTree.files as file (file.path)}
|
|
||||||
<li>
|
|
||||||
<button class="link-button" type="button" onclick={() => selectTreeFile(sourceTree!.tree.source_tree_id, file.path)}>
|
|
||||||
<strong>{file.path}</strong>
|
|
||||||
</button>
|
|
||||||
<span>{file.kind} · {file.content_type} · {file.size_bytes} bytes</span>
|
|
||||||
<small>{file.editable ? 'editable' : 'read-only'} · rev {file.revision}</small>
|
|
||||||
<DiagnosticsList diagnostics={file.diagnostics} />
|
|
||||||
</li>
|
|
||||||
{/each}
|
|
||||||
</ul>
|
|
||||||
{:else}
|
|
||||||
<p class="settings-note">No project profile source tree is available.</p>
|
|
||||||
{/if}
|
{/if}
|
||||||
</article>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{#if selectedFile}
|
|
||||||
<article class="settings-editor-panel">
|
|
||||||
<header class="settings-section-header">
|
|
||||||
<div>
|
|
||||||
<p class="eyebrow">{selectedFile.source_tree_id}</p>
|
|
||||||
<h3>{selectedFile.file.path}</h3>
|
|
||||||
</div>
|
|
||||||
<div class="settings-editor-actions">
|
|
||||||
<button type="button" disabled={saving || draftContent === selectedFile.content} onclick={saveSelectedFile}>
|
|
||||||
{saving ? 'Saving…' : 'Save'}
|
|
||||||
</button>
|
|
||||||
<button type="button" disabled={deleting} onclick={deleteSelectedFile}>
|
|
||||||
{deleting ? 'Deleting…' : 'Delete'}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</header>
|
|
||||||
<DecodalSourceEditor value={draftContent} onChange={(value) => (draftContent = value)} ariaLabel={`Decodal source ${selectedFile.file.path}`} />
|
|
||||||
</article>
|
|
||||||
{/if}
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
{#if message}
|
|
||||||
<p class="status-message" class:error={message.includes('failed') || message.includes('error')}>{message}</p>
|
|
||||||
{/if}
|
|
||||||
<DiagnosticsList {diagnostics} />
|
|
||||||
</section>
|
</section>
|
||||||
|
|||||||
-173
@@ -1,173 +0,0 @@
|
|||||||
<script lang="ts">
|
|
||||||
import DiagnosticsList from '$lib/workspace/settings/DiagnosticsList.svelte';
|
|
||||||
import DecodalSourceEditor from '$lib/workspace/settings/DecodalSourceEditor.svelte';
|
|
||||||
import {
|
|
||||||
deleteProfileTreeFile,
|
|
||||||
fetchProfileSourceTree,
|
|
||||||
fetchProfileTreeFile,
|
|
||||||
writeProfileTreeFile,
|
|
||||||
} from '$lib/workspace/settings/profile-api';
|
|
||||||
import { virtualProfilePathForCreate } from '$lib/workspace/settings/profile-routes';
|
|
||||||
import type { Diagnostic } from '$lib/workspace/settings/model';
|
|
||||||
import type {
|
|
||||||
WorkspaceProfileSourceTreeFileResponse,
|
|
||||||
WorkspaceProfileSourceTreeResponse,
|
|
||||||
} from '$lib/workspace/settings/profile-types';
|
|
||||||
import type { PageProps } from './$types';
|
|
||||||
|
|
||||||
let { data }: PageProps = $props();
|
|
||||||
let workspaceId = $derived(data.workspace?.workspace_id ?? '');
|
|
||||||
let sourceTreeId = $derived(data.sourceTreeId);
|
|
||||||
|
|
||||||
let tree = $state<WorkspaceProfileSourceTreeResponse | null>(null);
|
|
||||||
let selectedFile = $state<WorkspaceProfileSourceTreeFileResponse | null>(null);
|
|
||||||
let draftContent = $state('');
|
|
||||||
let newFilePath = $state('new-profile.dcdl');
|
|
||||||
let loading = $state(true);
|
|
||||||
let saving = $state(false);
|
|
||||||
let creating = $state(false);
|
|
||||||
let deleting = $state(false);
|
|
||||||
let message = $state<string | null>(null);
|
|
||||||
let diagnostics = $state<Diagnostic[]>([]);
|
|
||||||
|
|
||||||
async function reloadTree() {
|
|
||||||
tree = await fetchProfileSourceTree(workspaceId, sourceTreeId);
|
|
||||||
diagnostics = tree.diagnostics;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function selectFile(path: string) {
|
|
||||||
selectedFile = await fetchProfileTreeFile(workspaceId, sourceTreeId, path);
|
|
||||||
draftContent = selectedFile.content;
|
|
||||||
diagnostics = selectedFile.diagnostics;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function createFile() {
|
|
||||||
creating = true;
|
|
||||||
message = null;
|
|
||||||
try {
|
|
||||||
selectedFile = await writeProfileTreeFile(workspaceId, sourceTreeId, {
|
|
||||||
path: virtualProfilePathForCreate(newFilePath),
|
|
||||||
content: '{\n slug = "new-profile";\n description = "New profile";\n scope = "workspace_read";\n}',
|
|
||||||
});
|
|
||||||
draftContent = selectedFile.content;
|
|
||||||
await reloadTree();
|
|
||||||
message = 'Created Decodal profile source.';
|
|
||||||
} catch (err) {
|
|
||||||
message = err instanceof Error ? err.message : 'profile source create failed';
|
|
||||||
} finally {
|
|
||||||
creating = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function saveFile() {
|
|
||||||
if (!selectedFile) return;
|
|
||||||
saving = true;
|
|
||||||
message = null;
|
|
||||||
try {
|
|
||||||
selectedFile = await writeProfileTreeFile(workspaceId, sourceTreeId, {
|
|
||||||
path: selectedFile.file.path,
|
|
||||||
revision: selectedFile.file.revision,
|
|
||||||
content: draftContent,
|
|
||||||
});
|
|
||||||
draftContent = selectedFile.content;
|
|
||||||
await reloadTree();
|
|
||||||
message = 'Saved Decodal profile source.';
|
|
||||||
} catch (err) {
|
|
||||||
message = err instanceof Error ? err.message : 'profile source save failed';
|
|
||||||
} finally {
|
|
||||||
saving = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function deleteFile() {
|
|
||||||
if (!selectedFile) return;
|
|
||||||
deleting = true;
|
|
||||||
message = null;
|
|
||||||
try {
|
|
||||||
tree = await deleteProfileTreeFile(workspaceId, sourceTreeId, {
|
|
||||||
path: selectedFile.file.path,
|
|
||||||
revision: selectedFile.file.revision,
|
|
||||||
});
|
|
||||||
selectedFile = null;
|
|
||||||
draftContent = '';
|
|
||||||
diagnostics = tree.diagnostics;
|
|
||||||
message = 'Deleted Decodal profile source.';
|
|
||||||
} catch (err) {
|
|
||||||
message = err instanceof Error ? err.message : 'profile source delete failed';
|
|
||||||
} finally {
|
|
||||||
deleting = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
$effect(() => {
|
|
||||||
if (!workspaceId || !sourceTreeId) {
|
|
||||||
loading = false;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
loading = true;
|
|
||||||
reloadTree()
|
|
||||||
.catch((err) => {
|
|
||||||
message = err instanceof Error ? err.message : 'profile source tree load failed';
|
|
||||||
})
|
|
||||||
.finally(() => {
|
|
||||||
loading = false;
|
|
||||||
});
|
|
||||||
});
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<svelte:head>
|
|
||||||
<title>Profile source tree · Yoi Workspace</title>
|
|
||||||
</svelte:head>
|
|
||||||
|
|
||||||
<section class="card settings-section" aria-labelledby="profile-tree-title">
|
|
||||||
<header class="settings-section-header">
|
|
||||||
<div>
|
|
||||||
<h2 id="profile-tree-title">Profile source tree</h2>
|
|
||||||
</div>
|
|
||||||
</header>
|
|
||||||
|
|
||||||
{#if loading}
|
|
||||||
<p class="status-message">Loading source tree…</p>
|
|
||||||
{:else if tree}
|
|
||||||
<p class="settings-note">
|
|
||||||
{tree.tree.root_path} · {tree.tree.file_count} files · {tree.tree.content_type} · {tree.tree.content_digest}
|
|
||||||
</p>
|
|
||||||
<div class="settings-inline-form">
|
|
||||||
<input bind:value={newFilePath} aria-label="New profile source virtual path" placeholder="profiles/new-profile.dcdl" />
|
|
||||||
<button type="button" disabled={creating} onclick={createFile}>{creating ? 'Creating…' : 'Create source'}</button>
|
|
||||||
</div>
|
|
||||||
<div class="settings-profile-grid">
|
|
||||||
<article>
|
|
||||||
<h3>Files</h3>
|
|
||||||
<ul class="settings-profile-list">
|
|
||||||
{#each tree.files as file (file.path)}
|
|
||||||
<li>
|
|
||||||
<button class="link-button" type="button" onclick={() => selectFile(file.path)}><strong>{file.path}</strong></button>
|
|
||||||
<span>{file.content_type} · {file.content_digest}</span>
|
|
||||||
<small>{file.size_bytes} bytes · rev {file.revision}</small>
|
|
||||||
<DiagnosticsList diagnostics={file.diagnostics} />
|
|
||||||
</li>
|
|
||||||
{/each}
|
|
||||||
</ul>
|
|
||||||
</article>
|
|
||||||
{#if selectedFile}
|
|
||||||
<article>
|
|
||||||
<header class="settings-section-header">
|
|
||||||
<div>
|
|
||||||
<p class="eyebrow">{selectedFile.file.content_type}</p>
|
|
||||||
<h3>{selectedFile.file.path}</h3>
|
|
||||||
</div>
|
|
||||||
<div class="settings-editor-actions">
|
|
||||||
<button type="button" disabled={saving || draftContent === selectedFile.content} onclick={saveFile}>{saving ? 'Saving…' : 'Save'}</button>
|
|
||||||
<button type="button" disabled={deleting} onclick={deleteFile}>{deleting ? 'Deleting…' : 'Delete'}</button>
|
|
||||||
</div>
|
|
||||||
</header>
|
|
||||||
<DecodalSourceEditor value={draftContent} onChange={(value) => (draftContent = value)} ariaLabel={`Decodal source ${selectedFile.file.path}`} />
|
|
||||||
</article>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
{#if message}<p class="status-message">{message}</p>{/if}
|
|
||||||
<DiagnosticsList {diagnostics} />
|
|
||||||
</section>
|
|
||||||
-5
@@ -1,5 +0,0 @@
|
|||||||
import type { PageLoad } from "./$types";
|
|
||||||
|
|
||||||
export const load: PageLoad = ({ params }) => ({
|
|
||||||
sourceTreeId: params.sourceTreeId,
|
|
||||||
});
|
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
import { decodalLanguage } from "decodal-codemirror";
|
||||||
|
|
||||||
|
Deno.test("editor grammar accepts Decodal 0.4 schema syntax", () => {
|
||||||
|
const source = `
|
||||||
|
import "main.dcdl" as WorkspaceConfigSchema
|
||||||
|
{
|
||||||
|
features = {...{ enabled = Bool; }};
|
||||||
|
web = { enabled = Bool; ...Unknown };
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
const tree = decodalLanguage.parser.parse(source);
|
||||||
|
const errors: string[] = [];
|
||||||
|
tree.iterate({
|
||||||
|
enter(node) {
|
||||||
|
if (node.type.isError) {
|
||||||
|
errors.push(`${node.from}..${node.to}`);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
if (errors.length > 0) {
|
||||||
|
throw new Error(
|
||||||
|
`Decodal 0.4 grammar produced parse errors at ${errors.join(", ")}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -1,19 +1,48 @@
|
|||||||
/// <reference lib="deno.ns" />
|
/// <reference lib="deno.ns" />
|
||||||
|
|
||||||
import { assertEquals } from "jsr:@std/assert";
|
import { assertEquals } from "jsr:@std/assert";
|
||||||
// The generated wasm-bindgen loader is JavaScript with an adjacent declaration file.
|
|
||||||
// @ts-expect-error Deno checks the generated JS implementation rather than its .d.ts.
|
|
||||||
import init, {
|
import init, {
|
||||||
analyze_snapshot,
|
analyze_snapshot,
|
||||||
|
compose_schema_bundle,
|
||||||
evaluate_snapshot,
|
evaluate_snapshot,
|
||||||
} from "../../src/lib/workspace/config-source/generated/config_source_wasm.js";
|
} from "../../src/lib/workspace/config-source/generated/config_source_wasm.js";
|
||||||
import type { ConfigTreeSnapshot, ToolchainContract } from "../../src/lib/workspace/config-source/types.ts";
|
import type {
|
||||||
|
ConfigTreeSnapshot,
|
||||||
|
ToolchainContract,
|
||||||
|
WorkspaceConfigSchemaBundle,
|
||||||
|
} from "../../src/lib/workspace/config-source/types.ts";
|
||||||
|
|
||||||
const bytes = await Deno.readFile(
|
const bytes = await Deno.readFile(
|
||||||
new URL("../../src/lib/workspace/config-source/generated/config_source_wasm_bg.wasm", import.meta.url),
|
new URL(
|
||||||
|
"../../src/lib/workspace/config-source/generated/config_source_wasm_bg.wasm",
|
||||||
|
import.meta.url,
|
||||||
|
),
|
||||||
);
|
);
|
||||||
await init({ module_or_path: bytes });
|
await init({ module_or_path: bytes });
|
||||||
|
|
||||||
|
async function digestText(text: string): Promise<string> {
|
||||||
|
const bytes = new TextEncoder().encode(text);
|
||||||
|
const digest = await crypto.subtle.digest("SHA-256", bytes);
|
||||||
|
return `sha256:${
|
||||||
|
Array.from(new Uint8Array(digest), (byte) =>
|
||||||
|
byte.toString(16).padStart(2, "0")).join("")
|
||||||
|
}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function toolchainFingerprint(
|
||||||
|
entrypoints: string[],
|
||||||
|
schemaBundle: WorkspaceConfigSchemaBundle,
|
||||||
|
): Promise<string> {
|
||||||
|
return await digestText(JSON.stringify([
|
||||||
|
2,
|
||||||
|
"0.4.0",
|
||||||
|
1,
|
||||||
|
entrypoints,
|
||||||
|
1,
|
||||||
|
schemaBundle.fingerprint,
|
||||||
|
]));
|
||||||
|
}
|
||||||
|
|
||||||
const snapshot: ConfigTreeSnapshot = {
|
const snapshot: ConfigTreeSnapshot = {
|
||||||
revision: 4,
|
revision: 4,
|
||||||
digest: "sha256:test-tree",
|
digest: "sha256:test-tree",
|
||||||
@@ -33,13 +62,20 @@ const snapshot: ConfigTreeSnapshot = {
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const emptySchemaBundle = compose_schema_bundle(
|
||||||
|
[],
|
||||||
|
) as WorkspaceConfigSchemaBundle;
|
||||||
const contract: ToolchainContract = {
|
const contract: ToolchainContract = {
|
||||||
contract_version: 1,
|
contract_version: 2,
|
||||||
decodal_version: "0.2.0",
|
decodal_version: "0.4.0",
|
||||||
schema_version: 1,
|
schema_version: 1,
|
||||||
entrypoints: ["workspace.dcdl"],
|
entrypoints: ["workspace.dcdl"],
|
||||||
import_policy_version: 1,
|
import_policy_version: 1,
|
||||||
fingerprint: "sha256:test-contract",
|
schema_bundle: emptySchemaBundle,
|
||||||
|
fingerprint: await toolchainFingerprint(
|
||||||
|
["workspace.dcdl"],
|
||||||
|
emptySchemaBundle,
|
||||||
|
),
|
||||||
};
|
};
|
||||||
|
|
||||||
Deno.test("generated WASM evaluates the same virtual import contract", () => {
|
Deno.test("generated WASM evaluates the same virtual import contract", () => {
|
||||||
@@ -65,3 +101,143 @@ Deno.test("generated WASM diagnostics carry snapshot provenance", () => {
|
|||||||
assertEquals(diagnostics[0].tree_digest, "sha256:test-tree");
|
assertEquals(diagnostics[0].tree_digest, "sha256:test-tree");
|
||||||
assertEquals(diagnostics[0].kind, "syntax");
|
assertEquals(diagnostics[0].kind, "syntax");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const featuresSchema = "{ features = {...{ enabled = Bool; }}; }";
|
||||||
|
const webSchema = "{ web = { enabled = Bool; ...Unknown }; }";
|
||||||
|
const schemaBundle = compose_schema_bundle([
|
||||||
|
{
|
||||||
|
provider_id: "builtin:features",
|
||||||
|
namespace: "features",
|
||||||
|
version: "1",
|
||||||
|
source: featuresSchema,
|
||||||
|
source_digest: await digestText(featuresSchema),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
provider_id: "builtin:web",
|
||||||
|
namespace: "web",
|
||||||
|
version: "1",
|
||||||
|
source: webSchema,
|
||||||
|
source_digest: await digestText(webSchema),
|
||||||
|
},
|
||||||
|
]) as WorkspaceConfigSchemaBundle;
|
||||||
|
|
||||||
|
function schemaSnapshot(source: string): ConfigTreeSnapshot {
|
||||||
|
return {
|
||||||
|
revision: 7,
|
||||||
|
digest: "sha256:schema-tree",
|
||||||
|
entries: {
|
||||||
|
"main.dcdl": {
|
||||||
|
path: "main.dcdl",
|
||||||
|
content_type: "decodal",
|
||||||
|
content: source,
|
||||||
|
content_digest: "sha256:main",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const schemaContract: ToolchainContract = {
|
||||||
|
contract_version: 2,
|
||||||
|
decodal_version: "0.4.0",
|
||||||
|
schema_version: 1,
|
||||||
|
entrypoints: ["main.dcdl"],
|
||||||
|
import_policy_version: 1,
|
||||||
|
schema_bundle: schemaBundle,
|
||||||
|
fingerprint: await toolchainFingerprint(["main.dcdl"], schemaBundle),
|
||||||
|
};
|
||||||
|
|
||||||
|
const markdownSnapshot: ConfigTreeSnapshot = {
|
||||||
|
revision: 8,
|
||||||
|
digest: "sha256:markdown-tree",
|
||||||
|
entries: {
|
||||||
|
"main.dcdl": {
|
||||||
|
path: "main.dcdl",
|
||||||
|
content_type: "decodal",
|
||||||
|
content:
|
||||||
|
`{ skill = import "./skills/debug-rust/SKILL.md" as { frontmatter = { name = String; description = String; ...Unknown }; content = String; }; }`,
|
||||||
|
content_digest: "sha256:markdown-main",
|
||||||
|
},
|
||||||
|
"skills/debug-rust/SKILL.md": {
|
||||||
|
path: "skills/debug-rust/SKILL.md",
|
||||||
|
content_type: "text",
|
||||||
|
content:
|
||||||
|
"---\nname: debug-rust\ndescription: Debug Rust\ncustom-authority: no\n---\n# Debug Rust\n",
|
||||||
|
content_digest: "sha256:markdown-skill",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const markdownContract: ToolchainContract = {
|
||||||
|
...contract,
|
||||||
|
entrypoints: ["main.dcdl"],
|
||||||
|
fingerprint: await toolchainFingerprint(["main.dcdl"], emptySchemaBundle),
|
||||||
|
};
|
||||||
|
|
||||||
|
Deno.test("generated WASM evaluates Markdown with the shared Skill projection", () => {
|
||||||
|
const result = evaluate_snapshot(markdownSnapshot, markdownContract) as {
|
||||||
|
projections: Array<{ data_json: Record<string, unknown> }>;
|
||||||
|
};
|
||||||
|
assertEquals(result.projections[0].data_json, {
|
||||||
|
skill: {
|
||||||
|
frontmatter: {
|
||||||
|
"custom-authority": "no",
|
||||||
|
description: "Debug Rust",
|
||||||
|
name: "debug-rust",
|
||||||
|
},
|
||||||
|
content: "# Debug Rust\n",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
Deno.test("generated WASM applies Decodal 0.4 typed maps and explicit object rest", () => {
|
||||||
|
const result = evaluate_snapshot(
|
||||||
|
schemaSnapshot(
|
||||||
|
"{ features = { console = { enabled = true; }; }; web = { enabled = true; extension_value = 42; }; }",
|
||||||
|
),
|
||||||
|
schemaContract,
|
||||||
|
) as { projections: Array<{ data_json: Record<string, unknown> }> };
|
||||||
|
assertEquals(result.projections[0].data_json, {
|
||||||
|
features: { console: { enabled: true } },
|
||||||
|
web: { enabled: true, extension_value: 42 },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
type ProjectedDiagnostic = {
|
||||||
|
path: string;
|
||||||
|
kind: string;
|
||||||
|
message: string;
|
||||||
|
span: { start_byte: number; end_byte: number };
|
||||||
|
};
|
||||||
|
|
||||||
|
function evaluateFailure(source: string): ProjectedDiagnostic {
|
||||||
|
try {
|
||||||
|
evaluate_snapshot(schemaSnapshot(source), schemaContract);
|
||||||
|
} catch (error) {
|
||||||
|
const diagnostics = error as ProjectedDiagnostic[];
|
||||||
|
assertEquals(Array.isArray(diagnostics), true);
|
||||||
|
return diagnostics[0];
|
||||||
|
}
|
||||||
|
throw new Error("expected Decodal evaluation to fail");
|
||||||
|
}
|
||||||
|
|
||||||
|
Deno.test("generated WASM preserves native Decodal 0.4 diagnostic semantics", () => {
|
||||||
|
for (
|
||||||
|
const [source, expectedKind] of [
|
||||||
|
["{ features = {}; custom = 42; }", "constraintviolation"],
|
||||||
|
[
|
||||||
|
'{ features = { web = { enabled = "yes"; }; }; }',
|
||||||
|
"constraintviolation",
|
||||||
|
],
|
||||||
|
["{ features = { web = {}; }; }", "materialize"],
|
||||||
|
[
|
||||||
|
"{ features = { web = { enabled = true; typo = 1; }; }; }",
|
||||||
|
"constraintviolation",
|
||||||
|
],
|
||||||
|
] as const
|
||||||
|
) {
|
||||||
|
const diagnostic = evaluateFailure(source);
|
||||||
|
assertEquals(diagnostic.path, "main.dcdl");
|
||||||
|
assertEquals(diagnostic.kind, expectedKind);
|
||||||
|
assertEquals(diagnostic.message.length > 0, true);
|
||||||
|
assertEquals(diagnostic.span.end_byte > diagnostic.span.start_byte, true);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user