Compare commits
24
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4cf34375a8 | ||
|
|
404809ab6e | ||
|
|
c5c89795e1 | ||
|
|
99b08e0f0c | ||
|
|
83a99541b2 | ||
|
|
1ed835fb79 | ||
|
|
ace134e2f0 | ||
|
|
ecbd003579 | ||
|
|
93e784a3ed | ||
|
|
a1f6a6bad5 | ||
|
|
a6f92104fa | ||
|
|
0ad7d6d210 | ||
|
|
48ff977d06 | ||
|
|
93eea24420 | ||
|
|
e582babae3 | ||
|
|
46ffde19a6 | ||
|
|
6446eb1302 | ||
|
|
ad0f6c68d5 | ||
|
|
6bcd59fcc6 | ||
|
|
1fad5fc8ed | ||
|
|
5c57e10de9 | ||
|
|
c0f4e80320 | ||
|
|
4857910410 | ||
|
|
c2510495ef |
Generated
+10
-7
@@ -596,9 +596,11 @@ dependencies = [
|
||||
"decodal",
|
||||
"decodal-language-service",
|
||||
"decodal-language-tools",
|
||||
"minijinja",
|
||||
"pretty_assertions",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"serde_yaml",
|
||||
"sha2 0.11.0",
|
||||
"thiserror 2.0.18",
|
||||
"ts-rs",
|
||||
@@ -986,24 +988,24 @@ checksum = "092966b41edc516079bdf31ec78a2e0588d1d0c08f78b91d8307215928642b2b"
|
||||
|
||||
[[package]]
|
||||
name = "decodal"
|
||||
version = "0.2.0"
|
||||
version = "0.4.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2b6e47d6bc66cd3cd42c8df8ff77a994a7743e889045c6b762a6dbc360ad8494"
|
||||
checksum = "30e2a1ff0bf0d4160b998401a82aef64d26395e5b4798c55fbca6880a52f8e64"
|
||||
|
||||
[[package]]
|
||||
name = "decodal-language-service"
|
||||
version = "0.2.0"
|
||||
version = "0.4.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f25e462dce7c86743bd229ba91b831daf7928d524de9cef4ef861257ca156aa8"
|
||||
checksum = "577f8cdf109dc318c6bef32c95194f0b164ccea234ab0f70a02aeb3296813b16"
|
||||
dependencies = [
|
||||
"decodal",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "decodal-language-tools"
|
||||
version = "0.2.0"
|
||||
version = "0.4.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "08d8e3eb978cb1c2259df838ba2a8102bc45553d7b415216c80fc5b6a3f378c6"
|
||||
checksum = "2a18ca80b27386c5dbb160c74e88e6c1bfe33fd80f3cd5108bebe800b8aa698c"
|
||||
dependencies = [
|
||||
"decodal",
|
||||
"serde_json",
|
||||
@@ -4793,7 +4795,6 @@ dependencies = [
|
||||
"fs4",
|
||||
"llm-engine",
|
||||
"manifest",
|
||||
"minijinja",
|
||||
"protocol",
|
||||
"pulldown-cmark",
|
||||
"ratatui",
|
||||
@@ -4807,6 +4808,7 @@ dependencies = [
|
||||
"toml",
|
||||
"unicode-width",
|
||||
"uuid",
|
||||
"worker",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -6058,6 +6060,7 @@ dependencies = [
|
||||
"chrono",
|
||||
"clap",
|
||||
"client",
|
||||
"config-source",
|
||||
"dotenv",
|
||||
"flow",
|
||||
"fs4",
|
||||
|
||||
+3
-3
@@ -98,9 +98,9 @@ yoi-workspace-server = { path = "crates/workspace-server" }
|
||||
async-trait = "0.1"
|
||||
axum = "0.8"
|
||||
base64 = "0.22.1"
|
||||
decodal = "0.2.0"
|
||||
decodal-language-service = "0.2.0"
|
||||
decodal-language-tools = "0.2.0"
|
||||
decodal = "0.4.0"
|
||||
decodal-language-service = "0.4.0"
|
||||
decodal-language-tools = "0.4.0"
|
||||
fs4 = "0.13"
|
||||
futures = "0.3"
|
||||
libc = "0.2"
|
||||
|
||||
@@ -1028,7 +1028,7 @@ profile = "builtin:companion"
|
||||
r#"
|
||||
[ticket.roles.reviewer]
|
||||
profile = "builtin:companion"
|
||||
launch_prompt = "$workspace/ticket/reviewer/launch"
|
||||
launch_prompt = "ticket.reviewer.launch"
|
||||
"#,
|
||||
);
|
||||
let mut context = TicketRoleLaunchContext::new(temp.path(), TicketRole::Reviewer);
|
||||
@@ -1043,11 +1043,11 @@ launch_prompt = "$workspace/ticket/reviewer/launch"
|
||||
assert_eq!(plan.profile, "builtin:companion");
|
||||
assert_eq!(
|
||||
plan.launch_prompt_ref.as_deref(),
|
||||
Some("$workspace/ticket/reviewer/launch")
|
||||
Some("ticket.reviewer.launch")
|
||||
);
|
||||
assert!(matches!(&plan.run_segments[0], Segment::Text { .. }));
|
||||
assert!(!text.contains("Configured launch_prompt"));
|
||||
assert!(!text.contains("$workspace/ticket/reviewer/launch"));
|
||||
assert!(!text.contains("ticket.reviewer.launch"));
|
||||
assert!(!text.contains("Profile selector: builtin:companion"));
|
||||
assert!(!text.contains("Role: reviewer"));
|
||||
assert!(!text.contains("system_instruction"));
|
||||
@@ -1228,7 +1228,7 @@ profile = "./coder.toml"
|
||||
r#"
|
||||
[ticket.roles.coder]
|
||||
profile = "inherit"
|
||||
system_instruction = "$workspace/not-supported"
|
||||
system_instruction = "unsupported"
|
||||
"#,
|
||||
);
|
||||
let context = TicketRoleLaunchContext::new(temp.path(), TicketRole::Coder);
|
||||
|
||||
@@ -8,6 +8,7 @@ use wasm_bindgen::prelude::*;
|
||||
|
||||
thread_local! {
|
||||
static SESSION: RefCell<Option<ConfigTreeSnapshot>> = const { RefCell::new(None) };
|
||||
static SCHEMA_BUNDLE: RefCell<Option<WorkspaceConfigSchemaBundle>> = const { RefCell::new(None) };
|
||||
}
|
||||
|
||||
#[wasm_bindgen]
|
||||
@@ -23,6 +24,13 @@ pub fn set_snapshot(snapshot: JsValue) -> Result<(), JsValue> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[wasm_bindgen]
|
||||
pub fn set_schema_bundle(schema_bundle: JsValue) -> Result<(), JsValue> {
|
||||
let schema_bundle: WorkspaceConfigSchemaBundle = decode(schema_bundle)?;
|
||||
SCHEMA_BUNDLE.with(|session| session.replace(Some(schema_bundle)));
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[wasm_bindgen]
|
||||
pub fn apply_changes(changes: JsValue) -> Result<JsValue, JsValue> {
|
||||
let changes: Vec<ConfigTreeChange> = decode(changes)?;
|
||||
@@ -91,8 +99,8 @@ pub fn complete_current(
|
||||
.as_ref()
|
||||
.ok_or_else(|| JsValue::from_str("config source snapshot is not initialized"))?;
|
||||
let utf8_byte_offset = utf16_to_utf8_offset(&source, utf16_offset)?;
|
||||
let result = SnapshotEnvironment::new(snapshot.clone())
|
||||
.complete(&entrypoint, &source, utf8_byte_offset, explicit)
|
||||
let result = session_environment(snapshot.clone())
|
||||
.complete_config(&entrypoint, &source, utf8_byte_offset, explicit)
|
||||
.map_err(|error| JsValue::from_str(&format!("{error:?}")))?
|
||||
.map(|result| WasmCompletionResult {
|
||||
from: result.from,
|
||||
@@ -132,7 +140,16 @@ pub fn analyze_snapshot(
|
||||
) -> Result<JsValue, JsValue> {
|
||||
let snapshot: ConfigTreeSnapshot = decode(snapshot)?;
|
||||
let entrypoint = VirtualPath::parse(entrypoint).map_err(js_error)?;
|
||||
encode(SnapshotEnvironment::new(snapshot).analyze(&entrypoint, source_override.as_deref()))
|
||||
encode(session_environment(snapshot).analyze(&entrypoint, source_override.as_deref()))
|
||||
}
|
||||
|
||||
fn session_environment(snapshot: ConfigTreeSnapshot) -> SnapshotEnvironment {
|
||||
let schema_bundle = SCHEMA_BUNDLE.with(|schema_bundle| schema_bundle.borrow().clone());
|
||||
let mut environment = SnapshotEnvironment::new(snapshot);
|
||||
if let Some(schema_bundle) = schema_bundle {
|
||||
environment = environment.with_schema_bundle(schema_bundle);
|
||||
}
|
||||
environment
|
||||
}
|
||||
|
||||
#[wasm_bindgen]
|
||||
|
||||
@@ -9,8 +9,10 @@ publish = false
|
||||
decodal.workspace = true
|
||||
decodal-language-service.workspace = true
|
||||
decodal-language-tools.workspace = true
|
||||
minijinja = "2.19.0"
|
||||
serde = { workspace = true, features = ["derive"] }
|
||||
serde_json.workspace = true
|
||||
serde_yaml.workspace = true
|
||||
sha2.workspace = true
|
||||
thiserror.workspace = true
|
||||
ts-rs = "12.0.1"
|
||||
|
||||
+1041
-14
File diff suppressed because it is too large
Load Diff
@@ -349,11 +349,6 @@ impl From<FeatureConfig> for FeatureConfigPartial {
|
||||
pub struct WorkerMetaConfig {
|
||||
#[serde(default)]
|
||||
pub name: Option<String>,
|
||||
/// Optional `PromptCatalog` manifest pack override. See
|
||||
/// [`crate::WorkerMeta::prompt_pack`] for semantics. Relative paths
|
||||
/// are resolved through [`WorkerManifestConfig::resolve_paths`].
|
||||
#[serde(default)]
|
||||
pub prompt_pack: Option<PathBuf>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
||||
@@ -554,9 +549,6 @@ impl WorkerManifestConfig {
|
||||
base.display()
|
||||
);
|
||||
resolve_auth_file(&mut self.model.auth, base);
|
||||
if let Some(ref mut pack) = self.worker.prompt_pack {
|
||||
*pack = join_if_relative(base, pack);
|
||||
}
|
||||
for rule in &mut self.scope.allow {
|
||||
rule.target = join_if_relative(base, &rule.target);
|
||||
}
|
||||
@@ -718,7 +710,6 @@ impl WorkerMetaConfig {
|
||||
fn merge(self, upper: Self) -> Self {
|
||||
Self {
|
||||
name: upper.name.or(self.name),
|
||||
prompt_pack: upper.prompt_pack.or(self.prompt_pack),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1018,10 +1009,6 @@ impl TryFrom<WorkerManifestConfig> for WorkerManifest {
|
||||
.worker
|
||||
.name
|
||||
.ok_or(ResolveError::MissingField("worker.name"))?;
|
||||
let prompt_pack = cfg.worker.prompt_pack;
|
||||
if let Some(ref p) = prompt_pack {
|
||||
ensure_absolute("worker.prompt_pack", p)?;
|
||||
}
|
||||
|
||||
validate_model_paths(&cfg.model, "model.auth.file")?;
|
||||
|
||||
@@ -1150,7 +1137,7 @@ impl TryFrom<WorkerManifestConfig> for WorkerManifest {
|
||||
validate_mcp_config(&cfg.mcp)?;
|
||||
|
||||
Ok(WorkerManifest {
|
||||
worker: WorkerMeta { name, prompt_pack },
|
||||
worker: WorkerMeta { name },
|
||||
model: cfg.model,
|
||||
engine,
|
||||
scope: cfg.scope,
|
||||
@@ -1187,7 +1174,6 @@ mod tests {
|
||||
WorkerManifestConfig {
|
||||
worker: WorkerMetaConfig {
|
||||
name: Some("test".into()),
|
||||
prompt_pack: None,
|
||||
},
|
||||
model: ModelManifest {
|
||||
scheme: Some(SchemeKind::Anthropic),
|
||||
@@ -1505,7 +1491,6 @@ mod tests {
|
||||
let lower = WorkerManifestConfig {
|
||||
worker: WorkerMetaConfig {
|
||||
name: Some("lower".into()),
|
||||
prompt_pack: None,
|
||||
},
|
||||
model: ModelManifest {
|
||||
model_id: Some("lower-model".into()),
|
||||
@@ -1516,7 +1501,6 @@ mod tests {
|
||||
let upper = WorkerManifestConfig {
|
||||
worker: WorkerMetaConfig {
|
||||
name: Some("upper".into()),
|
||||
prompt_pack: None,
|
||||
},
|
||||
..Default::default()
|
||||
};
|
||||
@@ -1925,7 +1909,6 @@ enabled = false
|
||||
.merge(WorkerManifestConfig {
|
||||
worker: WorkerMetaConfig {
|
||||
name: Some("feature-test".into()),
|
||||
prompt_pack: None,
|
||||
},
|
||||
model: ModelManifest {
|
||||
scheme: Some(SchemeKind::Anthropic),
|
||||
@@ -2008,7 +1991,6 @@ enabled = true
|
||||
.merge(WorkerManifestConfig {
|
||||
worker: WorkerMetaConfig {
|
||||
name: Some("feature-merge-test".into()),
|
||||
prompt_pack: None,
|
||||
},
|
||||
model: ModelManifest {
|
||||
scheme: Some(SchemeKind::Anthropic),
|
||||
@@ -2075,7 +2057,6 @@ permission = "write"
|
||||
let overlay = WorkerManifestConfig {
|
||||
worker: WorkerMetaConfig {
|
||||
name: Some("x".into()),
|
||||
prompt_pack: None,
|
||||
},
|
||||
model: ModelManifest {
|
||||
scheme: Some(SchemeKind::Anthropic),
|
||||
|
||||
@@ -42,10 +42,9 @@ pub const COMPACT_OVERVIEW_WARNING_TOKENS: u64 = 16_000;
|
||||
/// See [`crate::CompactionConfig::overview_deadline_tokens`].
|
||||
pub const COMPACT_OVERVIEW_DEADLINE_TOKENS: u64 = 40_000;
|
||||
|
||||
/// Default instruction asset reference used when `worker.instruction`
|
||||
/// is omitted. See the `PromptLoader` prefix addressing scheme for the
|
||||
/// `$yoi/` / `$user/` / `$workspace/` namespaces.
|
||||
pub const DEFAULT_INSTRUCTION: &str = "$yoi/default";
|
||||
/// Default exact catalog-root dotted Prompt name used when
|
||||
/// `worker.instruction` is omitted.
|
||||
pub const DEFAULT_INSTRUCTION: &str = "default";
|
||||
|
||||
/// Default language policy used by the main worker for normal prose
|
||||
/// responses. See [`crate::EngineManifest::language`].
|
||||
|
||||
@@ -19,8 +19,8 @@ pub use paths::user_profiles_path;
|
||||
pub use profile::{
|
||||
ProfileDiscovery, ProfileError, ProfileManifestSnapshot, ProfileMetadata, ProfileRegistry,
|
||||
ProfileRegistryEntry, ProfileRegistrySource, ProfileResolveOptions, ProfileResolver,
|
||||
ProfileSelector, ProfileSource, ResolvedProfile, WorkspaceOverrideSnapshot,
|
||||
resolve_profile_artifact, resolve_profile_artifact_value,
|
||||
ProfileSelector, ProfileSource, ResolvedProfile, resolve_profile_artifact,
|
||||
resolve_profile_artifact_value,
|
||||
};
|
||||
pub use protocol::{Permission, ScopeRule};
|
||||
pub use scope::{DelegationScope, Scope, ScopeError, SharedScope};
|
||||
@@ -500,29 +500,13 @@ pub struct MemoryConfig {
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct WorkerMeta {
|
||||
pub name: String,
|
||||
/// Optional path to a TOML override file read as the top layer of
|
||||
/// `worker::PromptCatalog`. Subject to the same relative-path
|
||||
/// resolution as other manifest paths (joined against the
|
||||
/// manifest's base directory). `None` leaves the 4th overlay layer
|
||||
/// empty; auto-discovered user and workspace packs still apply.
|
||||
///
|
||||
/// Note: unlike `worker.instruction`, this is a plain filesystem
|
||||
/// path — not a `$prefix/` prompt reference. Pack files carry
|
||||
/// structured TOML data, while `worker.instruction` points at a
|
||||
/// minijinja `.md` template; the two use different addressing
|
||||
/// conventions on purpose.
|
||||
#[serde(default)]
|
||||
pub prompt_pack: Option<PathBuf>,
|
||||
}
|
||||
|
||||
/// Worker-level configuration embedded in the manifest.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct EngineManifest {
|
||||
/// Reference to the instruction prompt asset used as the body of
|
||||
/// the worker's system prompt. Uses the `PromptLoader` prefix
|
||||
/// addressing scheme (`$yoi/...`, `$user/...`,
|
||||
/// `$workspace/...`) and is always populated after resolution —
|
||||
/// unset manifests fall through to [`defaults::DEFAULT_INSTRUCTION`].
|
||||
/// Exact catalog-root dotted Prompt name (for example `default` or
|
||||
/// `role.coder`).
|
||||
#[serde(default = "default_instruction")]
|
||||
pub instruction: String,
|
||||
/// Language policy used by the main worker for normal prose responses.
|
||||
@@ -959,7 +943,7 @@ model_id = "claude-sonnet-4-20250514"
|
||||
auth = { kind = "api_key", file = "/abs/keys/anthropic" }
|
||||
|
||||
[engine]
|
||||
instruction = "$user/reviewer"
|
||||
instruction = "role.reviewer"
|
||||
max_tokens = 4096
|
||||
temperature = 0.3
|
||||
top_p = 0.9
|
||||
@@ -995,7 +979,7 @@ permission = "write"
|
||||
_ => panic!("expected ApiKey"),
|
||||
};
|
||||
assert_eq!(file, Some(std::path::Path::new("/abs/keys/anthropic")));
|
||||
assert_eq!(manifest.engine.instruction, "$user/reviewer");
|
||||
assert_eq!(manifest.engine.instruction, "role.reviewer");
|
||||
assert_eq!(manifest.engine.max_tokens, Some(4096));
|
||||
assert_eq!(manifest.engine.temperature, Some(0.3));
|
||||
assert_eq!(manifest.engine.top_p, Some(0.9));
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
//! 用途別に三つの base directory を持つ:
|
||||
//!
|
||||
//! - **`config_dir`** — 人が手で書く / 編集する設定。`profiles.toml`,
|
||||
//! `providers.toml`, `models.toml`, `prompts/`, `prompts.toml` 等
|
||||
//! `providers.toml`, `models.toml` 等
|
||||
//! - **`data_dir`** — プログラムが書く永続データ。`sessions/` 等
|
||||
//! - **`secret_data_dir`** — local secret store の読み書き base。既存
|
||||
//! secret store は path-derived key を使うため、通常 data とは別に
|
||||
@@ -85,16 +85,6 @@ pub fn user_profiles_path() -> Option<PathBuf> {
|
||||
user_profiles_path_from_config_dir(config_dir())
|
||||
}
|
||||
|
||||
/// `<config_dir>/prompts/` — user prompts ライブラリ。
|
||||
pub fn user_prompts_dir() -> Option<PathBuf> {
|
||||
user_prompts_dir_from_config_dir(config_dir())
|
||||
}
|
||||
|
||||
/// `<config_dir>/prompts.toml` — user prompt pack。
|
||||
pub fn user_pack_file() -> Option<PathBuf> {
|
||||
user_pack_file_from_config_dir(config_dir())
|
||||
}
|
||||
|
||||
/// `<config_dir>/<file_name>` — providers.toml / models.toml 等の
|
||||
/// user override ファイル。
|
||||
pub fn user_catalog_override(file_name: &str) -> Option<PathBuf> {
|
||||
@@ -200,14 +190,6 @@ fn user_profiles_path_from_config_dir(config_dir: Option<PathBuf>) -> Option<Pat
|
||||
Some(config_dir?.join("profiles.toml"))
|
||||
}
|
||||
|
||||
fn user_prompts_dir_from_config_dir(config_dir: Option<PathBuf>) -> Option<PathBuf> {
|
||||
Some(config_dir?.join("prompts"))
|
||||
}
|
||||
|
||||
fn user_pack_file_from_config_dir(config_dir: Option<PathBuf>) -> Option<PathBuf> {
|
||||
Some(config_dir?.join("prompts.toml"))
|
||||
}
|
||||
|
||||
fn user_catalog_override_from_config_dir(
|
||||
config_dir: Option<PathBuf>,
|
||||
file_name: &str,
|
||||
@@ -465,14 +447,6 @@ mod tests {
|
||||
user_profiles_path_from_config_dir(config_dir.clone()).unwrap(),
|
||||
PathBuf::from("/sand/config/profiles.toml")
|
||||
);
|
||||
assert_eq!(
|
||||
user_prompts_dir_from_config_dir(config_dir.clone()).unwrap(),
|
||||
PathBuf::from("/sand/config/prompts")
|
||||
);
|
||||
assert_eq!(
|
||||
user_pack_file_from_config_dir(config_dir.clone()).unwrap(),
|
||||
PathBuf::from("/sand/config/prompts.toml")
|
||||
);
|
||||
assert_eq!(
|
||||
user_catalog_override_from_config_dir(config_dir, "providers.toml").unwrap(),
|
||||
PathBuf::from("/sand/config/providers.toml")
|
||||
|
||||
@@ -22,7 +22,6 @@ use crate::{
|
||||
|
||||
const PROFILE_FORMAT_V1: &str = "yoi.profile.v1";
|
||||
const BUILTIN_MODEL_CATALOG: &str = include_str!("../../../resources/models/builtin.toml");
|
||||
const WORKSPACE_OVERRIDE_LOCAL_FILENAME: &str = "override.local.toml";
|
||||
|
||||
struct BuiltinProfile {
|
||||
name: &'static str,
|
||||
@@ -322,10 +321,10 @@ pub struct ProfileDiscovery {
|
||||
}
|
||||
|
||||
impl ProfileDiscovery {
|
||||
pub fn for_cwd(cwd: &Path) -> Self {
|
||||
pub fn for_cwd(_cwd: &Path) -> Self {
|
||||
Self {
|
||||
user_config: paths::user_profiles_path(),
|
||||
project_config: find_project_profiles_from(cwd),
|
||||
project_config: None,
|
||||
}
|
||||
}
|
||||
pub fn with_sources(user_config: Option<PathBuf>, project_config: Option<PathBuf>) -> Self {
|
||||
@@ -363,19 +362,6 @@ pub struct ProfileManifestSnapshot {
|
||||
pub source: ProfileSource,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub profile: Option<ProfileMetadata>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub workspace_override: Option<WorkspaceOverrideSnapshot>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct WorkspaceOverrideSnapshot {
|
||||
pub path: PathBuf,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
struct WorkspaceOverrideLayer {
|
||||
path: PathBuf,
|
||||
config: WorkerManifestConfig,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
@@ -495,7 +481,6 @@ impl ProfileResolver {
|
||||
.as_deref()
|
||||
.unwrap_or_else(|| Path::new(".")),
|
||||
)?;
|
||||
let workspace_override = load_workspace_override_from(&workspace_base)?;
|
||||
let raw_artifact = read_profile_artifact_file(&absolute_path)?;
|
||||
resolve_profile_value(
|
||||
source,
|
||||
@@ -504,7 +489,6 @@ impl ProfileResolver {
|
||||
options,
|
||||
raw_artifact.clone(),
|
||||
raw_artifact,
|
||||
workspace_override,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -519,7 +503,6 @@ impl ProfileResolver {
|
||||
.as_deref()
|
||||
.unwrap_or_else(|| Path::new(".")),
|
||||
)?;
|
||||
let workspace_override = load_workspace_override_from(&workspace_base)?;
|
||||
let raw_artifact = builtin_profile_artifact(label).ok_or_else(|| {
|
||||
ProfileError::InvalidProfile(format!("unknown builtin profile artifact `{label}`"))
|
||||
})?;
|
||||
@@ -530,7 +513,6 @@ impl ProfileResolver {
|
||||
options,
|
||||
raw_artifact.clone(),
|
||||
raw_artifact,
|
||||
workspace_override,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -542,7 +524,6 @@ fn resolve_profile_value(
|
||||
options: ProfileResolveOptions,
|
||||
value: serde_json::Value,
|
||||
raw_artifact: serde_json::Value,
|
||||
workspace_override: Option<WorkspaceOverrideLayer>,
|
||||
) -> Result<ResolvedProfile, ProfileError> {
|
||||
if !workspace_base.is_absolute() {
|
||||
return Err(ProfileError::InvalidPath {
|
||||
@@ -566,7 +547,6 @@ fn resolve_profile_value(
|
||||
let config = WorkerManifestConfig {
|
||||
worker: WorkerMetaConfig {
|
||||
name: Some(worker_name),
|
||||
prompt_pack: None,
|
||||
},
|
||||
model: profile.model.unwrap_or_default(),
|
||||
engine: profile.engine.unwrap_or_default(),
|
||||
@@ -585,29 +565,11 @@ fn resolve_profile_value(
|
||||
memory: profile.memory,
|
||||
skills: profile.skills,
|
||||
};
|
||||
let mut config =
|
||||
WorkerManifestConfig::builtin_defaults().merge(config.resolve_paths(profile_dir));
|
||||
let workspace_override_snapshot = if let Some(override_layer) = workspace_override {
|
||||
let override_base =
|
||||
override_layer
|
||||
.path
|
||||
.parent()
|
||||
.ok_or_else(|| ProfileError::InvalidPath {
|
||||
path: override_layer.path.clone(),
|
||||
message: "workspace override path has no parent directory".into(),
|
||||
})?;
|
||||
config = config.merge(override_layer.config.resolve_paths(override_base));
|
||||
Some(WorkspaceOverrideSnapshot {
|
||||
path: override_layer.path,
|
||||
})
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let config = WorkerManifestConfig::builtin_defaults().merge(config.resolve_paths(profile_dir));
|
||||
let mut manifest = WorkerManifest::try_from(config).map_err(ProfileError::ManifestResolve)?;
|
||||
manifest.profile = Some(ProfileManifestSnapshot {
|
||||
source: source.clone(),
|
||||
profile: profile_meta.clone(),
|
||||
workspace_override: workspace_override_snapshot,
|
||||
});
|
||||
let manifest_snapshot =
|
||||
serde_json::to_value(&manifest).map_err(ProfileError::SnapshotSerialize)?;
|
||||
@@ -751,70 +713,6 @@ fn load_profile_registry_file(
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn load_workspace_override_from(
|
||||
workspace_base: &Path,
|
||||
) -> Result<Option<WorkspaceOverrideLayer>, ProfileError> {
|
||||
find_workspace_override_from(workspace_base)
|
||||
.map(|path| load_workspace_override_file(&path))
|
||||
.transpose()
|
||||
}
|
||||
|
||||
fn load_workspace_override_file(path: &Path) -> Result<WorkspaceOverrideLayer, ProfileError> {
|
||||
let content =
|
||||
std::fs::read_to_string(path).map_err(|source| ProfileError::WorkspaceOverrideRead {
|
||||
path: path.to_path_buf(),
|
||||
source,
|
||||
})?;
|
||||
let config = WorkerManifestConfig::from_toml(&content).map_err(|source| {
|
||||
ProfileError::WorkspaceOverrideParse {
|
||||
path: path.to_path_buf(),
|
||||
source,
|
||||
}
|
||||
})?;
|
||||
if config.worker.name.is_some() {
|
||||
return Err(ProfileError::InvalidWorkspaceOverride {
|
||||
path: path.to_path_buf(),
|
||||
message: "workspace-local manifest overrides cannot set worker.name; Worker identity is a runtime input".into(),
|
||||
});
|
||||
}
|
||||
Ok(WorkspaceOverrideLayer {
|
||||
path: path.to_path_buf(),
|
||||
config,
|
||||
})
|
||||
}
|
||||
|
||||
fn find_workspace_override_from(start: &Path) -> Option<PathBuf> {
|
||||
let start = start
|
||||
.canonicalize()
|
||||
.ok()
|
||||
.unwrap_or_else(|| start.to_path_buf());
|
||||
let mut cur: Option<&Path> = Some(start.as_path());
|
||||
while let Some(dir) = cur {
|
||||
let candidate = dir.join(".yoi").join(WORKSPACE_OVERRIDE_LOCAL_FILENAME);
|
||||
if candidate.is_file() {
|
||||
return Some(candidate);
|
||||
}
|
||||
cur = dir.parent();
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
fn find_project_profiles_from(start: &Path) -> Option<PathBuf> {
|
||||
let start = start
|
||||
.canonicalize()
|
||||
.ok()
|
||||
.unwrap_or_else(|| start.to_path_buf());
|
||||
let mut cur: Option<&Path> = Some(start.as_path());
|
||||
while let Some(dir) = cur {
|
||||
let candidate = dir.join(".yoi").join("profiles.toml");
|
||||
if candidate.is_file() {
|
||||
return Some(candidate);
|
||||
}
|
||||
cur = dir.parent();
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
fn add_builtin_profiles(registry: &mut ProfileRegistry) {
|
||||
for profile in BUILTIN_PROFILES {
|
||||
registry.push_entry(ProfileRegistryEntry::embedded(
|
||||
@@ -1285,7 +1183,6 @@ pub fn resolve_profile_artifact_value(
|
||||
ProfileResolveOptions::with_worker_name(worker_name),
|
||||
raw_artifact.clone(),
|
||||
raw_artifact,
|
||||
None,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1313,20 +1210,6 @@ pub enum ProfileError {
|
||||
#[source]
|
||||
source: toml::de::Error,
|
||||
},
|
||||
#[error("failed to read workspace local manifest override {}: {source}", .path.display())]
|
||||
WorkspaceOverrideRead {
|
||||
path: PathBuf,
|
||||
#[source]
|
||||
source: std::io::Error,
|
||||
},
|
||||
#[error("failed to parse workspace local manifest override {}: {source}", .path.display())]
|
||||
WorkspaceOverrideParse {
|
||||
path: PathBuf,
|
||||
#[source]
|
||||
source: toml::de::Error,
|
||||
},
|
||||
#[error("invalid workspace local manifest override {}: {message}", .path.display())]
|
||||
InvalidWorkspaceOverride { path: PathBuf, message: String },
|
||||
#[error("no default profile is configured")]
|
||||
NoDefaultProfile,
|
||||
#[error("profile resolution requires an explicit runtime Worker name")]
|
||||
@@ -1574,27 +1457,15 @@ mod tests {
|
||||
assert!(reviewer.compaction.is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn orchestrator_role_keeps_review_routing_owned_by_coder() {
|
||||
let prompt = include_str!("../../../resources/prompts/role/orchestrator.md");
|
||||
|
||||
assert!(prompt.contains("assigned Coder owns its review/fix loop"));
|
||||
assert!(prompt.contains("then use `SpawnTicketCoder`"));
|
||||
assert!(prompt.contains("verify its current assignment names that Coder"));
|
||||
assert!(prompt.contains("never route implementation to an unassigned Coder"));
|
||||
assert!(prompt.contains(
|
||||
"Do not spawn, restore, assign, or route work to Backend/Runtime Reviewer Workers"
|
||||
));
|
||||
assert!(prompt.contains("never compensate by creating an independent Reviewer Worker"));
|
||||
assert!(!prompt.contains("sibling Coder/Reviewer Workers"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn profile_resolution_requires_runtime_worker_name() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let err = ProfileResolver::new()
|
||||
.with_workspace_base(tmp.path())
|
||||
.resolve(&ProfileSelector::Default, ProfileResolveOptions::default())
|
||||
.resolve(
|
||||
&ProfileSelector::source_named(ProfileRegistrySource::Builtin, "companion"),
|
||||
ProfileResolveOptions::default(),
|
||||
)
|
||||
.unwrap_err();
|
||||
assert!(matches!(err, ProfileError::MissingRuntimeWorkerName));
|
||||
}
|
||||
@@ -1857,134 +1728,6 @@ worker_context_max_tokens = 68000
|
||||
}
|
||||
);
|
||||
}
|
||||
#[test]
|
||||
fn workspace_local_override_layers_over_profile_defaults() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let workspace = tmp.path().join("project");
|
||||
let nested = workspace.join("nested");
|
||||
let yoi_dir = workspace.join(".yoi");
|
||||
std::fs::create_dir_all(&nested).unwrap();
|
||||
std::fs::create_dir_all(&yoi_dir).unwrap();
|
||||
let override_path = yoi_dir.join(WORKSPACE_OVERRIDE_LOCAL_FILENAME);
|
||||
std::fs::write(
|
||||
&override_path,
|
||||
r#"
|
||||
[worker]
|
||||
prompt_pack = "prompts.toml"
|
||||
[engine]
|
||||
language = "ja"
|
||||
[session]
|
||||
record_event_trace = false
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let resolved = ProfileResolver::new()
|
||||
.with_workspace_base(&nested)
|
||||
.resolve(
|
||||
&ProfileSelector::Default,
|
||||
ProfileResolveOptions::with_worker_name("runtime-worker"),
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(resolved.manifest.worker.name, "runtime-worker");
|
||||
assert_eq!(resolved.manifest.engine.language, "ja");
|
||||
assert!(!resolved.manifest.session.record_event_trace);
|
||||
assert_eq!(
|
||||
resolved.manifest.worker.prompt_pack.as_deref(),
|
||||
Some(yoi_dir.join("prompts.toml").as_path())
|
||||
);
|
||||
assert!(resolved.manifest.scope.allow.is_empty());
|
||||
assert_eq!(
|
||||
resolved
|
||||
.manifest
|
||||
.profile
|
||||
.as_ref()
|
||||
.and_then(|snapshot| snapshot.workspace_override.as_ref())
|
||||
.map(|snapshot| snapshot.path.as_path()),
|
||||
Some(override_path.as_path())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn workspace_local_override_uses_nearest_ancestor() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let workspace = tmp.path().join("project");
|
||||
let nested = workspace.join("nested");
|
||||
let child = nested.join("child");
|
||||
let parent_yoi = workspace.join(".yoi");
|
||||
let nested_yoi = nested.join(".yoi");
|
||||
std::fs::create_dir_all(&child).unwrap();
|
||||
std::fs::create_dir_all(&parent_yoi).unwrap();
|
||||
std::fs::create_dir_all(&nested_yoi).unwrap();
|
||||
std::fs::write(
|
||||
parent_yoi.join(WORKSPACE_OVERRIDE_LOCAL_FILENAME),
|
||||
r#"
|
||||
[worker]
|
||||
prompt_pack = "parent-prompts.toml"
|
||||
[engine]
|
||||
language = "parent"
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
let nested_override_path = nested_yoi.join(WORKSPACE_OVERRIDE_LOCAL_FILENAME);
|
||||
std::fs::write(
|
||||
&nested_override_path,
|
||||
r#"
|
||||
[worker]
|
||||
prompt_pack = "nested-prompts.toml"
|
||||
[engine]
|
||||
language = "nested"
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let resolved = ProfileResolver::new()
|
||||
.with_workspace_base(&child)
|
||||
.resolve(
|
||||
&ProfileSelector::Default,
|
||||
ProfileResolveOptions::with_worker_name("runtime-worker"),
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(resolved.manifest.engine.language, "nested");
|
||||
assert_eq!(
|
||||
resolved.manifest.worker.prompt_pack.as_deref(),
|
||||
Some(nested_yoi.join("nested-prompts.toml").as_path())
|
||||
);
|
||||
assert_eq!(
|
||||
resolved
|
||||
.manifest
|
||||
.profile
|
||||
.as_ref()
|
||||
.and_then(|snapshot| snapshot.workspace_override.as_ref())
|
||||
.map(|snapshot| snapshot.path.as_path()),
|
||||
Some(nested_override_path.as_path())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn workspace_local_override_rejects_runtime_worker_name() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let yoi_dir = tmp.path().join(".yoi");
|
||||
std::fs::create_dir_all(&yoi_dir).unwrap();
|
||||
std::fs::write(
|
||||
yoi_dir.join(WORKSPACE_OVERRIDE_LOCAL_FILENAME),
|
||||
"[worker]\nname = \"not-local\"\n",
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let err = ProfileResolver::new()
|
||||
.with_workspace_base(tmp.path())
|
||||
.resolve(
|
||||
&ProfileSelector::Default,
|
||||
ProfileResolveOptions::with_worker_name("runtime-worker"),
|
||||
)
|
||||
.unwrap_err();
|
||||
assert!(matches!(err, ProfileError::InvalidWorkspaceOverride { .. }));
|
||||
assert!(err.to_string().contains("worker.name"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unsupported_profile_extension_has_clear_diagnostic() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
@@ -2003,7 +1746,7 @@ language = "nested"
|
||||
);
|
||||
}
|
||||
#[test]
|
||||
fn discovery_reads_user_and_project_registry_and_project_default_wins() {
|
||||
fn explicit_discovery_sources_preserve_non_workspace_tooling_contract() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let user_config = tmp.path().join("profiles.toml");
|
||||
let project_dir = tmp.path().join("project/.yoi");
|
||||
|
||||
@@ -1047,19 +1047,19 @@ worktree_name = "custom-orchestrator"
|
||||
|
||||
[ticket.roles.intake]
|
||||
profile = "project:intake"
|
||||
launch_prompt = "$workspace/ticket/intake/launch"
|
||||
launch_prompt = "ticket.intake.launch"
|
||||
|
||||
[ticket.roles.orchestrator]
|
||||
profile = "project:orchestrator"
|
||||
launch_prompt = "$workspace/ticket/orchestrator/launch"
|
||||
launch_prompt = "ticket.orchestrator.launch"
|
||||
|
||||
[ticket.roles.coder]
|
||||
profile = "inherit"
|
||||
launch_prompt = "$workspace/ticket/coder/launch"
|
||||
launch_prompt = "ticket.coder.launch"
|
||||
|
||||
[ticket.roles.reviewer]
|
||||
profile = "project:reviewer"
|
||||
launch_prompt = "$workspace/ticket/reviewer/launch"
|
||||
launch_prompt = "ticket.reviewer.launch"
|
||||
"#,
|
||||
);
|
||||
|
||||
@@ -1095,7 +1095,7 @@ launch_prompt = "$workspace/ticket/reviewer/launch"
|
||||
.launch_prompt_for(TicketRole::Reviewer)
|
||||
.unwrap()
|
||||
.as_str(),
|
||||
"$workspace/ticket/reviewer/launch"
|
||||
"ticket.reviewer.launch"
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1338,7 +1338,7 @@ profile = "builtin:companion"
|
||||
r#"
|
||||
[roles.coder]
|
||||
profile = "inherit"
|
||||
system_instruction = "$workspace/not-supported"
|
||||
system_instruction = "unsupported"
|
||||
"#,
|
||||
);
|
||||
|
||||
|
||||
@@ -25,7 +25,7 @@ session-store = { workspace = true }
|
||||
fs4 = { workspace = true }
|
||||
ticket = { workspace = true }
|
||||
serde = { workspace = true, features = ["derive"] }
|
||||
minijinja = "2.19.0"
|
||||
worker = { path = "../worker" }
|
||||
pulldown-cmark = { version = "0.13.3", default-features = false }
|
||||
llm-engine.workspace = true
|
||||
|
||||
|
||||
@@ -3539,7 +3539,9 @@ mod completion_flow_tests {
|
||||
arguments: args.into(),
|
||||
});
|
||||
}
|
||||
assert!(app.task_store.tasks().is_empty());
|
||||
let tasks = app.task_store.tasks();
|
||||
assert_eq!(tasks.len(), 1);
|
||||
assert_eq!(tasks[0].status, crate::task::TaskStatus::Completed);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -69,8 +69,7 @@ use render::{PanelListRow, row_hit_boxes};
|
||||
|
||||
const MAX_ENTRIES: usize = 50;
|
||||
const CLOSED_VISIBLE_ROWS: usize = 3;
|
||||
const ORCHESTRATOR_IDLE_QUEUE_NOTICE_TEMPLATE: &str =
|
||||
include_str!("../../../../resources/prompts/panel/orchestrator_idle_queue_notice.md");
|
||||
const ORCHESTRATOR_IDLE_QUEUE_NOTICE_PROMPT: &str = "panel.orchestrator_idle_queue_notice";
|
||||
const ORCHESTRATOR_QUEUE_ATTENTION_MAX_TICKETS: usize = 6;
|
||||
const ORCHESTRATOR_QUEUE_ATTENTION_MAX_TEXT_CHARS: usize = 120;
|
||||
const ORCHESTRATOR_QUEUE_ATTENTION_MAX_MESSAGE_CHARS: usize = 2_400;
|
||||
@@ -3791,15 +3790,9 @@ fn orchestrator_queue_template_ticket(
|
||||
|
||||
fn render_orchestrator_queue_attention_template(
|
||||
context: &OrchestratorQueueTemplateContext,
|
||||
) -> Result<String, minijinja::Error> {
|
||||
let mut env = minijinja::Environment::new();
|
||||
env.set_undefined_behavior(minijinja::UndefinedBehavior::Strict);
|
||||
env.add_template(
|
||||
"orchestrator_idle_queue_notice",
|
||||
ORCHESTRATOR_IDLE_QUEUE_NOTICE_TEMPLATE,
|
||||
)?;
|
||||
env.get_template("orchestrator_idle_queue_notice")?
|
||||
.render(context)
|
||||
) -> Result<String, worker::CatalogError> {
|
||||
worker::PromptCatalog::builtins_only()?
|
||||
.render_serializable(ORCHESTRATOR_IDLE_QUEUE_NOTICE_PROMPT, context)
|
||||
}
|
||||
|
||||
fn orchestrator_work_set_detail(
|
||||
|
||||
+12
-52
@@ -1,7 +1,7 @@
|
||||
//! Inline-viewport "spawn Worker and attach" UX.
|
||||
//!
|
||||
//! Rendered at the user's current cursor position when `yoi` is invoked
|
||||
//! with no positional argument. Discovers `.yoi/profiles.toml` profile
|
||||
//! with no positional argument. Uses user-configured and bundled Profile
|
||||
//! choices plus bundled profiles, defaults to the builtin profile, prompts for
|
||||
//! the Worker's name, and on confirmation launches the Worker runtime command as an
|
||||
//! independent process. Once the process reports its socket via the
|
||||
@@ -654,68 +654,28 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn profile_choices_use_project_registry_default() {
|
||||
fn profile_choices_ignore_repository_local_profile_registry() {
|
||||
let temp = tempfile::tempdir().unwrap();
|
||||
let project = temp.path().join("project");
|
||||
let yoi = project.join(".yoi");
|
||||
std::fs::create_dir_all(&yoi).unwrap();
|
||||
std::fs::write(
|
||||
yoi.join("profiles.toml"),
|
||||
r#"
|
||||
default = "coder"
|
||||
[profile]
|
||||
coder = "profiles/coder.toml"
|
||||
"#,
|
||||
"default = \"coder\"\n[profile]\ncoder = \"profiles/coder.toml\"\n",
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let (choices, default_index) = profile_choices_for_cwd(&project);
|
||||
let default_choice = choices
|
||||
.iter()
|
||||
.position(|choice| choice.selector.as_deref() == Some("project:coder"))
|
||||
.expect("project default choice is present");
|
||||
assert_eq!(default_index, default_choice);
|
||||
let selected = &choices[default_index];
|
||||
assert_eq!(selected.selector.as_deref(), Some("project:coder"));
|
||||
assert_eq!(selected.label, "project:coder (default)");
|
||||
assert!(selected.is_default);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn profile_choices_include_builtin_and_project_default_marker() {
|
||||
let temp = tempfile::tempdir().unwrap();
|
||||
let project = temp.path().join("project");
|
||||
let yoi = project.join(".yoi");
|
||||
std::fs::create_dir_all(&yoi).unwrap();
|
||||
std::fs::write(
|
||||
yoi.join("profiles.toml"),
|
||||
r#"
|
||||
default = "coder"
|
||||
[profile.coder]
|
||||
path = "profiles/coder.toml"
|
||||
description = "Project coder"
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let (choices, default_index) = profile_choices_for_cwd(&project);
|
||||
assert_eq!(choices[0].selector.as_deref(), Some("builtin:companion"));
|
||||
assert_eq!(
|
||||
choices[0].label,
|
||||
"builtin:companion — Bundled Companion role profile"
|
||||
assert_eq!(default_index, 0);
|
||||
assert!(
|
||||
choices
|
||||
.iter()
|
||||
.all(|choice| { choice.selector.as_deref() != Some("project:coder") })
|
||||
);
|
||||
let project_index = choices
|
||||
.iter()
|
||||
.position(|choice| choice.selector.as_deref() == Some("project:coder"))
|
||||
.expect("project default choice is present");
|
||||
assert_eq!(default_index, project_index);
|
||||
assert_eq!(
|
||||
choices[project_index].selector.as_deref(),
|
||||
Some("project:coder")
|
||||
);
|
||||
assert_eq!(
|
||||
choices[project_index].label,
|
||||
"project:coder (default) — Project coder"
|
||||
assert!(
|
||||
choices
|
||||
.iter()
|
||||
.any(|choice| { choice.selector.as_deref() == Some("builtin:companion") })
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
+24
-19
@@ -28,8 +28,8 @@ pub enum TaskStatus {
|
||||
}
|
||||
|
||||
impl TaskStatus {
|
||||
fn is_active(self) -> bool {
|
||||
matches!(self, Self::Pending | Self::Inprogress)
|
||||
fn is_retained(self) -> bool {
|
||||
!matches!(self, Self::Deleted)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -125,7 +125,7 @@ impl TaskStore {
|
||||
if let Some(d) = p.description {
|
||||
t.description = d;
|
||||
}
|
||||
if !t.status.is_active() {
|
||||
if !t.status.is_retained() {
|
||||
self.tasks.remove(task_position);
|
||||
}
|
||||
}
|
||||
@@ -145,7 +145,7 @@ impl TaskStore {
|
||||
fn replace_with(&mut self, tasks: Vec<TaskEntry>) {
|
||||
let tasks: Vec<_> = tasks
|
||||
.into_iter()
|
||||
.filter(|task| task.status.is_active())
|
||||
.filter(|task| task.status.is_retained())
|
||||
.collect();
|
||||
self.next_taskid = tasks
|
||||
.iter()
|
||||
@@ -194,7 +194,7 @@ fn parse_snapshot_text(text: &str) -> Option<Vec<TaskEntry>> {
|
||||
snapshot
|
||||
.tasks
|
||||
.into_iter()
|
||||
.filter(|task| task.status.is_active())
|
||||
.filter(|task| task.status.is_retained())
|
||||
.collect(),
|
||||
)
|
||||
}
|
||||
@@ -241,7 +241,7 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn counts_tracks_only_active_tasks_after_completion() {
|
||||
fn counts_retains_completed_tasks_until_deleted() {
|
||||
let mut s = TaskStore::new();
|
||||
s.apply_tool_call("TaskCreate", r#"{"subject":"a","description":""}"#);
|
||||
s.apply_tool_call("TaskCreate", r#"{"subject":"b","description":""}"#);
|
||||
@@ -251,10 +251,13 @@ mod tests {
|
||||
let c = s.counts();
|
||||
assert_eq!(c.pending, 1);
|
||||
assert_eq!(c.inprogress, 1);
|
||||
assert_eq!(c.completed, 0);
|
||||
assert_eq!(c.completed, 1);
|
||||
assert_eq!(c.deleted, 0);
|
||||
assert_eq!(c.total(), 2);
|
||||
assert_eq!(c.total(), 3);
|
||||
assert_eq!(c.active(), 2);
|
||||
|
||||
s.apply_tool_call("TaskUpdate", r#"{"taskid":2,"status":"deleted"}"#);
|
||||
assert!(s.tasks().iter().all(|task| task.taskid != 2));
|
||||
}
|
||||
|
||||
/// Snapshot text matches the wrapping `Worker::try_pre_run_compact` and the
|
||||
@@ -263,7 +266,7 @@ mod tests {
|
||||
fn wrap_snapshot(json_body: &str, overview: &str) -> String {
|
||||
format!(
|
||||
"[Session TaskStore snapshot]\n\n{overview}\n\n```json\n{json_body}\n```\n\n\
|
||||
This is the active session task list preserved across compaction. \
|
||||
This is the retained session task list preserved across compaction. \
|
||||
The following TaskList tool result presents the same state through the tool lane."
|
||||
)
|
||||
}
|
||||
@@ -288,19 +291,21 @@ mod tests {
|
||||
}"#;
|
||||
let text = wrap_snapshot(
|
||||
body,
|
||||
"TaskStore: 1 active task(s) (pending: 1, inprogress: 0)",
|
||||
"TaskStore: 2 task(s) (pending: 1, inprogress: 0, completed: 1)",
|
||||
);
|
||||
let mut s = TaskStore::new();
|
||||
s.apply_tool_call("TaskCreate", r#"{"subject":"stale","description":""}"#);
|
||||
s.apply_system_message_text(&text);
|
||||
let tasks = s.tasks();
|
||||
assert_eq!(tasks.len(), 1);
|
||||
assert_eq!(tasks[0].taskid, 7);
|
||||
assert_eq!(tasks[0].status, TaskStatus::Pending);
|
||||
// Subsequent TaskCreate must continue beyond the highest active taskid
|
||||
assert_eq!(tasks.len(), 2);
|
||||
assert_eq!(tasks[0].taskid, 5);
|
||||
assert_eq!(tasks[0].status, TaskStatus::Completed);
|
||||
assert_eq!(tasks[1].taskid, 7);
|
||||
assert_eq!(tasks[1].status, TaskStatus::Pending);
|
||||
// Subsequent TaskCreate must continue beyond the highest retained taskid
|
||||
// observed in the snapshot.
|
||||
s.apply_tool_call("TaskCreate", r#"{"subject":"new","description":""}"#);
|
||||
assert_eq!(s.tasks()[1].taskid, 8);
|
||||
assert_eq!(s.tasks()[2].taskid, 8);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -324,7 +329,7 @@ mod tests {
|
||||
}
|
||||
]
|
||||
}"#;
|
||||
let text = wrap_snapshot(body, "TaskStore: 1 active task(s)");
|
||||
let text = wrap_snapshot(body, "TaskStore: 1 task(s)");
|
||||
let mut s = TaskStore::new();
|
||||
s.apply_system_message_text(&text);
|
||||
let t = &s.tasks()[0];
|
||||
@@ -349,13 +354,13 @@ mod snapshot_format_contract {
|
||||
fn wrap_pod_style(snapshot_text: &str) -> String {
|
||||
format!(
|
||||
"[Session TaskStore snapshot]\n\n{snapshot_text}\n\n\
|
||||
This is the active session task list preserved across compaction. \
|
||||
This is the retained session task list preserved across compaction. \
|
||||
The following TaskList tool result presents the same state through the tool lane."
|
||||
)
|
||||
}
|
||||
|
||||
fn snapshot_fixture() -> &'static str {
|
||||
r#"TaskStore: 1 active task(s) (pending: 0, inprogress: 1)
|
||||
r#"TaskStore: 1 task(s) (pending: 0, inprogress: 1, completed: 0)
|
||||
|
||||
```json
|
||||
{
|
||||
@@ -372,7 +377,7 @@ mod snapshot_format_contract {
|
||||
}
|
||||
|
||||
fn empty_snapshot_fixture() -> &'static str {
|
||||
r#"TaskStore: 0 active task(s) (pending: 0, inprogress: 0)
|
||||
r#"TaskStore: 0 task(s) (pending: 0, inprogress: 0, completed: 0)
|
||||
|
||||
```json
|
||||
{
|
||||
|
||||
@@ -24,6 +24,8 @@ pub struct ConfigBundle {
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub declarations: Vec<ConfigDeclaration>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub prompt_catalog: Option<worker::EffectivePromptCatalog>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub profile_source_archive: Option<ProfileSourceArchive>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub profile_source_archive_handle: Option<BackendResourceHandle>,
|
||||
@@ -69,6 +71,16 @@ impl ConfigBundle {
|
||||
));
|
||||
}
|
||||
|
||||
if let Some(prompt_catalog) = &self.prompt_catalog {
|
||||
lines.push(format!(
|
||||
"prompt_catalog\0{}\0{}\0{}\0{}",
|
||||
prompt_catalog.config_revision,
|
||||
prompt_catalog.schema_fingerprint,
|
||||
prompt_catalog.toolchain_fingerprint,
|
||||
prompt_catalog.catalog_digest
|
||||
));
|
||||
}
|
||||
|
||||
if let Some(archive) = &self.profile_source_archive {
|
||||
lines.push(format!(
|
||||
"profile_archive\0{}\0{}\0{}",
|
||||
@@ -274,6 +286,12 @@ pub(crate) fn validate_config_bundle(bundle: &ConfigBundle) -> Result<(), Runtim
|
||||
validate_declaration_reference(&bundle.metadata.id, declaration)?;
|
||||
}
|
||||
|
||||
if let Some(prompt_catalog) = &bundle.prompt_catalog {
|
||||
prompt_catalog.verify_digest().map_err(|error| {
|
||||
RuntimeError::InvalidRequest(format!("invalid Prompt catalog projection: {error}"))
|
||||
})?;
|
||||
}
|
||||
|
||||
if let Some(archive) = &bundle.profile_source_archive {
|
||||
validate_profile_source_archive_ref(&archive.reference).map_err(|err| {
|
||||
RuntimeError::InvalidRequest(format!("invalid profile source archive: {err}"))
|
||||
@@ -582,6 +600,7 @@ mod tests {
|
||||
name: "credential".to_string(),
|
||||
reference: reference.to_string(),
|
||||
}],
|
||||
prompt_catalog: None,
|
||||
profile_source_archive: None,
|
||||
profile_source_archive_handle: None,
|
||||
}
|
||||
@@ -615,6 +634,32 @@ mod tests {
|
||||
validate_config_bundle(&bundle_with_declaration("vault:team.api-key")).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn validates_immutable_prompt_catalog_projection() {
|
||||
let mut bundle = bundle_with_declaration("secret:github-token");
|
||||
bundle.prompt_catalog = Some(
|
||||
worker::EffectivePromptCatalog::new(
|
||||
std::collections::BTreeMap::from([("default".to_string(), "hello".to_string())]),
|
||||
7,
|
||||
"schema",
|
||||
"toolchain",
|
||||
)
|
||||
.unwrap(),
|
||||
);
|
||||
bundle = bundle.with_computed_digest();
|
||||
validate_config_bundle(&bundle).unwrap();
|
||||
|
||||
bundle
|
||||
.prompt_catalog
|
||||
.as_mut()
|
||||
.unwrap()
|
||||
.templates
|
||||
.insert("default".into(), "tampered".into());
|
||||
bundle = bundle.with_computed_digest();
|
||||
let error = validate_config_bundle(&bundle).unwrap_err();
|
||||
assert!(error.to_string().contains("catalog digest mismatch"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bundle_summary_redacts_runtime_internal_resource_handle() {
|
||||
let mut bundle = bundle_with_declaration("secret:github-token");
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
use crate::catalog::{CreateWorkerRequest, WorkingDirectoryStatus};
|
||||
use crate::config_bundle::ConfigBundle;
|
||||
use crate::config_bundle::{ConfigBundle, validate_config_bundle};
|
||||
use crate::diagnostics::{DiagnosticSeverity, RuntimeDiagnostic};
|
||||
use crate::error::RuntimeError;
|
||||
use crate::identity::{WorkerId, WorkerRef};
|
||||
@@ -324,6 +324,13 @@ impl RuntimeSnapshot {
|
||||
message: format!("runtime snapshot backend is {:?}", self.backend),
|
||||
});
|
||||
}
|
||||
for bundle in self.config_bundles.values() {
|
||||
validate_config_bundle(bundle).map_err(|error| RuntimeError::StoreCorrupt {
|
||||
operation: "read runtime snapshot",
|
||||
path: path.to_path_buf(),
|
||||
message: format!("invalid config bundle {}: {error}", bundle.metadata.id),
|
||||
})?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
|
||||
@@ -1811,12 +1811,21 @@ mod tests {
|
||||
label: Some("test".to_string()),
|
||||
}],
|
||||
declarations: Vec::new(),
|
||||
prompt_catalog: None,
|
||||
profile_source_archive: None,
|
||||
profile_source_archive_handle: None,
|
||||
}
|
||||
.with_computed_digest()
|
||||
}
|
||||
|
||||
fn store_coder_test_bundle(runtime: &Runtime) {
|
||||
runtime
|
||||
.store_config_bundle(test_bundle(ProfileSelector::Builtin(
|
||||
"builtin:coder".to_string(),
|
||||
)))
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
fn scoped_task_request(objective: &str, workspace_id: &str) -> CreateWorkerRequest {
|
||||
let mut request = task_request(objective);
|
||||
request.workspace_api = Some(WorkspaceApiRef {
|
||||
@@ -1922,6 +1931,7 @@ mod tests {
|
||||
let runtime =
|
||||
Runtime::with_execution_backend(RuntimeOptions::default(), Arc::new(AcceptingBackend))
|
||||
.unwrap();
|
||||
store_coder_test_bundle(&runtime);
|
||||
let (auth, signer) = auth_config_and_signer();
|
||||
let token_a = token_for_workspace(&signer, "workspace-a");
|
||||
let token_b = token_for_workspace(&signer, "workspace-b");
|
||||
@@ -2002,6 +2012,7 @@ mod tests {
|
||||
let runtime =
|
||||
Runtime::with_execution_backend(RuntimeOptions::default(), Arc::new(AcceptingBackend))
|
||||
.unwrap();
|
||||
store_coder_test_bundle(&runtime);
|
||||
let (auth, signer_a, signer_b) = auth_config_and_two_signers();
|
||||
let token_a = token_for_workspace(&signer_a, "workspace-a");
|
||||
let token_b = token_for_workspace(&signer_b, "workspace-a");
|
||||
@@ -2648,6 +2659,7 @@ mod ws_tests {
|
||||
label: Some("ws".to_string()),
|
||||
}],
|
||||
declarations: Vec::new(),
|
||||
prompt_catalog: None,
|
||||
profile_source_archive: None,
|
||||
profile_source_archive_handle: None,
|
||||
}
|
||||
|
||||
@@ -743,10 +743,10 @@ mod tests {
|
||||
.load(Some("profiles/main.dcdl"), "./shared.dcdl")
|
||||
.unwrap();
|
||||
match loaded {
|
||||
LoadedImport::Source(source) => {
|
||||
assert_eq!(source.key, "profiles/shared.dcdl");
|
||||
LoadedImport::Source { key, .. } => {
|
||||
assert_eq!(key, "profiles/shared.dcdl");
|
||||
}
|
||||
LoadedImport::Value(_) => panic!("expected source import"),
|
||||
LoadedImport::Value { .. } => panic!("expected source import"),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -261,6 +261,19 @@ impl Runtime {
|
||||
digest: bundle.metadata.digest.clone(),
|
||||
};
|
||||
let summary = bundle.summary();
|
||||
if let Some(existing) = state.config_bundles.get(&bundle.metadata.id) {
|
||||
if existing.metadata.digest != bundle.metadata.digest {
|
||||
return Err(RuntimeError::ConfigBundleDigestMismatch {
|
||||
bundle_id: bundle.metadata.id.clone(),
|
||||
expected_digest: existing.metadata.digest.clone(),
|
||||
actual_digest: bundle.metadata.digest.clone(),
|
||||
});
|
||||
}
|
||||
return Ok(ConfigBundleAvailability {
|
||||
reference,
|
||||
summary: existing.summary(),
|
||||
});
|
||||
}
|
||||
state
|
||||
.config_bundles
|
||||
.insert(bundle.metadata.id.clone(), bundle);
|
||||
@@ -526,6 +539,7 @@ impl Runtime {
|
||||
message: "worker creation requires an execution backend".to_string(),
|
||||
}
|
||||
})?;
|
||||
let config_bundle = state.resolve_config_bundle_ref(request.config_bundle.as_ref())?;
|
||||
|
||||
let worker_id = WorkerId::generated(state.next_worker_sequence);
|
||||
state.next_worker_sequence += 1;
|
||||
@@ -551,7 +565,7 @@ impl Runtime {
|
||||
workspace_scope: scope.cloned(),
|
||||
context: self.execution_context(worker_ref.clone()),
|
||||
working_directory: None,
|
||||
config_bundle: None,
|
||||
config_bundle,
|
||||
};
|
||||
(backend, worker_ref, spawn_request)
|
||||
};
|
||||
@@ -868,7 +882,7 @@ impl Runtime {
|
||||
let (backend, request) = {
|
||||
let mut state = self.lock()?;
|
||||
state.ensure_running()?;
|
||||
let (worker_request, previous_working_directory, config_bundle, run_generation) = {
|
||||
let (worker_request, previous_working_directory, run_generation) = {
|
||||
let worker = state.worker(worker_ref)?;
|
||||
if worker.execution_handle.is_some() {
|
||||
return Ok(worker.detail());
|
||||
@@ -879,19 +893,14 @@ impl Runtime {
|
||||
worker_ref.worker_id
|
||||
)));
|
||||
}
|
||||
let config_bundle = worker
|
||||
.request
|
||||
.config_bundle
|
||||
.as_ref()
|
||||
.and_then(|bundle_ref| state.config_bundles.get(&bundle_ref.id))
|
||||
.cloned();
|
||||
(
|
||||
worker.request.clone(),
|
||||
worker.working_directory.clone(),
|
||||
config_bundle,
|
||||
worker.run_generation.saturating_add(1).max(1),
|
||||
)
|
||||
};
|
||||
let config_bundle =
|
||||
state.resolve_config_bundle_ref(worker_request.config_bundle.as_ref())?;
|
||||
let backend = state.execution_backend.clone().ok_or_else(|| {
|
||||
RuntimeError::WorkerExecutionUnavailable {
|
||||
worker_id: worker_ref.worker_id.clone(),
|
||||
@@ -1053,8 +1062,7 @@ impl Runtime {
|
||||
worker.status = worker_status_from_run_state(dispatch_result.run_state);
|
||||
let status = worker.status;
|
||||
#[cfg(feature = "ws-server")]
|
||||
{
|
||||
let payload = input_protocol_event(&input);
|
||||
if let Some(payload) = input_protocol_event(&input) {
|
||||
state.push_worker_observation_event(worker_ref.clone(), payload);
|
||||
}
|
||||
state.publish_worker_upsert(worker_ref.worker_id)?;
|
||||
@@ -1464,7 +1472,9 @@ impl Runtime {
|
||||
) -> Result<(), RuntimeError> {
|
||||
let mut state = self.lock()?;
|
||||
state.ensure_worker_ref(worker_ref)?;
|
||||
state.push_worker_observation_event(worker_ref.clone(), input_protocol_event(&input));
|
||||
if let Some(payload) = input_protocol_event(&input) {
|
||||
state.push_worker_observation_event(worker_ref.clone(), payload);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -1557,31 +1567,20 @@ impl Runtime {
|
||||
.collect::<Vec<_>>();
|
||||
let mut candidates = Vec::with_capacity(worker_ids.len());
|
||||
for worker_id in worker_ids {
|
||||
let (
|
||||
worker_ref,
|
||||
request,
|
||||
previous_working_directory,
|
||||
config_bundle,
|
||||
run_generation,
|
||||
) = {
|
||||
let (worker_ref, request, previous_working_directory, run_generation) = {
|
||||
let worker = state
|
||||
.workers
|
||||
.get(&worker_id)
|
||||
.expect("collected Worker exists");
|
||||
let config_bundle = worker
|
||||
.request
|
||||
.config_bundle
|
||||
.as_ref()
|
||||
.and_then(|bundle_ref| state.config_bundles.get(&bundle_ref.id))
|
||||
.cloned();
|
||||
(
|
||||
worker.worker_ref.clone(),
|
||||
worker.request.clone(),
|
||||
worker.working_directory.clone(),
|
||||
config_bundle,
|
||||
worker.run_generation.saturating_add(1).max(1),
|
||||
)
|
||||
};
|
||||
let config_bundle =
|
||||
state.resolve_config_bundle_ref(request.config_bundle.as_ref())?;
|
||||
state
|
||||
.workers
|
||||
.get_mut(&worker_id)
|
||||
@@ -2074,6 +2073,17 @@ impl RuntimeState {
|
||||
})
|
||||
}
|
||||
|
||||
fn resolve_config_bundle_ref(
|
||||
&self,
|
||||
reference: Option<&ConfigBundleRef>,
|
||||
) -> Result<Option<ConfigBundle>, RuntimeError> {
|
||||
let Some(reference) = reference else {
|
||||
return Ok(None);
|
||||
};
|
||||
self.check_config_bundle_ref(reference)?;
|
||||
Ok(self.config_bundles.get(&reference.id).cloned())
|
||||
}
|
||||
|
||||
fn validate_worker_config_boundary(
|
||||
&self,
|
||||
_request: &CreateWorkerRequest,
|
||||
@@ -2646,30 +2656,28 @@ fn validate_worker_input(input: &WorkerInput) -> Result<(), RuntimeError> {
|
||||
}
|
||||
|
||||
#[cfg(feature = "ws-server")]
|
||||
fn input_protocol_event(input: &WorkerInput) -> protocol::Event {
|
||||
fn input_protocol_event(input: &WorkerInput) -> Option<protocol::Event> {
|
||||
match input.kind {
|
||||
WorkerInputKind::User => protocol::Event::UserMessage {
|
||||
WorkerInputKind::User => Some(protocol::Event::UserMessage {
|
||||
segments: input.segments.clone().unwrap_or_else(|| {
|
||||
vec![protocol::Segment::Text {
|
||||
content: input.content.clone(),
|
||||
}]
|
||||
}),
|
||||
},
|
||||
WorkerInputKind::Notify => protocol::Event::SystemItem {
|
||||
item: serde_json::json!({
|
||||
"kind": "embedded_worker_notification",
|
||||
"content": input.content.clone(),
|
||||
}),
|
||||
},
|
||||
}),
|
||||
// The committed `SystemItem::Notification` is the sole agent-visible
|
||||
// and Console-visible authority for Notify. A synthetic observation
|
||||
// here would display the same notification twice.
|
||||
WorkerInputKind::Notify => None,
|
||||
WorkerInputKind::Compact
|
||||
| WorkerInputKind::ListRewindTargets
|
||||
| WorkerInputKind::RegisterPeer => protocol::Event::SystemItem {
|
||||
| WorkerInputKind::RegisterPeer => Some(protocol::Event::SystemItem {
|
||||
item: serde_json::json!({
|
||||
"kind": "embedded_worker_command_input",
|
||||
"command": input.kind,
|
||||
"content": input.content.clone(),
|
||||
}),
|
||||
},
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2832,6 +2840,7 @@ mod tests {
|
||||
name: "read".to_string(),
|
||||
reference: "capability:read".to_string(),
|
||||
}],
|
||||
prompt_catalog: None,
|
||||
profile_source_archive: None,
|
||||
profile_source_archive_handle: None,
|
||||
}
|
||||
@@ -2844,6 +2853,7 @@ mod tests {
|
||||
restore_result: Mutex<Option<WorkerExecutionSpawnResult>>,
|
||||
restore_count: Mutex<u64>,
|
||||
run_generations: Mutex<Vec<u64>>,
|
||||
config_bundles: Mutex<Vec<Option<ConfigBundle>>>,
|
||||
contexts: Mutex<BTreeMap<WorkerId, WorkerExecutionContext>>,
|
||||
dispatched_inputs: Mutex<Vec<WorkerInput>>,
|
||||
preserve_commit_ack_submission_id: AtomicBool,
|
||||
@@ -2891,6 +2901,10 @@ mod tests {
|
||||
.lock()
|
||||
.unwrap()
|
||||
.push(request.run_generation);
|
||||
self.config_bundles
|
||||
.lock()
|
||||
.unwrap()
|
||||
.push(request.config_bundle.clone());
|
||||
self.contexts
|
||||
.lock()
|
||||
.unwrap()
|
||||
@@ -2914,6 +2928,10 @@ mod tests {
|
||||
.lock()
|
||||
.unwrap()
|
||||
.push(request.run_generation);
|
||||
self.config_bundles
|
||||
.lock()
|
||||
.unwrap()
|
||||
.push(request.config_bundle.clone());
|
||||
if let Some(result) = self.restore_result.lock().unwrap().clone() {
|
||||
return result;
|
||||
}
|
||||
@@ -3410,11 +3428,30 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn synced_config_bundle_is_stored_checked_and_used_for_worker_creation() {
|
||||
let runtime = runtime_with_backend();
|
||||
let bundle = test_bundle();
|
||||
let backend = Arc::new(TestExecutionBackend::default());
|
||||
let runtime =
|
||||
Runtime::with_execution_backend(RuntimeOptions::default(), backend.clone()).unwrap();
|
||||
let mut bundle = test_bundle();
|
||||
bundle.prompt_catalog = Some(
|
||||
worker::EffectivePromptCatalog::new(
|
||||
BTreeMap::from([("default".to_string(), "workspace prompt".to_string())]),
|
||||
7,
|
||||
"schema",
|
||||
"toolchain",
|
||||
)
|
||||
.unwrap(),
|
||||
);
|
||||
bundle = bundle.with_computed_digest();
|
||||
let availability = runtime.store_config_bundle(bundle.clone()).unwrap();
|
||||
assert_eq!(availability.reference.id, "bundle-1");
|
||||
assert_eq!(availability.reference.digest, bundle.metadata.digest);
|
||||
let mut conflicting_bundle = bundle.clone();
|
||||
conflicting_bundle.profiles[0].label = Some("conflicting".to_string());
|
||||
conflicting_bundle = conflicting_bundle.with_computed_digest();
|
||||
assert!(matches!(
|
||||
runtime.store_config_bundle(conflicting_bundle),
|
||||
Err(RuntimeError::ConfigBundleDigestMismatch { .. })
|
||||
));
|
||||
|
||||
let listed = runtime.list_config_bundles().unwrap();
|
||||
assert_eq!(listed.len(), 1);
|
||||
@@ -3429,6 +3466,53 @@ mod tests {
|
||||
.create_worker(bundled_task_request("synced", &bundle))
|
||||
.unwrap();
|
||||
assert_eq!(detail.config_bundle, Some(availability.reference));
|
||||
assert_eq!(
|
||||
backend.config_bundles.lock().unwrap().as_slice(),
|
||||
&[Some(bundle.clone())]
|
||||
);
|
||||
|
||||
runtime.stop_worker(&detail.worker_ref, None).unwrap();
|
||||
runtime.restore_worker(&detail.worker_ref).unwrap();
|
||||
assert_eq!(
|
||||
backend.config_bundles.lock().unwrap().as_slice(),
|
||||
&[Some(bundle.clone()), Some(bundle)]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn restore_fails_closed_when_recorded_config_bundle_is_missing_or_mismatched() {
|
||||
let (runtime, backend) = runtime_and_backend();
|
||||
let bundle = test_bundle();
|
||||
let detail = runtime
|
||||
.create_worker(bundled_task_request("missing-on-restore", &bundle))
|
||||
.unwrap();
|
||||
runtime.stop_worker(&detail.worker_ref, None).unwrap();
|
||||
runtime.lock().unwrap().config_bundles.clear();
|
||||
assert!(matches!(
|
||||
runtime.restore_worker(&detail.worker_ref),
|
||||
Err(RuntimeError::ConfigBundleMissing { .. })
|
||||
));
|
||||
assert_eq!(backend.config_bundles.lock().unwrap().len(), 1);
|
||||
|
||||
let (runtime, backend) = runtime_and_backend();
|
||||
let bundle = test_bundle();
|
||||
let detail = runtime
|
||||
.create_worker(bundled_task_request("mismatch-on-restore", &bundle))
|
||||
.unwrap();
|
||||
runtime.stop_worker(&detail.worker_ref, None).unwrap();
|
||||
let mut replacement = bundle.clone();
|
||||
replacement.profiles[0].label = Some("replacement".to_string());
|
||||
replacement = replacement.with_computed_digest();
|
||||
runtime
|
||||
.lock()
|
||||
.unwrap()
|
||||
.config_bundles
|
||||
.insert(replacement.metadata.id.clone(), replacement);
|
||||
assert!(matches!(
|
||||
runtime.restore_worker(&detail.worker_ref),
|
||||
Err(RuntimeError::ConfigBundleDigestMismatch { .. })
|
||||
));
|
||||
assert_eq!(backend.config_bundles.lock().unwrap().len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -3859,7 +3943,7 @@ mod tests {
|
||||
|
||||
#[cfg(feature = "ws-server")]
|
||||
#[test]
|
||||
fn send_input_records_protocol_observations() {
|
||||
fn notify_input_does_not_duplicate_committed_notification_observation() {
|
||||
let runtime = Runtime::with_execution_backend(
|
||||
RuntimeOptions {
|
||||
..RuntimeOptions::default()
|
||||
@@ -3880,15 +3964,40 @@ mod tests {
|
||||
let observations = runtime
|
||||
.read_worker_observation_events(&detail.worker_ref, WorkerObservationCursor::zero())
|
||||
.unwrap();
|
||||
assert_eq!(observations.len(), 2);
|
||||
assert_eq!(observations.len(), 1);
|
||||
assert!(matches!(
|
||||
observations[0].payload,
|
||||
protocol::Event::UserMessage { .. }
|
||||
));
|
||||
assert!(matches!(
|
||||
observations[1].payload,
|
||||
protocol::Event::SystemItem { .. }
|
||||
));
|
||||
|
||||
runtime
|
||||
.observe_worker_event(
|
||||
&detail.worker_ref,
|
||||
protocol::Event::SystemItem {
|
||||
item: serde_json::json!({
|
||||
"kind": "notification",
|
||||
"message": "note",
|
||||
"body": "[Notification] note",
|
||||
}),
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let observations = runtime
|
||||
.read_worker_observation_events(&detail.worker_ref, WorkerObservationCursor::zero())
|
||||
.unwrap();
|
||||
assert_eq!(observations.len(), 2);
|
||||
let protocol::Event::SystemItem { item } = &observations[1].payload else {
|
||||
panic!("committed notification observation must be a system item");
|
||||
};
|
||||
assert_eq!(item["kind"], "notification");
|
||||
assert!(observations.iter().all(|observation| {
|
||||
!matches!(
|
||||
&observation.payload,
|
||||
protocol::Event::SystemItem { item }
|
||||
if item["kind"] == "embedded_worker_notification"
|
||||
)
|
||||
}));
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -53,7 +53,7 @@ use worker::feature::builtin::{
|
||||
#[cfg(feature = "ws-server")]
|
||||
use worker::ipc::protocol_session::{live_log_entry_event, subscribe_worker_protocol_session};
|
||||
use worker::{
|
||||
PromptLoader, SegmentLogSink, WORKER_INPUT_SUBMISSION_EXTENSION_DOMAIN, Worker,
|
||||
PromptCatalogSource, SegmentLogSink, WORKER_INPUT_SUBMISSION_EXTENSION_DOMAIN, Worker,
|
||||
WorkerController, WorkerControllerTransport, WorkerError, WorkerFilesystemAuthority,
|
||||
WorkerHandle, WorkerSharedState, WorkerWorkspaceContext, WorkspaceClient, WorkspaceId,
|
||||
};
|
||||
@@ -329,12 +329,12 @@ impl ProfileRuntimeWorkerFactory {
|
||||
|
||||
fn restore_fallback_manifest(
|
||||
worker_name: &str,
|
||||
) -> Result<(manifest::WorkerManifest, PromptLoader), String> {
|
||||
) -> Result<(manifest::WorkerManifest, PromptCatalogSource), String> {
|
||||
let mut config = manifest::WorkerManifestConfig::builtin_defaults();
|
||||
config.worker.name = Some(worker_name.to_string());
|
||||
let manifest = manifest::WorkerManifest::try_from(config)
|
||||
.map_err(|err| format!("failed to build restore fallback manifest: {err}"))?;
|
||||
Ok((manifest, PromptLoader::builtins_only()))
|
||||
Ok((manifest, PromptCatalogSource::builtins_only()))
|
||||
}
|
||||
async fn resolve_profile_source_archive(
|
||||
&self,
|
||||
@@ -566,7 +566,7 @@ impl RuntimeWorkerFactory for ProfileRuntimeWorkerFactory {
|
||||
let archive = self
|
||||
.resolve_profile_source_archive(&request.request.profile_source)
|
||||
.await?;
|
||||
let (manifest, loader) = {
|
||||
let (manifest, mut loader) = {
|
||||
let manifest = archive
|
||||
.resolve_profile(selector, &worker_root, &worker_name)
|
||||
.map_err(|err| format!("failed to resolve profile source archive: {err}"))?;
|
||||
@@ -584,6 +584,13 @@ impl RuntimeWorkerFactory for ProfileRuntimeWorkerFactory {
|
||||
)?
|
||||
}
|
||||
};
|
||||
if let Some(prompt_catalog) = request
|
||||
.config_bundle
|
||||
.as_ref()
|
||||
.and_then(|bundle| bundle.prompt_catalog.clone())
|
||||
{
|
||||
loader = loader.with_effective_catalog(prompt_catalog);
|
||||
}
|
||||
let flow_transition_enabled = manifest.feature.flow.enabled;
|
||||
|
||||
let worker_aggregate_dir = self.worker_aggregate_dir(&request.worker_ref)?;
|
||||
@@ -719,7 +726,14 @@ impl RuntimeWorkerFactory for ProfileRuntimeWorkerFactory {
|
||||
self.worker_mutation_identity.as_ref(),
|
||||
self.embedded_worker_mutation_dispatcher.as_ref(),
|
||||
);
|
||||
let (manifest, loader) = Self::restore_fallback_manifest(&worker_name)?;
|
||||
let (manifest, mut loader) = Self::restore_fallback_manifest(&worker_name)?;
|
||||
if let Some(prompt_catalog) = request
|
||||
.config_bundle
|
||||
.as_ref()
|
||||
.and_then(|bundle| bundle.prompt_catalog.clone())
|
||||
{
|
||||
loader = loader.with_effective_catalog(prompt_catalog);
|
||||
}
|
||||
|
||||
let worker_aggregate_dir = self.worker_aggregate_dir(&request.worker_ref)?;
|
||||
let session_dir = worker_aggregate_dir.join("session");
|
||||
@@ -2090,6 +2104,7 @@ mod tests {
|
||||
label: Some("adapter-test".to_string()),
|
||||
}],
|
||||
declarations: Vec::new(),
|
||||
prompt_catalog: None,
|
||||
profile_source_archive: Some(sample_profile_archive()),
|
||||
profile_source_archive_handle: None,
|
||||
}
|
||||
|
||||
@@ -29,6 +29,7 @@ tools = { workspace = true }
|
||||
workdir = { workspace = true }
|
||||
minijinja = "2.19.0"
|
||||
chrono = "0.4"
|
||||
config-source = { path = "../config-source" }
|
||||
include_dir = "0.7.4"
|
||||
fs4 = { workspace = true, features = ["sync"] }
|
||||
flow = { path = "../flow" }
|
||||
@@ -51,6 +52,3 @@ serial_test = "3.4.0"
|
||||
tempfile = { workspace = true }
|
||||
wat = "1.241.2"
|
||||
yoi-plugin-pdk = { workspace = true }
|
||||
|
||||
[build-dependencies]
|
||||
toml = { workspace = true }
|
||||
|
||||
+1
-47
@@ -1,49 +1,3 @@
|
||||
//! Emits `$OUT_DIR/internal_keys.rs` containing the sorted list of keys
|
||||
//! present in `resources/prompts/internal.toml`. The generated slice is
|
||||
//! included into `src/prompts.rs` where a `const _` assertion compares
|
||||
//! it bidirectionally against the `WorkerPrompt` enum's own key list, so
|
||||
//! that a mismatch fails the build (see ticket: worker-prompt-catalog).
|
||||
use std::env;
|
||||
use std::fs;
|
||||
use std::path::PathBuf;
|
||||
|
||||
fn main() {
|
||||
let manifest_dir = env::var("CARGO_MANIFEST_DIR").expect("CARGO_MANIFEST_DIR");
|
||||
let toml_path = PathBuf::from(&manifest_dir)
|
||||
.join("..")
|
||||
.join("..")
|
||||
.join("resources")
|
||||
.join("prompts")
|
||||
.join("internal.toml");
|
||||
|
||||
println!("cargo:rerun-if-changed={}", toml_path.display());
|
||||
println!("cargo:rerun-if-changed=build.rs");
|
||||
|
||||
let toml_str = fs::read_to_string(&toml_path)
|
||||
.unwrap_or_else(|e| panic!("failed to read {}: {e}", toml_path.display()));
|
||||
|
||||
let parsed: toml::Value = toml::from_str(&toml_str)
|
||||
.unwrap_or_else(|e| panic!("failed to parse {}: {e}", toml_path.display()));
|
||||
|
||||
let prompt_section = parsed
|
||||
.get("prompt")
|
||||
.and_then(|v| v.as_table())
|
||||
.unwrap_or_else(|| panic!("{} must contain a `[prompt]` table", toml_path.display()));
|
||||
|
||||
let mut keys: Vec<String> = prompt_section.keys().cloned().collect();
|
||||
keys.sort();
|
||||
|
||||
let out_dir = env::var("OUT_DIR").expect("OUT_DIR");
|
||||
let out_path = PathBuf::from(out_dir).join("internal_keys.rs");
|
||||
|
||||
let mut code = String::from("pub(crate) const INTERNAL_KEYS: &[&str] = &[\n");
|
||||
for k in &keys {
|
||||
code.push_str(" ");
|
||||
code.push_str(&format!("{k:?}"));
|
||||
code.push_str(",\n");
|
||||
}
|
||||
code.push_str("];\n");
|
||||
|
||||
fs::write(&out_path, code)
|
||||
.unwrap_or_else(|e| panic!("failed to write {}: {e}", out_path.display()));
|
||||
println!("cargo:rerun-if-changed=../../resources/prompts");
|
||||
}
|
||||
|
||||
@@ -3,7 +3,8 @@ use std::path::{Path, PathBuf};
|
||||
use std::process::ExitCode;
|
||||
|
||||
use crate::{
|
||||
PromptLoader, Worker, WorkerController, WorkerFilesystemAuthority, WorkerWorkspaceContext,
|
||||
PromptCatalogSource, Worker, WorkerController, WorkerFilesystemAuthority,
|
||||
WorkerWorkspaceContext,
|
||||
};
|
||||
use clap::{CommandFactory, FromArgMatches, Parser};
|
||||
use manifest::{Permission, ScopeConfig, ScopeRule, WorkerManifest, WorkerManifestConfig, paths};
|
||||
@@ -137,7 +138,7 @@ fn sanitise_worker_name(raw: &str) -> String {
|
||||
}
|
||||
}
|
||||
|
||||
fn resolve_manifest(cli: &Cli) -> Result<(WorkerManifest, PromptLoader), String> {
|
||||
fn resolve_manifest(cli: &Cli) -> Result<(WorkerManifest, PromptCatalogSource), String> {
|
||||
let process_root = runtime_workspace_root(cli)?;
|
||||
let runtime_worker_name = runtime_worker_name(cli, &process_root);
|
||||
let ((mut manifest, loader), apply_direct_launch_policy) = if let Some(config_json) =
|
||||
@@ -178,29 +179,31 @@ fn apply_session_restore_overrides(manifest: &mut WorkerManifest, cli: &Cli) ->
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn load_spawn_config_json(config_json: &str) -> Result<(WorkerManifest, PromptLoader), String> {
|
||||
fn load_spawn_config_json(
|
||||
config_json: &str,
|
||||
) -> Result<(WorkerManifest, PromptCatalogSource), String> {
|
||||
let config = serde_json::from_str::<WorkerManifestConfig>(config_json)
|
||||
.map_err(|e| format!("failed to parse --spawn-config-json: {e}"))?;
|
||||
let manifest = WorkerManifest::try_from(WorkerManifestConfig::builtin_defaults().merge(config))
|
||||
.map_err(|e| format!("failed to resolve --spawn-config-json: {e}"))?;
|
||||
Ok((manifest, PromptLoader::builtins_only()))
|
||||
Ok((manifest, PromptCatalogSource::builtins_only()))
|
||||
}
|
||||
|
||||
fn load_builtin_default_manifest(
|
||||
worker_name: &str,
|
||||
) -> Result<(WorkerManifest, PromptLoader), String> {
|
||||
) -> Result<(WorkerManifest, PromptCatalogSource), String> {
|
||||
let mut config = WorkerManifestConfig::builtin_defaults();
|
||||
config.worker.name = Some(worker_name.to_string());
|
||||
let manifest = WorkerManifest::try_from(config)
|
||||
.map_err(|e| format!("failed to resolve builtin worker defaults: {e}"))?;
|
||||
Ok((manifest, PromptLoader::builtins_only()))
|
||||
Ok((manifest, PromptCatalogSource::builtins_only()))
|
||||
}
|
||||
|
||||
pub fn resolve_runtime_profile_manifest(
|
||||
_profile: Option<&str>,
|
||||
_workspace_root: &Path,
|
||||
_worker_name: &str,
|
||||
) -> Result<(WorkerManifest, PromptLoader), String> {
|
||||
) -> Result<(WorkerManifest, PromptCatalogSource), String> {
|
||||
Err(
|
||||
"runtime profile resolution requires a pre-resolved manifest/profile archive from Backend authority"
|
||||
.to_string(),
|
||||
@@ -211,7 +214,7 @@ pub fn resolve_runtime_profile_manifest_from_manifest(
|
||||
mut manifest: WorkerManifest,
|
||||
workspace_root: &Path,
|
||||
worker_name: &str,
|
||||
) -> Result<(WorkerManifest, PromptLoader), String> {
|
||||
) -> Result<(WorkerManifest, PromptCatalogSource), String> {
|
||||
if manifest.worker.name.is_empty() {
|
||||
manifest.worker.name = worker_name.to_string();
|
||||
}
|
||||
@@ -219,28 +222,28 @@ pub fn resolve_runtime_profile_manifest_from_manifest(
|
||||
// Do not run plugin discovery here: runtime-created Workers receive their
|
||||
// resolved manifest/profile archive from Backend authority, not by scanning
|
||||
// materialized workdir-local plugin stores.
|
||||
Ok((manifest, PromptLoader::builtins_only()))
|
||||
Ok((manifest, PromptCatalogSource::builtins_only()))
|
||||
}
|
||||
|
||||
pub fn resolve_runtime_profile_manifest_from_manifest_without_filesystem(
|
||||
mut manifest: WorkerManifest,
|
||||
_workspace_root: &Path,
|
||||
worker_name: &str,
|
||||
) -> Result<(WorkerManifest, PromptLoader), String> {
|
||||
) -> Result<(WorkerManifest, PromptCatalogSource), String> {
|
||||
if manifest.worker.name.is_empty() {
|
||||
manifest.worker.name = worker_name.to_string();
|
||||
}
|
||||
manifest.scope = ScopeConfig::default();
|
||||
manifest.delegation_scope = ScopeConfig::default();
|
||||
// Same as the filesystem-capable runtime path: no local discovery.
|
||||
Ok((manifest, PromptLoader::builtins_only()))
|
||||
Ok((manifest, PromptCatalogSource::builtins_only()))
|
||||
}
|
||||
|
||||
fn load_single_manifest(
|
||||
path: &Path,
|
||||
explicit_worker_name: Option<&str>,
|
||||
default_worker_name: &str,
|
||||
) -> Result<(WorkerManifest, PromptLoader), String> {
|
||||
) -> Result<(WorkerManifest, PromptCatalogSource), String> {
|
||||
let toml = std::fs::read_to_string(path)
|
||||
.map_err(|e| format!("failed to read manifest {}: {e}", path.display()))?;
|
||||
let absolute_path = if path.is_absolute() {
|
||||
@@ -274,7 +277,7 @@ fn load_single_manifest(
|
||||
path.display()
|
||||
));
|
||||
}
|
||||
Ok((manifest, PromptLoader::builtins_only()))
|
||||
Ok((manifest, PromptCatalogSource::builtins_only()))
|
||||
}
|
||||
|
||||
fn read_rule(target: PathBuf) -> ScopeRule {
|
||||
@@ -751,11 +754,9 @@ permission = "write"
|
||||
let cli =
|
||||
Cli::try_parse_from(["yoi worker", "--manifest", manifest.to_str().unwrap()]).unwrap();
|
||||
|
||||
let (manifest, loader) = resolve_manifest(&cli).unwrap();
|
||||
let (manifest, _loader) = resolve_manifest(&cli).unwrap();
|
||||
|
||||
assert_eq!(manifest.worker.name, "single");
|
||||
assert!(loader.user_dir().is_none());
|
||||
assert!(loader.workspace_dir().is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -764,7 +765,7 @@ permission = "write"
|
||||
let yoi_dir = tmp.path().join(".yoi");
|
||||
std::fs::create_dir_all(&yoi_dir).unwrap();
|
||||
write(
|
||||
&yoi_dir.join("override.local.toml"),
|
||||
&yoi_dir.join("ignored-local-file.toml"),
|
||||
r#"
|
||||
[worker]
|
||||
name = "from-local-override"
|
||||
@@ -822,7 +823,7 @@ permission = "write"
|
||||
let yoi_dir = workspace.join(".yoi");
|
||||
std::fs::create_dir_all(&yoi_dir).unwrap();
|
||||
write(
|
||||
&yoi_dir.join("override.local.toml"),
|
||||
&yoi_dir.join("ignored-local-file.toml"),
|
||||
r#"
|
||||
[worker]
|
||||
name = "from-local-override"
|
||||
@@ -834,12 +835,10 @@ language = "override"
|
||||
let cli = Cli::try_parse_from(["yoi worker", "--workspace", workspace.to_str().unwrap()])
|
||||
.unwrap();
|
||||
|
||||
let (manifest, loader) = resolve_manifest(&cli).unwrap();
|
||||
let (manifest, _loader) = resolve_manifest(&cli).unwrap();
|
||||
|
||||
assert_eq!(manifest.worker.name, "runtime-workspace");
|
||||
assert_ne!(manifest.engine.language, "override");
|
||||
assert!(loader.user_dir().is_none());
|
||||
assert!(loader.workspace_dir().is_none());
|
||||
assert_scope_contains(&manifest.scope.allow, &workspace, Permission::Write);
|
||||
}
|
||||
|
||||
@@ -1031,12 +1030,8 @@ permission = "write"
|
||||
])
|
||||
.unwrap();
|
||||
|
||||
let (manifest, loader) = resolve_manifest(&cli).unwrap();
|
||||
let (manifest, _loader) = resolve_manifest(&cli).unwrap();
|
||||
|
||||
assert_eq!(manifest.worker.name, "single-file");
|
||||
assert!(loader.user_dir().is_none());
|
||||
assert!(loader.workspace_dir().is_none());
|
||||
assert!(loader.user_pack_file().is_none());
|
||||
assert!(loader.workspace_pack_file().is_none());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1858,8 +1858,8 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn instruction_contributions_are_deduped_in_registration_order() {
|
||||
let workflow = instruction("workflow", "$yoi/common/tickets");
|
||||
let orchestration = instruction("orchestration", "$yoi/common/worker-orchestration");
|
||||
let workflow = instruction("workflow", "common.tickets");
|
||||
let orchestration = instruction("orchestration", "common.worker_orchestration");
|
||||
let contributions = dedupe_instruction_contributions([
|
||||
workflow.clone(),
|
||||
orchestration.clone(),
|
||||
@@ -1871,8 +1871,8 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn undeclared_instruction_contribution_is_rejected() {
|
||||
let declared = instruction("declared", "$yoi/common/tickets");
|
||||
let undeclared = instruction("undeclared", "$yoi/common/tickets");
|
||||
let declared = instruction("declared", "common.tickets");
|
||||
let undeclared = instruction("undeclared", "common.tickets");
|
||||
let descriptor =
|
||||
FeatureDescriptor::builtin("instruction", "Instruction").with_instruction(declared);
|
||||
let mut hook_builder = HookRegistryBuilder::default();
|
||||
|
||||
@@ -15,8 +15,10 @@ mod store;
|
||||
mod tool_impl;
|
||||
|
||||
pub(crate) use self::tool_impl::task_tools;
|
||||
#[cfg(test)]
|
||||
pub(crate) use store::TaskStatus;
|
||||
use store::snapshot_overview;
|
||||
pub(crate) use store::{TaskEntry, TaskStatus, TaskStore};
|
||||
pub(crate) use store::{TaskEntry, TaskStore};
|
||||
|
||||
use crate::feature::{
|
||||
FeatureDescriptor, FeatureHookPoint, FeatureInstallContext, FeatureInstallError, FeatureModule,
|
||||
@@ -76,7 +78,7 @@ impl TaskFeature {
|
||||
/// pointing at the same feature-owned store after rewind.
|
||||
pub fn restore_from_history(&self, history: &[Item]) {
|
||||
let restored = TaskStore::from_history(history);
|
||||
self.state.task_store.replace_with(restored.list_active());
|
||||
self.state.task_store.replace_with(restored.list());
|
||||
}
|
||||
|
||||
/// Feature-owned snapshot text used by compaction to preserve Task state.
|
||||
@@ -86,7 +88,7 @@ impl TaskFeature {
|
||||
|
||||
/// Feature-owned compact summary used for the synthetic TaskList result.
|
||||
pub fn snapshot_overview(&self) -> String {
|
||||
snapshot_overview(&self.state.task_store.list_active())
|
||||
snapshot_overview(&self.state.task_store.list())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
@@ -208,14 +210,8 @@ struct TaskReminderPreRequestHook {
|
||||
#[async_trait]
|
||||
impl Hook<PreLlmRequest> for TaskReminderPreRequestHook {
|
||||
async fn call(&self, input: &PreRequestContext) -> HookPreRequestAction {
|
||||
let active_tasks: Vec<TaskEntry> = self
|
||||
.state
|
||||
.task_store
|
||||
.list()
|
||||
.into_iter()
|
||||
.filter(|task| matches!(task.status, TaskStatus::Pending | TaskStatus::Inprogress))
|
||||
.collect();
|
||||
if active_tasks.is_empty() {
|
||||
let tasks = self.state.task_store.list();
|
||||
if tasks.is_empty() {
|
||||
return HookPreRequestAction::Continue;
|
||||
}
|
||||
|
||||
@@ -228,7 +224,7 @@ impl Hook<PreLlmRequest> for TaskReminderPreRequestHook {
|
||||
|
||||
if let Some(system_items) = input.system_items() {
|
||||
self.state.reminder_state.note_reminder();
|
||||
system_items.append_task_reminder(render_task_reminder_body(&active_tasks));
|
||||
system_items.append_task_reminder(render_task_reminder_body(&tasks));
|
||||
}
|
||||
HookPreRequestAction::Continue
|
||||
}
|
||||
@@ -252,11 +248,11 @@ fn is_task_management_tool(name: &str) -> bool {
|
||||
TASK_MANAGEMENT_TOOL_NAMES.contains(&name)
|
||||
}
|
||||
|
||||
fn render_task_reminder_body(active_tasks: &[TaskEntry]) -> String {
|
||||
fn render_task_reminder_body(tasks: &[TaskEntry]) -> String {
|
||||
let mut body = String::from(
|
||||
"Active session tasks are still open. If progress changed, call TaskUpdate.\n",
|
||||
"Current session steps are listed below. Call TaskUpdate at each natural boundary: keep the current step inprogress, mark it completed when finished, and delete older completed steps once their result is no longer useful context.\n",
|
||||
);
|
||||
for task in active_tasks {
|
||||
for task in tasks {
|
||||
body.push_str(&format!(
|
||||
"- taskid {} ({}) {}\n",
|
||||
task.taskid, task.status, task.subject
|
||||
@@ -433,7 +429,7 @@ mod tests {
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn task_reminder_is_silent_when_no_active_tasks_exist() {
|
||||
async fn task_reminder_includes_completed_tasks_until_deleted() {
|
||||
let feature = TaskFeature::new();
|
||||
let done = feature
|
||||
.task_store()
|
||||
@@ -448,10 +444,16 @@ mod tests {
|
||||
};
|
||||
let pending = Arc::new(Mutex::new(Vec::new()));
|
||||
|
||||
for _ in 0..TASK_REMINDER_REQUEST_THRESHOLD * 2 {
|
||||
for _ in 0..TASK_REMINDER_REQUEST_THRESHOLD {
|
||||
let _ = hook.call(&pre_request_context(Arc::clone(&pending))).await;
|
||||
assert!(pending.lock().expect("pending queue poisoned").is_empty());
|
||||
}
|
||||
let queued = pending.lock().expect("pending queue poisoned");
|
||||
assert_eq!(queued.len(), 1);
|
||||
let SystemItem::TaskReminder { body, .. } = &queued[0] else {
|
||||
panic!("unexpected system item: {:?}", queued[0]);
|
||||
};
|
||||
assert!(body.contains("taskid 1 (completed) done"));
|
||||
assert!(body.contains("delete older completed steps"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
|
||||
@@ -19,8 +19,8 @@ pub enum TaskStatus {
|
||||
}
|
||||
|
||||
impl TaskStatus {
|
||||
pub fn is_active(self) -> bool {
|
||||
matches!(self, Self::Pending | Self::Inprogress)
|
||||
pub fn is_retained(self) -> bool {
|
||||
!matches!(self, Self::Deleted)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -93,13 +93,6 @@ impl TaskStore {
|
||||
.clone()
|
||||
}
|
||||
|
||||
pub fn list_active(&self) -> Vec<TaskEntry> {
|
||||
self.list()
|
||||
.into_iter()
|
||||
.filter(|task| task.status.is_active())
|
||||
.collect()
|
||||
}
|
||||
|
||||
pub fn get(&self, taskid: u64) -> Option<TaskEntry> {
|
||||
self.inner
|
||||
.lock()
|
||||
@@ -137,7 +130,7 @@ impl TaskStore {
|
||||
task.description = description;
|
||||
}
|
||||
let updated = task.clone();
|
||||
if !updated.status.is_active() {
|
||||
if !updated.status.is_retained() {
|
||||
inner.tasks.remove(task_position);
|
||||
}
|
||||
Ok(updated)
|
||||
@@ -149,7 +142,7 @@ impl TaskStore {
|
||||
|
||||
pub fn snapshot_limited(&self, limit: usize) -> TaskSnapshot {
|
||||
TaskSnapshot {
|
||||
tasks: self.list_active().into_iter().take(limit).collect(),
|
||||
tasks: self.list().into_iter().take(limit).collect(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -196,7 +189,7 @@ impl TaskStore {
|
||||
pub fn replace_with(&self, tasks: Vec<TaskEntry>) {
|
||||
let tasks: Vec<_> = tasks
|
||||
.into_iter()
|
||||
.filter(|task| task.status.is_active())
|
||||
.filter(|task| task.status.is_retained())
|
||||
.collect();
|
||||
let next_taskid = tasks
|
||||
.iter()
|
||||
@@ -267,24 +260,30 @@ pub fn snapshot_overview(tasks: &[TaskEntry]) -> String {
|
||||
.iter()
|
||||
.filter(|t| t.status == TaskStatus::Inprogress)
|
||||
.count();
|
||||
let active = pending + inprogress;
|
||||
format!("TaskStore: {active} active task(s) (pending: {pending}, inprogress: {inprogress})")
|
||||
let completed = tasks
|
||||
.iter()
|
||||
.filter(|t| t.status == TaskStatus::Completed)
|
||||
.count();
|
||||
format!(
|
||||
"TaskStore: {} task(s) (pending: {pending}, inprogress: {inprogress}, completed: {completed})",
|
||||
tasks.len()
|
||||
)
|
||||
}
|
||||
|
||||
pub fn render_snapshot(tasks: &[TaskEntry]) -> String {
|
||||
let active_tasks: Vec<_> = tasks
|
||||
let retained_tasks: Vec<_> = tasks
|
||||
.iter()
|
||||
.filter(|task| task.status.is_active())
|
||||
.filter(|task| task.status.is_retained())
|
||||
.cloned()
|
||||
.collect();
|
||||
let snapshot = TaskSnapshot {
|
||||
tasks: active_tasks.clone(),
|
||||
tasks: retained_tasks.clone(),
|
||||
};
|
||||
let json =
|
||||
serde_json::to_string_pretty(&snapshot).unwrap_or_else(|_| String::from("{\"tasks\":[]}"));
|
||||
format!(
|
||||
"{}\n\n```json\n{}\n```\n",
|
||||
snapshot_overview(&active_tasks),
|
||||
snapshot_overview(&retained_tasks),
|
||||
json
|
||||
)
|
||||
}
|
||||
@@ -303,7 +302,7 @@ pub(super) fn parse_compact_snapshot_text(text: &str) -> Option<Vec<TaskEntry>>
|
||||
snapshot
|
||||
.tasks
|
||||
.into_iter()
|
||||
.filter(|task| task.status.is_active())
|
||||
.filter(|task| task.status.is_retained())
|
||||
.collect(),
|
||||
)
|
||||
}
|
||||
@@ -312,6 +311,22 @@ pub(super) fn parse_compact_snapshot_text(text: &str) -> Option<Vec<TaskEntry>>
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn completed_task_is_retained_until_deleted() {
|
||||
let store = TaskStore::new();
|
||||
store.create("step".into(), "description".into());
|
||||
store
|
||||
.update(1, Some(TaskStatus::Completed), None, None)
|
||||
.unwrap();
|
||||
assert_eq!(store.list()[0].status, TaskStatus::Completed);
|
||||
|
||||
let deleted = store
|
||||
.update(1, Some(TaskStatus::Deleted), None, None)
|
||||
.unwrap();
|
||||
assert_eq!(deleted.status, TaskStatus::Deleted);
|
||||
assert!(store.list().is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn replay_history_reconstructs_store_and_ignores_malformed_calls() {
|
||||
let history = vec![
|
||||
@@ -323,9 +338,11 @@ mod tests {
|
||||
];
|
||||
let store = TaskStore::from_history(&history);
|
||||
let tasks = store.list();
|
||||
assert_eq!(tasks.len(), 1);
|
||||
assert_eq!(tasks.len(), 2);
|
||||
assert_eq!(tasks[0].taskid, 1);
|
||||
assert_eq!(tasks[0].status, TaskStatus::Pending);
|
||||
assert_eq!(tasks[1].taskid, 2);
|
||||
assert_eq!(tasks[1].status, TaskStatus::Completed);
|
||||
}
|
||||
|
||||
/// Wrap snapshot text the way `Worker::try_pre_run_compact` does, so tests
|
||||
@@ -333,7 +350,7 @@ mod tests {
|
||||
fn wrap_snapshot_system_message(snapshot: &str) -> String {
|
||||
format!(
|
||||
"[Session TaskStore snapshot]\n\n{snapshot}\n\n\
|
||||
This is the active session task list preserved across compaction. \
|
||||
This is the retained session task list preserved across compaction. \
|
||||
The following TaskList tool result presents the same state through the tool lane."
|
||||
)
|
||||
}
|
||||
@@ -355,9 +372,11 @@ mod tests {
|
||||
];
|
||||
let store = TaskStore::from_history(&history);
|
||||
let tasks = store.list();
|
||||
assert_eq!(tasks.len(), 1);
|
||||
assert_eq!(tasks[0].taskid, 2);
|
||||
assert_eq!(tasks[0].subject, "new");
|
||||
assert_eq!(tasks.len(), 2);
|
||||
assert_eq!(tasks[0].taskid, 1);
|
||||
assert_eq!(tasks[0].status, TaskStatus::Completed);
|
||||
assert_eq!(tasks[1].taskid, 2);
|
||||
assert_eq!(tasks[1].subject, "new");
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -396,12 +415,15 @@ mod tests {
|
||||
];
|
||||
let store = TaskStore::from_history(&history);
|
||||
let tasks = store.list();
|
||||
assert_eq!(tasks.len(), 2);
|
||||
assert_eq!(tasks[0].taskid, 2);
|
||||
assert_eq!(tasks[0].subject, "B");
|
||||
assert_eq!(tasks[0].status, TaskStatus::Inprogress);
|
||||
assert_eq!(tasks[1].taskid, 3);
|
||||
assert_eq!(tasks[1].subject, "C");
|
||||
assert_eq!(tasks.len(), 3);
|
||||
assert_eq!(tasks[0].taskid, 1);
|
||||
assert_eq!(tasks[0].subject, "A");
|
||||
assert_eq!(tasks[0].status, TaskStatus::Completed);
|
||||
assert_eq!(tasks[1].taskid, 2);
|
||||
assert_eq!(tasks[1].subject, "B");
|
||||
assert_eq!(tasks[1].status, TaskStatus::Inprogress);
|
||||
assert_eq!(tasks[2].taskid, 3);
|
||||
assert_eq!(tasks[2].subject, "C");
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -443,10 +465,10 @@ mod tests {
|
||||
let snapshot_text = pre.snapshot_text();
|
||||
let system = Item::system_message(wrap_snapshot_system_message(&snapshot_text));
|
||||
let call = Item::tool_call("compact-tasklist", "TaskList", "{}");
|
||||
let active_tasks = pre.list_active();
|
||||
let retained_tasks = pre.list();
|
||||
let result = Item::tool_result_with_content(
|
||||
"compact-tasklist",
|
||||
snapshot_overview(&active_tasks),
|
||||
snapshot_overview(&retained_tasks),
|
||||
snapshot_text.clone(),
|
||||
);
|
||||
|
||||
@@ -455,7 +477,7 @@ mod tests {
|
||||
.as_text()
|
||||
.and_then(parse_compact_snapshot_text)
|
||||
.expect("system message should parse as snapshot");
|
||||
assert_eq!(extracted, active_tasks);
|
||||
assert_eq!(extracted, retained_tasks);
|
||||
|
||||
// The synthetic call/result pair shares one call_id and carries the
|
||||
// expected tool name + detailed content.
|
||||
@@ -482,6 +504,6 @@ mod tests {
|
||||
|
||||
// Replaying the full triple reconstructs the same TaskStore.
|
||||
let store = TaskStore::from_history(&[system, call, result]);
|
||||
assert_eq!(store.list(), active_tasks);
|
||||
assert_eq!(store.list(), retained_tasks);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18,7 +18,7 @@ struct TaskCreateParams {
|
||||
|
||||
#[derive(Debug, Deserialize, schemars::JsonSchema)]
|
||||
struct TaskListParams {
|
||||
/// Maximum number of active tasks to return. Defaults to 20.
|
||||
/// Maximum number of retained tasks to return. Defaults to 20.
|
||||
#[serde(default)]
|
||||
limit: Option<usize>,
|
||||
}
|
||||
@@ -55,23 +55,28 @@ struct TaskUpdateTool {
|
||||
store: TaskStore,
|
||||
}
|
||||
|
||||
const CREATE_DESCRIPTION: &str = "Create a session-lifetime task only when user-visible \
|
||||
progress tracking is genuinely useful: multiple active tasks must be remembered, or the work \
|
||||
will involve long edits, long-running commands, extended investigation, or interruption-prone \
|
||||
coordination. Do not create a task just because a request has several steps, and do not create \
|
||||
one for short questions, quick checks, single reviews, or one-off commands. Prefer updating an \
|
||||
existing active task over creating a duplicate. Input only `subject` and `description`; `taskid` \
|
||||
is assigned automatically and initial `status` is `pending`.";
|
||||
const LIST_DESCRIPTION: &str = "List active session-lifetime tasks. Completed and deleted tasks are forgotten and omitted. Defaults to 20 tasks unless `limit` is provided.";
|
||||
const CREATE_DESCRIPTION: &str = "Before beginning nontrivial work, decompose the predictable \
|
||||
portion into a small ordered set of natural, user-visible steps and create one session-lifetime task \
|
||||
per step. A natural step has a distinct verifiable outcome, such as investigation, implementation, \
|
||||
or validation; do not create a task for every command or mechanical substep. Do not collapse several \
|
||||
predictable steps into one umbrella task. Keep future steps pending, mark the current step inprogress, \
|
||||
and add or revise tasks when new information changes the plan. Skip task tracking for short questions, \
|
||||
quick checks, and one-off commands. Input only `subject` and `description`; `taskid` is assigned \
|
||||
automatically and initial `status` is `pending`.";
|
||||
const LIST_DESCRIPTION: &str = "List retained session-lifetime tasks, including completed steps that \
|
||||
have not yet been deleted. Deleted tasks are forgotten and omitted. Defaults to 20 tasks unless \
|
||||
`limit` is provided.";
|
||||
const GET_DESCRIPTION: &str = "Get one session-lifetime task by `taskid`. Tasks are \
|
||||
user-visible real-time status for short-term current-work tracking. Returns an error if the task \
|
||||
does not exist.";
|
||||
const UPDATE_DESCRIPTION: &str = "Update an existing session-lifetime task when meaningful \
|
||||
progress changes between substantial steps. Tasks are user-visible real-time status, so avoid \
|
||||
churn for trivial substeps. Keep status current with `pending`, `inprogress`, `completed`, or \
|
||||
`deleted`. Provide `taskid` and at least one of `status`, `subject`, or `description`; deletion is \
|
||||
logical (`status = deleted`). If an unexpected problem blocks progress, do not force the next \
|
||||
step: leave the task as-is, summarize the problem to the user, and end the turn.";
|
||||
const UPDATE_DESCRIPTION: &str = "Update task status at each natural step boundary. Mark the \
|
||||
finished current step completed before advancing the next pending step to inprogress. Completed tasks \
|
||||
remain visible as recent workflow context; at a later natural boundary, delete older completed tasks \
|
||||
whose result is no longer useful. Do not jump directly from active to deleted merely to hide progress, \
|
||||
and avoid churn for mechanical substeps. Keep status current with `pending`, `inprogress`, `completed`, \
|
||||
or `deleted`. Provide `taskid` and at least one of `status`, `subject`, or `description`; deletion is \
|
||||
logical (`status = deleted`). If an unexpected problem blocks progress, do not force the next step: \
|
||||
leave the task as-is, summarize the problem to the user, and end the turn.";
|
||||
|
||||
#[async_trait]
|
||||
impl Tool for TaskCreateTool {
|
||||
@@ -83,7 +88,7 @@ impl Tool for TaskCreateTool {
|
||||
let params: TaskCreateParams = serde_json::from_str(input_json)
|
||||
.map_err(|e| ToolError::InvalidArgument(format!("invalid TaskCreate input: {e}")))?;
|
||||
let created = self.store.create(params.subject, params.description);
|
||||
let tasks = self.store.list_active();
|
||||
let tasks = self.store.list();
|
||||
Ok(task_output(
|
||||
format!(
|
||||
"Created task {} ({})\n{}",
|
||||
@@ -106,10 +111,10 @@ impl Tool for TaskListTool {
|
||||
let params: TaskListParams = serde_json::from_str(input_json)
|
||||
.map_err(|e| ToolError::InvalidArgument(format!("invalid TaskList input: {e}")))?;
|
||||
let limit = params.limit.unwrap_or(DEFAULT_TASK_LIST_LIMIT);
|
||||
let active_tasks = self.store.list_active();
|
||||
let tasks: Vec<_> = active_tasks.iter().take(limit).cloned().collect();
|
||||
let retained_tasks = self.store.list();
|
||||
let tasks: Vec<_> = retained_tasks.iter().take(limit).cloned().collect();
|
||||
Ok(ToolOutput {
|
||||
summary: list_overview(active_tasks.len(), tasks.len()),
|
||||
summary: list_overview(retained_tasks.len(), tasks.len()),
|
||||
content: Some(render_task_list(&tasks)),
|
||||
|
||||
attachments: Vec::new(),
|
||||
@@ -157,7 +162,7 @@ impl Tool for TaskUpdateTool {
|
||||
params.description,
|
||||
)
|
||||
.map_err(|e| ToolError::ExecutionFailed(e.to_string()))?;
|
||||
let tasks = self.store.list_active();
|
||||
let tasks = self.store.list();
|
||||
Ok(task_output(
|
||||
format!(
|
||||
"Updated task {} ({})\n{}",
|
||||
@@ -179,14 +184,14 @@ fn task_output(summary: String, task: &TaskEntry) -> ToolOutput {
|
||||
}
|
||||
}
|
||||
|
||||
fn list_overview(total_active: usize, returned: usize) -> String {
|
||||
if returned < total_active {
|
||||
fn list_overview(total: usize, returned: usize) -> String {
|
||||
if returned < total {
|
||||
format!(
|
||||
"TaskStore: {returned} active task(s) shown; {} omitted.",
|
||||
total_active - returned
|
||||
"TaskStore: {returned} task(s) shown; {} omitted.",
|
||||
total - returned
|
||||
)
|
||||
} else {
|
||||
format!("TaskStore: {returned} active task(s)")
|
||||
format!("TaskStore: {returned} task(s)")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -310,7 +315,7 @@ mod tests {
|
||||
assert!(out.content.unwrap().contains("implement tasks"));
|
||||
|
||||
let out = list.execute("{}", Default::default()).await.unwrap();
|
||||
assert!(out.summary.contains("1 active task(s)"));
|
||||
assert!(out.summary.contains("1 task(s)"));
|
||||
let content = out.content.unwrap();
|
||||
assert!(content.contains("\"taskid\": 1"));
|
||||
assert!(!content.contains("\"limit\""));
|
||||
@@ -318,7 +323,7 @@ mod tests {
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn task_list_omits_completed_deleted_and_defaults_to_twenty() {
|
||||
async fn task_list_retains_completed_omits_deleted_and_defaults_to_twenty() {
|
||||
let store = TaskStore::new();
|
||||
let create = tool(task_create_tool(store.clone()));
|
||||
let update = tool(task_update_tool(store.clone()));
|
||||
@@ -343,10 +348,7 @@ mod tests {
|
||||
.unwrap();
|
||||
|
||||
let out = list.execute("{}", Default::default()).await.unwrap();
|
||||
assert_eq!(
|
||||
out.summary,
|
||||
"TaskStore: 20 active task(s) shown; 3 omitted."
|
||||
);
|
||||
assert_eq!(out.summary, "TaskStore: 20 task(s) shown; 4 omitted.");
|
||||
let content = out.content.unwrap();
|
||||
let json: serde_json::Value = serde_json::from_str(&content).unwrap();
|
||||
let tasks = json.as_array().unwrap();
|
||||
@@ -355,7 +357,7 @@ mod tests {
|
||||
.iter()
|
||||
.map(|task| task["taskid"].as_u64().unwrap())
|
||||
.collect();
|
||||
assert!(!ids.contains(&1));
|
||||
assert!(ids.contains(&1));
|
||||
assert!(!ids.contains(&2));
|
||||
assert!(!content.contains("\"limit\""));
|
||||
assert!(!content.contains("\"total_active\""));
|
||||
@@ -365,10 +367,7 @@ mod tests {
|
||||
.execute(r#"{"limit":3}"#, Default::default())
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
out.summary,
|
||||
"TaskStore: 3 active task(s) shown; 20 omitted."
|
||||
);
|
||||
assert_eq!(out.summary, "TaskStore: 3 task(s) shown; 21 omitted.");
|
||||
let content = out.content.unwrap();
|
||||
let json: serde_json::Value = serde_json::from_str(&content).unwrap();
|
||||
assert_eq!(json.as_array().unwrap().len(), 3);
|
||||
@@ -412,6 +411,16 @@ mod tests {
|
||||
assert!(!content.contains("completed task"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn task_descriptions_require_natural_steps_and_completed_cleanup() {
|
||||
assert!(CREATE_DESCRIPTION.contains("Before beginning nontrivial work"));
|
||||
assert!(CREATE_DESCRIPTION.contains("one session-lifetime task per step"));
|
||||
assert!(CREATE_DESCRIPTION.contains("Do not collapse several predictable steps"));
|
||||
assert!(UPDATE_DESCRIPTION.contains("each natural step boundary"));
|
||||
assert!(UPDATE_DESCRIPTION.contains("remain visible"));
|
||||
assert!(UPDATE_DESCRIPTION.contains("delete older completed tasks"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn task_update_validates_existing_and_at_least_one_field() {
|
||||
let store = TaskStore::new();
|
||||
|
||||
@@ -33,7 +33,7 @@ const FEATURE_NAME: &str = "Ticket tools";
|
||||
const FEATURE_DESCRIPTION: &str = "Typed local Ticket work-item operations over a bounded backend root. \
|
||||
The tools operate through the ticket crate backend and do not grant generic filesystem write scope.";
|
||||
const TICKET_WORKFLOW_INSTRUCTION_ID: &str = "ticket.workflow";
|
||||
const TICKET_WORKFLOW_PROMPT_REF: &str = "$yoi/common/tickets";
|
||||
const TICKET_WORKFLOW_PROMPT_REF: &str = "common.tickets";
|
||||
pub const TICKET_SERVICE_ID: &str = "ticket.authority";
|
||||
const TICKET_SERVICE_VERSION: &str = "1";
|
||||
|
||||
|
||||
@@ -23,10 +23,7 @@ const DEFAULT_PAGE_LIMIT: usize = 20;
|
||||
const MAX_PAGE_LIMIT: usize = 100;
|
||||
const MAX_READ_BYTES: usize = 16 * 1024;
|
||||
const OBSERVATION_INSTRUCTION_ID: &str = "worker-observation.policy";
|
||||
const OBSERVATION_PROMPT_REF: &str = "$yoi/common/worker-observation";
|
||||
#[cfg(test)]
|
||||
const OBSERVATION_PROMPT_SOURCE: &str =
|
||||
include_str!("../../../../../resources/prompts/common/worker-observation.md");
|
||||
const OBSERVATION_PROMPT_REF: &str = "common.worker_observation";
|
||||
|
||||
fn observation_instruction() -> FeatureInstructionDeclaration {
|
||||
FeatureInstructionDeclaration::new(
|
||||
@@ -801,6 +798,8 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn prompt_source_names_the_worker_observation_contract() {
|
||||
let catalog = crate::PromptCatalog::builtins_only().unwrap();
|
||||
let source = &catalog.projection().templates["common.worker_observation"];
|
||||
for token in [
|
||||
"ListWorkerSessions",
|
||||
"ViewSessionOverview",
|
||||
@@ -808,7 +807,7 @@ mod tests {
|
||||
"ReadSessionEntry",
|
||||
"SessionEntryRef",
|
||||
] {
|
||||
assert!(OBSERVATION_PROMPT_SOURCE.contains(token), "missing {token}");
|
||||
assert!(source.contains(token), "missing {token}");
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -32,8 +32,10 @@ pub use manifest::{
|
||||
WorkerMetaConfig,
|
||||
};
|
||||
pub use model_client::{ProviderError, build_client};
|
||||
pub use prompt::catalog::{CatalogError, PromptCatalog, WorkerPrompt};
|
||||
pub use prompt::loader::PromptLoader;
|
||||
pub use prompt::catalog::{
|
||||
CatalogError, EffectivePromptCatalog, PromptCatalog, WorkerPrompt, prompt_schema_source,
|
||||
};
|
||||
pub use prompt::source::PromptCatalogSource;
|
||||
pub use prompt::system::{SystemPromptContext, SystemPromptError, SystemPromptTemplate};
|
||||
pub use protocol::{ErrorCode, Event, Method, TurnResult, WorkerStatus};
|
||||
pub use runtime::dir::RuntimeDir;
|
||||
|
||||
+447
-584
File diff suppressed because it is too large
Load Diff
@@ -1,425 +0,0 @@
|
||||
//! Prefix-addressed prompt asset loader used by [`crate::SystemPromptTemplate`].
|
||||
//!
|
||||
//! Three prefixes address three physical libraries:
|
||||
//!
|
||||
//! | prefix | location |
|
||||
//! |--------------|---------------------------------------------------------|
|
||||
//! | `$yoi` | builtin, baked into the binary via `include_dir!` |
|
||||
//! | `$user` | `<config_dir>/prompts/` (resolved by `manifest::paths`) |
|
||||
//! | `$workspace` | `<project>/.yoi/prompts/` |
|
||||
//!
|
||||
//! A reference is `$<prefix>/<path>` where `<path>` is a `/`-separated
|
||||
//! name without the `.md` extension (e.g. `$yoi/common/header`).
|
||||
//! Unqualified names (no `$prefix/` at the front) are resolved relative
|
||||
//! to an optional current reference — typically the file that issued
|
||||
//! the `{% include %}` — so a prompt library can be authored as a
|
||||
//! self-contained directory.
|
||||
//!
|
||||
//! Missing files produce a [`LoaderError::NotFound`]; there is no
|
||||
//! fallthrough between layers.
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use include_dir::{Dir, include_dir};
|
||||
use thiserror::Error;
|
||||
|
||||
static BUILTIN_PROMPTS: Dir<'static> = include_dir!("$CARGO_MANIFEST_DIR/../../resources/prompts");
|
||||
|
||||
const PREFIX_YOI: &str = "$yoi";
|
||||
const PREFIX_USER: &str = "$user";
|
||||
const PREFIX_WORKSPACE: &str = "$workspace";
|
||||
|
||||
/// Prefix-resolved reference to a prompt asset. Produced by
|
||||
/// [`PromptLoader::parse_ref`] from a user-supplied string such as
|
||||
/// `"$yoi/default"`.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct PromptRef {
|
||||
prefix: Prefix,
|
||||
/// Relative path under the prefix root, without the `.md` extension.
|
||||
/// `/`-separated, never empty, never starts with `/`.
|
||||
path: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
enum Prefix {
|
||||
Yoi,
|
||||
User,
|
||||
Workspace,
|
||||
}
|
||||
|
||||
impl Prefix {
|
||||
fn as_str(self) -> &'static str {
|
||||
match self {
|
||||
Self::Yoi => PREFIX_YOI,
|
||||
Self::User => PREFIX_USER,
|
||||
Self::Workspace => PREFIX_WORKSPACE,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl PromptRef {
|
||||
/// Produce a canonical `$prefix/path` string.
|
||||
pub fn to_qualified_string(&self) -> String {
|
||||
format!("{}/{}", self.prefix.as_str(), self.path)
|
||||
}
|
||||
|
||||
/// Directory portion (leading prefix segments minus the file name),
|
||||
/// joined with `/`. Returns an empty string when the ref points at
|
||||
/// a file directly under the prefix root.
|
||||
fn dir(&self) -> &str {
|
||||
match self.path.rsplit_once('/') {
|
||||
Some((dir, _)) => dir,
|
||||
None => "",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Errors produced when resolving a [`PromptRef`].
|
||||
#[derive(Debug, Error)]
|
||||
pub enum LoaderError {
|
||||
#[error("invalid prompt reference '{raw}': {reason}")]
|
||||
InvalidRef { raw: String, reason: String },
|
||||
#[error("unknown prompt prefix '{prefix}' in reference '{raw}'")]
|
||||
UnknownPrefix { raw: String, prefix: String },
|
||||
#[error(
|
||||
"unqualified prompt reference '{raw}' requires a current prefix \
|
||||
(include it from inside another template, or use an explicit \
|
||||
$prefix/path form)"
|
||||
)]
|
||||
UnqualifiedWithoutCurrent { raw: String },
|
||||
#[error("prompt prefix '{prefix}' is not configured for this loader")]
|
||||
PrefixNotConfigured { prefix: &'static str },
|
||||
#[error("prompt asset not found: '{}'", .reference.to_qualified_string())]
|
||||
NotFound { reference: PromptRef },
|
||||
#[error("failed to read prompt asset '{}': {source}", .reference.to_qualified_string())]
|
||||
Io {
|
||||
reference: PromptRef,
|
||||
#[source]
|
||||
source: std::io::Error,
|
||||
},
|
||||
}
|
||||
|
||||
/// Loader that resolves [`PromptRef`]s against the configured prompt
|
||||
/// libraries. Cheap to clone.
|
||||
///
|
||||
/// Also carries the auto-discovered `prompts.toml` pack file paths so
|
||||
/// [`crate::prompt::catalog::PromptCatalog`] can read the same user/workspace
|
||||
/// layers without a separate plumbing channel. These fields do not
|
||||
/// affect `$prefix` asset resolution — they are purely metadata
|
||||
/// consulted by the catalog loader.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct PromptLoader {
|
||||
user_dir: Option<PathBuf>,
|
||||
workspace_dir: Option<PathBuf>,
|
||||
user_pack_file: Option<PathBuf>,
|
||||
workspace_pack_file: Option<PathBuf>,
|
||||
}
|
||||
|
||||
impl PromptLoader {
|
||||
/// Loader with only the builtin `$yoi` library available.
|
||||
/// `$user` / `$workspace` references fail with
|
||||
/// [`LoaderError::PrefixNotConfigured`].
|
||||
pub fn builtins_only() -> Self {
|
||||
Self {
|
||||
user_dir: None,
|
||||
workspace_dir: None,
|
||||
user_pack_file: None,
|
||||
workspace_pack_file: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Loader with optional user and workspace prompt directories.
|
||||
pub fn new(user_dir: Option<PathBuf>, workspace_dir: Option<PathBuf>) -> Self {
|
||||
Self {
|
||||
user_dir,
|
||||
workspace_dir,
|
||||
user_pack_file: None,
|
||||
workspace_pack_file: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Override pack file paths supplied by the caller's profile/manifest
|
||||
/// resolution context.
|
||||
pub fn with_pack_files(
|
||||
mut self,
|
||||
user_pack_file: Option<PathBuf>,
|
||||
workspace_pack_file: Option<PathBuf>,
|
||||
) -> Self {
|
||||
self.user_pack_file = user_pack_file;
|
||||
self.workspace_pack_file = workspace_pack_file;
|
||||
self
|
||||
}
|
||||
|
||||
/// Root of the `$user` prompt library, if configured.
|
||||
pub fn user_dir(&self) -> Option<&Path> {
|
||||
self.user_dir.as_deref()
|
||||
}
|
||||
|
||||
/// Root of the `$workspace` prompt library, if configured.
|
||||
pub fn workspace_dir(&self) -> Option<&Path> {
|
||||
self.workspace_dir.as_deref()
|
||||
}
|
||||
|
||||
/// Auto-discovered path to the user-layer `prompts.toml` pack, if any.
|
||||
pub fn user_pack_file(&self) -> Option<&Path> {
|
||||
self.user_pack_file.as_deref()
|
||||
}
|
||||
|
||||
/// Auto-discovered path to the workspace-layer `prompts.toml` pack, if any.
|
||||
pub fn workspace_pack_file(&self) -> Option<&Path> {
|
||||
self.workspace_pack_file.as_deref()
|
||||
}
|
||||
|
||||
/// Parse a string reference into a [`PromptRef`]. Unqualified
|
||||
/// references (no leading `$prefix/`) are resolved against
|
||||
/// `current`: the prefix is inherited, and the path is joined to
|
||||
/// the current ref's directory.
|
||||
pub fn parse_ref(
|
||||
&self,
|
||||
raw: &str,
|
||||
current: Option<&PromptRef>,
|
||||
) -> Result<PromptRef, LoaderError> {
|
||||
let trimmed = raw.trim();
|
||||
if trimmed.is_empty() {
|
||||
return Err(LoaderError::InvalidRef {
|
||||
raw: raw.to_string(),
|
||||
reason: "reference must not be empty".into(),
|
||||
});
|
||||
}
|
||||
if let Some(prefix) = trimmed.strip_prefix('$') {
|
||||
let (prefix_name, rest) =
|
||||
prefix
|
||||
.split_once('/')
|
||||
.ok_or_else(|| LoaderError::InvalidRef {
|
||||
raw: raw.to_string(),
|
||||
reason: "prefix must be followed by '/'".into(),
|
||||
})?;
|
||||
let prefix = parse_prefix(raw, prefix_name)?;
|
||||
let path = normalize_path(raw, rest)?;
|
||||
Ok(PromptRef { prefix, path })
|
||||
} else {
|
||||
let Some(current) = current else {
|
||||
return Err(LoaderError::UnqualifiedWithoutCurrent {
|
||||
raw: raw.to_string(),
|
||||
});
|
||||
};
|
||||
let dir = current.dir();
|
||||
let joined = if dir.is_empty() {
|
||||
trimmed.to_string()
|
||||
} else {
|
||||
format!("{dir}/{trimmed}")
|
||||
};
|
||||
let path = normalize_path(raw, &joined)?;
|
||||
Ok(PromptRef {
|
||||
prefix: current.prefix,
|
||||
path,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// Resolve a [`PromptRef`] to its raw template source. Hard-errors
|
||||
/// when the prefix is not configured or the file does not exist.
|
||||
pub fn load(&self, reference: &PromptRef) -> Result<String, LoaderError> {
|
||||
match reference.prefix {
|
||||
Prefix::Yoi => load_from_include_dir(&BUILTIN_PROMPTS, reference),
|
||||
Prefix::User => match self.user_dir.as_deref() {
|
||||
Some(dir) => load_from_dir(dir, reference),
|
||||
None => Err(LoaderError::PrefixNotConfigured {
|
||||
prefix: PREFIX_USER,
|
||||
}),
|
||||
},
|
||||
Prefix::Workspace => match self.workspace_dir.as_deref() {
|
||||
Some(dir) => load_from_dir(dir, reference),
|
||||
None => Err(LoaderError::PrefixNotConfigured {
|
||||
prefix: PREFIX_WORKSPACE,
|
||||
}),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/// Parse `raw` against `current`, then load the resulting ref.
|
||||
/// Convenience wrapper for the minijinja loader hook.
|
||||
pub fn resolve(
|
||||
&self,
|
||||
raw: &str,
|
||||
current: Option<&PromptRef>,
|
||||
) -> Result<(PromptRef, String), LoaderError> {
|
||||
let reference = self.parse_ref(raw, current)?;
|
||||
let source = self.load(&reference)?;
|
||||
Ok((reference, source))
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_prefix(raw: &str, prefix_name: &str) -> Result<Prefix, LoaderError> {
|
||||
match prefix_name {
|
||||
"yoi" => Ok(Prefix::Yoi),
|
||||
"user" => Ok(Prefix::User),
|
||||
"workspace" => Ok(Prefix::Workspace),
|
||||
_ => Err(LoaderError::UnknownPrefix {
|
||||
raw: raw.to_string(),
|
||||
prefix: format!("${prefix_name}"),
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
fn normalize_path(raw: &str, rest: &str) -> Result<String, LoaderError> {
|
||||
let cleaned = rest.trim_matches('/').trim();
|
||||
if cleaned.is_empty() {
|
||||
return Err(LoaderError::InvalidRef {
|
||||
raw: raw.to_string(),
|
||||
reason: "path component must not be empty".into(),
|
||||
});
|
||||
}
|
||||
if cleaned.split('/').any(|seg| seg == "." || seg == "..") {
|
||||
return Err(LoaderError::InvalidRef {
|
||||
raw: raw.to_string(),
|
||||
reason: "path must not contain '.' or '..' segments".into(),
|
||||
});
|
||||
}
|
||||
Ok(cleaned.to_string())
|
||||
}
|
||||
|
||||
fn load_from_dir(dir: &Path, reference: &PromptRef) -> Result<String, LoaderError> {
|
||||
let path = dir.join(format!("{}.md", reference.path));
|
||||
match std::fs::read_to_string(&path) {
|
||||
Ok(s) => Ok(s),
|
||||
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Err(LoaderError::NotFound {
|
||||
reference: reference.clone(),
|
||||
}),
|
||||
Err(source) => Err(LoaderError::Io {
|
||||
reference: reference.clone(),
|
||||
source,
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
fn load_from_include_dir(dir: &Dir<'static>, reference: &PromptRef) -> Result<String, LoaderError> {
|
||||
let path = format!("{}.md", reference.path);
|
||||
dir.get_file(&path)
|
||||
.and_then(|f| f.contents_utf8())
|
||||
.map(|s| s.to_string())
|
||||
.ok_or_else(|| LoaderError::NotFound {
|
||||
reference: reference.clone(),
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use tempfile::TempDir;
|
||||
|
||||
#[test]
|
||||
fn builtin_default_resolves() {
|
||||
let loader = PromptLoader::builtins_only();
|
||||
let (r, source) = loader.resolve("$yoi/default", None).unwrap();
|
||||
assert_eq!(r.to_qualified_string(), "$yoi/default");
|
||||
assert!(!source.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn builtin_ticket_role_instructions_resolve() {
|
||||
let loader = PromptLoader::builtins_only();
|
||||
for role in ["intake", "orchestrator", "coder", "reviewer"] {
|
||||
let (reference, source) = loader.resolve(&format!("$yoi/role/{role}"), None).unwrap();
|
||||
assert_eq!(reference.to_qualified_string(), format!("$yoi/role/{role}"));
|
||||
assert!(source.contains("first committed user message"));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn builtin_subdirectory_lookup() {
|
||||
let loader = PromptLoader::builtins_only();
|
||||
let (_, source) = loader.resolve("$yoi/common/tool-usage", None).unwrap();
|
||||
assert!(source.contains("tool"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn user_prefix_resolves() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let user_dir = tmp.path().to_path_buf();
|
||||
std::fs::write(user_dir.join("my.md"), "user-body").unwrap();
|
||||
let loader = PromptLoader::new(Some(user_dir), None);
|
||||
let (_, source) = loader.resolve("$user/my", None).unwrap();
|
||||
assert_eq!(source, "user-body");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn workspace_prefix_resolves() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let ws_dir = tmp.path().to_path_buf();
|
||||
std::fs::write(ws_dir.join("custom.md"), "ws-body").unwrap();
|
||||
let loader = PromptLoader::new(None, Some(ws_dir));
|
||||
let (_, source) = loader.resolve("$workspace/custom", None).unwrap();
|
||||
assert_eq!(source, "ws-body");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn missing_file_is_hard_error() {
|
||||
let loader = PromptLoader::builtins_only();
|
||||
let err = loader.resolve("$yoi/definitely-missing", None).unwrap_err();
|
||||
assert!(matches!(err, LoaderError::NotFound { .. }));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn user_prefix_not_configured_errors() {
|
||||
let loader = PromptLoader::builtins_only();
|
||||
let err = loader.resolve("$user/my", None).unwrap_err();
|
||||
assert!(matches!(
|
||||
err,
|
||||
LoaderError::PrefixNotConfigured { prefix: "$user" }
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unknown_prefix_errors() {
|
||||
let loader = PromptLoader::builtins_only();
|
||||
let err = loader.resolve("$bogus/x", None).unwrap_err();
|
||||
assert!(matches!(err, LoaderError::UnknownPrefix { .. }));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unqualified_ref_without_current_errors() {
|
||||
let loader = PromptLoader::builtins_only();
|
||||
let err = loader.resolve("default", None).unwrap_err();
|
||||
assert!(matches!(err, LoaderError::UnqualifiedWithoutCurrent { .. }));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unqualified_ref_resolves_relative_to_current() {
|
||||
let loader = PromptLoader::builtins_only();
|
||||
let current = loader.parse_ref("$yoi/common/tool-usage", None).unwrap();
|
||||
// Sibling lookup under the same prefix and directory.
|
||||
let sibling = loader.parse_ref("workspace", Some(¤t)).unwrap();
|
||||
assert_eq!(sibling.to_qualified_string(), "$yoi/common/workspace");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unqualified_ref_from_root_file_has_empty_dir() {
|
||||
let loader = PromptLoader::builtins_only();
|
||||
let current = loader.parse_ref("$yoi/default", None).unwrap();
|
||||
let sibling = loader.parse_ref("other", Some(¤t)).unwrap();
|
||||
assert_eq!(sibling.to_qualified_string(), "$yoi/other");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn explicit_prefix_overrides_current() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let user_dir = tmp.path().to_path_buf();
|
||||
std::fs::write(user_dir.join("custom.md"), "user-body").unwrap();
|
||||
let loader = PromptLoader::new(Some(user_dir), None);
|
||||
|
||||
let current = loader.parse_ref("$yoi/default", None).unwrap();
|
||||
// Even with an $yoi-rooted current, an explicit $user
|
||||
// prefix must win.
|
||||
let (reference, source) = loader.resolve("$user/custom", Some(¤t)).unwrap();
|
||||
assert_eq!(reference.to_qualified_string(), "$user/custom");
|
||||
assert_eq!(source, "user-body");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn traversal_segments_rejected() {
|
||||
let loader = PromptLoader::builtins_only();
|
||||
let err = loader.resolve("$yoi/../etc/passwd", None).unwrap_err();
|
||||
assert!(matches!(err, LoaderError::InvalidRef { .. }));
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
pub(crate) mod agents_md;
|
||||
pub(crate) mod catalog;
|
||||
pub(crate) mod loader;
|
||||
pub(crate) mod source;
|
||||
pub(crate) mod system;
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
//! Immutable effective Prompt catalog carrier.
|
||||
//!
|
||||
//! Prompt sources are resolved and evaluated by Workspace config authority.
|
||||
//! This type carries only the already-materialized projection into Worker
|
||||
//! construction; it performs no filesystem, prefix, relative-path, user, or
|
||||
//! repository discovery.
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use super::catalog::EffectivePromptCatalog;
|
||||
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct PromptCatalogSource {
|
||||
effective_catalog: Option<Arc<EffectivePromptCatalog>>,
|
||||
}
|
||||
|
||||
impl PromptCatalogSource {
|
||||
pub fn builtins_only() -> Self {
|
||||
Self::default()
|
||||
}
|
||||
|
||||
pub fn with_effective_catalog(mut self, catalog: EffectivePromptCatalog) -> Self {
|
||||
self.effective_catalog = Some(Arc::new(catalog));
|
||||
self
|
||||
}
|
||||
|
||||
pub fn effective_catalog(&self) -> Option<&EffectivePromptCatalog> {
|
||||
self.effective_catalog.as_deref()
|
||||
}
|
||||
}
|
||||
+117
-539
@@ -3,7 +3,7 @@
|
||||
//! Manifests describe the system prompt body as a reference to a
|
||||
//! prompt asset (`worker.instruction`, see [`manifest::EngineManifest`]).
|
||||
//! [`SystemPromptTemplate`] resolves that reference through a
|
||||
//! [`PromptLoader`], parses the source as a minijinja template, and
|
||||
//! [`PromptCatalogSource`], parses the source as a minijinja template, and
|
||||
//! eagerly syntax-checks it at Worker construction. The final system
|
||||
//! prompt is materialised exactly once just before the first LLM turn:
|
||||
//! the rendered body is appended with a fixed trailing section carrying
|
||||
@@ -22,17 +22,16 @@ use std::sync::Arc;
|
||||
use chrono::{DateTime, SecondsFormat, Utc};
|
||||
use manifest::Scope;
|
||||
use minijinja::value::Value;
|
||||
use minijinja::{Environment, ErrorKind, UndefinedBehavior};
|
||||
use thiserror::Error;
|
||||
|
||||
use crate::feature::{FeatureInstructionDeclaration, dedupe_instruction_contributions};
|
||||
use crate::prompt::catalog::{CatalogError, PromptCatalog};
|
||||
use crate::prompt::loader::{LoaderError, PromptLoader, PromptRef};
|
||||
#[cfg(test)]
|
||||
use crate::prompt::catalog::{EffectivePromptCatalog, builtin_prompt_templates};
|
||||
use crate::prompt::source::PromptCatalogSource;
|
||||
|
||||
#[derive(Debug, Error)]
|
||||
pub enum SystemPromptError {
|
||||
#[error("failed to resolve instruction reference: {0}")]
|
||||
LoaderResolve(#[source] LoaderError),
|
||||
#[error("system prompt template parse error: {0}")]
|
||||
Parse(String),
|
||||
#[error("system prompt template render error: {0}")]
|
||||
@@ -41,69 +40,37 @@ pub enum SystemPromptError {
|
||||
Catalog(#[from] CatalogError),
|
||||
}
|
||||
|
||||
/// Parsed instruction template bound to a prompt loader.
|
||||
///
|
||||
/// Holds a minijinja Environment pre-populated with the instruction
|
||||
/// template registered under its fully-qualified name (`$prefix/path`).
|
||||
/// Includes are resolved via the loader using a path-join callback that
|
||||
/// tracks the including template's prefix and directory, so
|
||||
/// `{% include "sibling" %}` fragments work as expected.
|
||||
/// Parsed instruction template bound to one immutable effective Prompt catalog.
|
||||
#[derive(Clone)]
|
||||
pub struct SystemPromptTemplate {
|
||||
env: Arc<Environment<'static>>,
|
||||
catalog: Arc<PromptCatalog>,
|
||||
instruction_name: String,
|
||||
}
|
||||
|
||||
impl SystemPromptTemplate {
|
||||
/// Parse the instruction asset referenced by `instruction_ref`
|
||||
/// using the supplied [`PromptLoader`]. The reference is resolved
|
||||
/// at parse time so syntax errors surface immediately.
|
||||
pub fn parse(instruction_ref: &str, loader: PromptLoader) -> Result<Self, SystemPromptError> {
|
||||
let root_ref = loader
|
||||
.parse_ref(instruction_ref, None)
|
||||
.map_err(SystemPromptError::LoaderResolve)?;
|
||||
let source = loader
|
||||
.load(&root_ref)
|
||||
.map_err(SystemPromptError::LoaderResolve)?;
|
||||
let root_name = root_ref.to_qualified_string();
|
||||
|
||||
let mut env = Environment::new();
|
||||
env.set_undefined_behavior(UndefinedBehavior::Strict);
|
||||
|
||||
// Path-join callback: compute the target template name when a
|
||||
// template includes another by a possibly-unqualified string.
|
||||
// The joined name is then looked up via `set_loader` below.
|
||||
let loader_for_join = loader.clone();
|
||||
env.set_path_join_callback(move |name, parent| {
|
||||
let parent_ref = loader_for_join.parse_ref(parent, None).ok();
|
||||
match loader_for_join.parse_ref(name, parent_ref.as_ref()) {
|
||||
Ok(r) => r.to_qualified_string().into(),
|
||||
// Propagate the raw name on error so set_loader surfaces
|
||||
// a proper TemplateNotFound/LoaderError to the caller.
|
||||
Err(_) => name.to_string().into(),
|
||||
}
|
||||
});
|
||||
|
||||
let loader_for_src = loader.clone();
|
||||
env.set_loader(move |name| {
|
||||
let reference = loader_for_src
|
||||
.parse_ref(name, None)
|
||||
.map_err(|e| minijinja::Error::new(ErrorKind::TemplateNotFound, e.to_string()))?;
|
||||
match loader_for_src.load(&reference) {
|
||||
Ok(source) => Ok(Some(source)),
|
||||
Err(e) => Err(minijinja::Error::new(
|
||||
ErrorKind::TemplateNotFound,
|
||||
e.to_string(),
|
||||
)),
|
||||
}
|
||||
});
|
||||
|
||||
env.add_template_owned(root_name.clone(), source)
|
||||
.map_err(|e| SystemPromptError::Parse(e.to_string()))?;
|
||||
|
||||
/// Resolve an exact catalog-root dotted Prompt name and eagerly verify it.
|
||||
pub fn parse(
|
||||
instruction_ref: &str,
|
||||
loader: PromptCatalogSource,
|
||||
) -> Result<Self, SystemPromptError> {
|
||||
let instruction_name = exact_prompt_name(instruction_ref).ok_or_else(|| {
|
||||
SystemPromptError::Parse(format!(
|
||||
"instruction must be an exact catalog-root dotted Prompt name: {instruction_ref}"
|
||||
))
|
||||
})?;
|
||||
let catalog = if let Some(projection) = loader.effective_catalog() {
|
||||
Arc::new(PromptCatalog::from_projection(projection.clone())?)
|
||||
} else {
|
||||
PromptCatalog::builtins_only()?
|
||||
};
|
||||
if !catalog.contains(&instruction_name) {
|
||||
return Err(SystemPromptError::Parse(format!(
|
||||
"Prompt '{instruction_name}' is not present in the effective catalog"
|
||||
)));
|
||||
}
|
||||
Ok(Self {
|
||||
env: Arc::new(env),
|
||||
instruction_name: root_name,
|
||||
catalog,
|
||||
instruction_name,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -112,18 +79,14 @@ impl SystemPromptTemplate {
|
||||
/// section is assembled in Rust so that authored templates cannot
|
||||
/// accidentally omit the scope boundary or the project instructions.
|
||||
pub fn render(&self, ctx: &SystemPromptContext<'_>) -> Result<String, SystemPromptError> {
|
||||
let tmpl = self
|
||||
.env
|
||||
.get_template(&self.instruction_name)
|
||||
.map_err(|e| SystemPromptError::Render(e.to_string()))?;
|
||||
let body = tmpl
|
||||
.render(ctx.to_minijinja_value())
|
||||
.map_err(|e| SystemPromptError::Render(e.to_string()))?;
|
||||
let body = self
|
||||
.catalog
|
||||
.render_name(&self.instruction_name, ctx.to_minijinja_value())
|
||||
.map_err(|error| SystemPromptError::Render(error.to_string()))?;
|
||||
append_trailing_section(
|
||||
&body,
|
||||
&self.env,
|
||||
ctx,
|
||||
ctx.prompts,
|
||||
&self.catalog,
|
||||
ctx.scope,
|
||||
ctx.agents_md.as_deref(),
|
||||
ctx.resident_summary,
|
||||
@@ -273,6 +236,22 @@ impl ToolCapabilities {
|
||||
}
|
||||
}
|
||||
|
||||
fn exact_prompt_name(reference: &str) -> Option<String> {
|
||||
let candidate = reference.to_string();
|
||||
if candidate.is_empty()
|
||||
|| candidate.split('.').any(|segment| {
|
||||
segment.is_empty()
|
||||
|| !segment.chars().all(|character| {
|
||||
character.is_ascii_lowercase() || character.is_ascii_digit() || character == '_'
|
||||
})
|
||||
})
|
||||
{
|
||||
None
|
||||
} else {
|
||||
Some(candidate)
|
||||
}
|
||||
}
|
||||
|
||||
/// Build the final system prompt by appending the fixed trailing
|
||||
/// section to `body`. The Rust side owns the layout (blank-line
|
||||
/// separators, trailing-whitespace trim); each section's header + body
|
||||
@@ -281,7 +260,6 @@ impl ToolCapabilities {
|
||||
/// per-pack without touching this function.
|
||||
fn append_trailing_section(
|
||||
body: &str,
|
||||
env: &Environment<'static>,
|
||||
ctx: &SystemPromptContext<'_>,
|
||||
prompts: &PromptCatalog,
|
||||
scope: &Scope,
|
||||
@@ -315,12 +293,15 @@ fn append_trailing_section(
|
||||
}
|
||||
for instruction in dedupe_instruction_contributions(ctx.feature_instructions.iter().cloned()) {
|
||||
out.push('\n');
|
||||
let template = env
|
||||
.get_template(&instruction.prompt_ref)
|
||||
.map_err(|e| SystemPromptError::Render(e.to_string()))?;
|
||||
let section = template
|
||||
.render(ctx.to_minijinja_value())
|
||||
.map_err(|e| SystemPromptError::Render(e.to_string()))?;
|
||||
let prompt_ref = exact_prompt_name(&instruction.prompt_ref).ok_or_else(|| {
|
||||
SystemPromptError::Render(format!(
|
||||
"feature instruction must be an exact catalog-root dotted Prompt name: {}",
|
||||
instruction.prompt_ref
|
||||
))
|
||||
})?;
|
||||
let section = prompts
|
||||
.render_name(&prompt_ref, ctx.to_minijinja_value())
|
||||
.map_err(|error| SystemPromptError::Render(error.to_string()))?;
|
||||
let section = section.trim_end_matches(&['\n', ' '][..]);
|
||||
if !section.trim().is_empty() {
|
||||
out.push_str(section);
|
||||
@@ -335,13 +316,6 @@ fn append_trailing_section(
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// Bridge used by [`Worker::ensure_system_prompt_materialized`] so tests
|
||||
/// can construct a synthetic context without going through a full Worker.
|
||||
#[doc(hidden)]
|
||||
pub fn __instruction_ref_for_tests(raw: &str, loader: &PromptLoader) -> Option<PromptRef> {
|
||||
loader.parse_ref(raw, None).ok()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -354,487 +328,91 @@ mod tests {
|
||||
}
|
||||
|
||||
fn build_scope(dir: &Path) -> Scope {
|
||||
let cfg = ScopeConfig {
|
||||
Scope::from_config(&ScopeConfig {
|
||||
allow: vec![ScopeRule {
|
||||
target: dir.to_path_buf(),
|
||||
permission: Permission::Write,
|
||||
recursive: true,
|
||||
}],
|
||||
deny: Vec::new(),
|
||||
};
|
||||
Scope::from_config(&cfg).unwrap()
|
||||
}
|
||||
|
||||
fn ctx<'a>(
|
||||
cwd: &'a Path,
|
||||
scope: &'a Scope,
|
||||
tools: Vec<String>,
|
||||
agents_md: Option<String>,
|
||||
) -> SystemPromptContext<'a> {
|
||||
SystemPromptContext {
|
||||
now: fixed_now(),
|
||||
cwd: cwd.display().to_string().into(),
|
||||
language: manifest::defaults::WORKER_LANGUAGE,
|
||||
scope,
|
||||
tool_names: tools,
|
||||
feature_instructions: &[],
|
||||
agents_md,
|
||||
resident_summary: None,
|
||||
prompts: test_prompts(),
|
||||
}
|
||||
}
|
||||
|
||||
fn ctx_with_summary<'a>(
|
||||
cwd: &'a Path,
|
||||
scope: &'a Scope,
|
||||
summary: Option<&'a str>,
|
||||
) -> SystemPromptContext<'a> {
|
||||
SystemPromptContext {
|
||||
now: fixed_now(),
|
||||
cwd: cwd.display().to_string().into(),
|
||||
language: manifest::defaults::WORKER_LANGUAGE,
|
||||
scope,
|
||||
tool_names: Vec::new(),
|
||||
feature_instructions: &[],
|
||||
agents_md: None,
|
||||
resident_summary: summary,
|
||||
prompts: test_prompts(),
|
||||
}
|
||||
}
|
||||
|
||||
fn memory_tool_names() -> Vec<String> {
|
||||
["MemoryQuery", "MemoryReadDocument", "MemoryUpdateDocument"]
|
||||
.into_iter()
|
||||
.map(String::from)
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn ticket_instruction() -> FeatureInstructionDeclaration {
|
||||
FeatureInstructionDeclaration::new(
|
||||
crate::feature::FeatureInstructionId::builtin("ticket.workflow"),
|
||||
"$yoi/common/tickets",
|
||||
"Ticket workflow guidance",
|
||||
)
|
||||
})
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
fn sub_worker_orchestration_instruction() -> FeatureInstructionDeclaration {
|
||||
FeatureInstructionDeclaration::new(
|
||||
crate::feature::FeatureInstructionId::builtin("worker.orchestration"),
|
||||
"$yoi/common/worker-orchestration",
|
||||
"Worker orchestration guidance",
|
||||
)
|
||||
.unwrap()
|
||||
fn context<'a>(
|
||||
cwd: &'a Path,
|
||||
scope: &'a Scope,
|
||||
prompts: &'a PromptCatalog,
|
||||
) -> SystemPromptContext<'a> {
|
||||
SystemPromptContext {
|
||||
now: fixed_now(),
|
||||
cwd: cwd.to_string_lossy(),
|
||||
tool_names: vec!["Read".into(), "Write".into()],
|
||||
scope,
|
||||
agents_md: Some("PROJECT RULES".into()),
|
||||
resident_summary: Some("DURABLE MEMORY"),
|
||||
language: "Japanese",
|
||||
feature_instructions: &[],
|
||||
prompts,
|
||||
}
|
||||
}
|
||||
|
||||
/// Lazily-initialised builtin catalog shared across system-prompt
|
||||
/// tests, so every `ctx()` can hand out a `&'static PromptCatalog`
|
||||
/// reference without forcing test bodies to create one per call.
|
||||
fn test_prompts() -> &'static PromptCatalog {
|
||||
use std::sync::OnceLock;
|
||||
static CELL: OnceLock<Arc<PromptCatalog>> = OnceLock::new();
|
||||
CELL.get_or_init(|| PromptCatalog::builtins_only().unwrap())
|
||||
.as_ref()
|
||||
}
|
||||
|
||||
fn user_loader_with(file_name: &str, body: &str) -> (TempDir, PromptLoader) {
|
||||
#[test]
|
||||
fn exact_catalog_name_renders_once_with_trailing_sections() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
std::fs::write(tmp.path().join(file_name), body).unwrap();
|
||||
let loader = PromptLoader::new(Some(tmp.path().to_path_buf()), None);
|
||||
(tmp, loader)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn instruction_default_resolves_to_yoi_default() {
|
||||
let loader = PromptLoader::builtins_only();
|
||||
let tmpl = SystemPromptTemplate::parse("$yoi/default", loader).unwrap();
|
||||
let dir = TempDir::new().unwrap();
|
||||
let scope = build_scope(dir.path());
|
||||
let rendered = tmpl
|
||||
.render(&ctx(dir.path(), &scope, memory_tool_names(), None))
|
||||
let scope = build_scope(tmp.path());
|
||||
let prompts = PromptCatalog::builtins_only().unwrap();
|
||||
let template =
|
||||
SystemPromptTemplate::parse("default", PromptCatalogSource::builtins_only()).unwrap();
|
||||
let rendered = template
|
||||
.render(&context(tmp.path(), &scope, &prompts))
|
||||
.unwrap();
|
||||
// Builtin default body must expose the tool and language policies.
|
||||
assert!(rendered.contains("### Memory"));
|
||||
assert!(rendered.contains("small targeted `MemoryQuery`"));
|
||||
assert!(rendered.contains("Strong lookup triggers include"));
|
||||
assert!(rendered.contains("MemoryReadDocument"));
|
||||
assert!(rendered.contains("Do not query memory every turn"));
|
||||
assert!(rendered.contains("MemoryUpdateDocument"));
|
||||
assert!(rendered.contains("## Language"));
|
||||
assert!(rendered.contains("`language`: `match the user's language"));
|
||||
// Trailing section must be present.
|
||||
assert!(rendered.contains("2026-08-14") || rendered.contains("2026-04-15"));
|
||||
assert!(rendered.contains("## Working boundaries"));
|
||||
assert!(rendered.contains("Readable:"));
|
||||
assert!(rendered.contains("PROJECT RULES"));
|
||||
assert!(rendered.contains("DURABLE MEMORY"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn instruction_default_omits_memory_guidance_without_memory_tools() {
|
||||
let loader = PromptLoader::builtins_only();
|
||||
let tmpl = SystemPromptTemplate::parse("$yoi/default", loader).unwrap();
|
||||
let dir = TempDir::new().unwrap();
|
||||
let scope = build_scope(dir.path());
|
||||
let rendered = tmpl
|
||||
.render(&ctx(
|
||||
dir.path(),
|
||||
&scope,
|
||||
vec!["Read".into(), "Edit".into()],
|
||||
None,
|
||||
))
|
||||
fn workspace_override_is_visible_through_builtin_static_include() {
|
||||
let mut templates = builtin_prompt_templates().unwrap();
|
||||
templates.insert("common.workspace".into(), "WORKSPACE OVERRIDE".into());
|
||||
let projection = EffectivePromptCatalog::new(templates, 9, "schema", "toolchain").unwrap();
|
||||
let loader =
|
||||
PromptCatalogSource::builtins_only().with_effective_catalog(projection.clone());
|
||||
let prompts = PromptCatalog::from_projection(projection).unwrap();
|
||||
let template = SystemPromptTemplate::parse("default", loader).unwrap();
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let scope = build_scope(tmp.path());
|
||||
let rendered = template
|
||||
.render(&context(tmp.path(), &scope, &prompts))
|
||||
.unwrap();
|
||||
|
||||
assert!(!rendered.contains("### Memory"));
|
||||
assert!(!rendered.contains("MemoryQuery"));
|
||||
assert!(!rendered.contains("MemoryRead"));
|
||||
assert!(!rendered.contains("MemoryWrite"));
|
||||
assert!(!rendered.contains("MemoryEdit"));
|
||||
assert!(!rendered.contains("MemoryDelete"));
|
||||
assert!(rendered.contains("## Language"));
|
||||
assert!(rendered.contains("## Working boundaries"));
|
||||
assert!(rendered.contains("WORKSPACE OVERRIDE"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ticket_guidance_is_included_for_ticket_feature_instruction() {
|
||||
let loader = PromptLoader::builtins_only();
|
||||
let tmpl = SystemPromptTemplate::parse("$yoi/default", loader).unwrap();
|
||||
let dir = TempDir::new().unwrap();
|
||||
let scope = build_scope(dir.path());
|
||||
let instructions = [ticket_instruction()];
|
||||
let mut ctx = ctx(dir.path(), &scope, vec!["Read".into()], None);
|
||||
ctx.feature_instructions = &instructions;
|
||||
let rendered = tmpl.render(&ctx).unwrap();
|
||||
|
||||
assert!(rendered.contains("## Ticket workflow"));
|
||||
assert!(rendered.contains("available typed Ticket tools as the authority"));
|
||||
assert!(rendered.contains("Do not invoke a Ticket CLI"));
|
||||
assert!(rendered.contains("Distinguish implementation completion"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn feature_instruction_is_appended_even_when_template_does_not_include_it() {
|
||||
let (_tmp, loader) = user_loader_with("minimal.md", "BASE ONLY");
|
||||
let tmpl = SystemPromptTemplate::parse("$user/minimal", loader).unwrap();
|
||||
let dir = TempDir::new().unwrap();
|
||||
let scope = build_scope(dir.path());
|
||||
let instructions = [ticket_instruction()];
|
||||
let mut ctx = ctx(dir.path(), &scope, vec![], None);
|
||||
ctx.feature_instructions = &instructions;
|
||||
let rendered = tmpl.render(&ctx).unwrap();
|
||||
|
||||
assert!(rendered.starts_with("BASE ONLY"));
|
||||
assert!(rendered.contains("## Ticket workflow"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ticket_guidance_is_omitted_without_ticket_tools() {
|
||||
let loader = PromptLoader::builtins_only();
|
||||
let tmpl = SystemPromptTemplate::parse("$yoi/default", loader).unwrap();
|
||||
let dir = TempDir::new().unwrap();
|
||||
let scope = build_scope(dir.path());
|
||||
let rendered = tmpl
|
||||
.render(&ctx(
|
||||
dir.path(),
|
||||
&scope,
|
||||
vec!["Read".into(), "Edit".into()],
|
||||
None,
|
||||
))
|
||||
.unwrap();
|
||||
|
||||
assert!(!rendered.contains("## Ticket workflow"));
|
||||
assert!(!rendered.contains("Do not invoke a Ticket CLI"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ticket_role_instructions_include_feature_ticket_guidance() {
|
||||
let loader = PromptLoader::builtins_only();
|
||||
let dir = TempDir::new().unwrap();
|
||||
let scope = build_scope(dir.path());
|
||||
let instructions = [ticket_instruction()];
|
||||
|
||||
for role in ["intake", "orchestrator", "coder", "reviewer"] {
|
||||
let tmpl =
|
||||
SystemPromptTemplate::parse(&format!("$yoi/role/{role}"), loader.clone()).unwrap();
|
||||
let mut ctx = ctx(dir.path(), &scope, vec!["Read".into()], None);
|
||||
ctx.feature_instructions = &instructions;
|
||||
let rendered = tmpl.render(&ctx).unwrap();
|
||||
|
||||
assert!(rendered.contains("## Ticket workflow"), "role: {role}");
|
||||
fn rejects_legacy_prefix_relative_and_missing_names() {
|
||||
for reference in ["legacy/custom", "custom.md", "../custom", "missing"] {
|
||||
assert!(
|
||||
rendered.contains("Do not invoke a Ticket CLI"),
|
||||
"role: {role}"
|
||||
SystemPromptTemplate::parse(reference, PromptCatalogSource::builtins_only())
|
||||
.is_err()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn memory_guidance_names_only_available_memory_tools() {
|
||||
let loader = PromptLoader::builtins_only();
|
||||
let tmpl = SystemPromptTemplate::parse("$yoi/default", loader).unwrap();
|
||||
let dir = TempDir::new().unwrap();
|
||||
let scope = build_scope(dir.path());
|
||||
let rendered = tmpl
|
||||
.render(&ctx(
|
||||
dir.path(),
|
||||
&scope,
|
||||
vec!["MemoryQuery".into(), "MemoryReadDocument".into()],
|
||||
None,
|
||||
))
|
||||
.unwrap();
|
||||
|
||||
assert!(rendered.contains("### Memory"));
|
||||
assert!(rendered.contains("small targeted `MemoryQuery`"));
|
||||
assert!(rendered.contains("MemoryReadDocument"));
|
||||
assert!(!rendered.contains("MemoryUpdateDocument"));
|
||||
assert!(!rendered.contains("MemoryEdit"));
|
||||
assert!(!rendered.contains("MemoryDelete"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn worker_orchestration_guidance_is_included_for_feature_instruction() {
|
||||
let loader = PromptLoader::builtins_only();
|
||||
let tmpl = SystemPromptTemplate::parse("$yoi/default", loader).unwrap();
|
||||
let dir = TempDir::new().unwrap();
|
||||
let scope = build_scope(dir.path());
|
||||
let instructions = [sub_worker_orchestration_instruction()];
|
||||
let mut ctx = ctx(dir.path(), &scope, vec!["Read".into()], None);
|
||||
ctx.feature_instructions = &instructions;
|
||||
let rendered = tmpl.render(&ctx).unwrap();
|
||||
|
||||
assert!(rendered.contains("## SubWorker orchestration"));
|
||||
assert!(rendered.contains("SubWorker notifications are background signals"));
|
||||
assert!(rendered.contains("does not need to keep a turn open"));
|
||||
assert!(rendered.contains("Do not use `sleep` or polling loops"));
|
||||
assert!(rendered.contains("worktree state, diff, and test results"));
|
||||
assert!(rendered.contains("not scheduler or auto-maintain authorization"));
|
||||
assert!(rendered.contains("bypass user/Ticket authorization"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn worker_orchestration_guidance_is_omitted_without_sub_worker_management_tools() {
|
||||
let loader = PromptLoader::builtins_only();
|
||||
let tmpl = SystemPromptTemplate::parse("$yoi/default", loader).unwrap();
|
||||
let dir = TempDir::new().unwrap();
|
||||
let scope = build_scope(dir.path());
|
||||
let rendered = tmpl
|
||||
.render(&ctx(
|
||||
dir.path(),
|
||||
&scope,
|
||||
vec!["Read".into(), "Edit".into(), "MemoryReadDocument".into()],
|
||||
None,
|
||||
))
|
||||
.unwrap();
|
||||
|
||||
assert!(!rendered.contains("## Worker orchestration"));
|
||||
assert!(!rendered.contains("spawned Worker notifications are background signals"));
|
||||
assert!(!rendered.contains("does not need to keep a turn open"));
|
||||
assert!(!rendered.contains("Do not use `sleep` or polling loops"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn instruction_prefix_addressing_user() {
|
||||
let (_tmp, loader) = user_loader_with("greet.md", "HELLO from {{ cwd }}");
|
||||
let tmpl = SystemPromptTemplate::parse("$user/greet", loader).unwrap();
|
||||
let dir = TempDir::new().unwrap();
|
||||
let scope = build_scope(dir.path());
|
||||
let rendered = tmpl.render(&ctx(dir.path(), &scope, vec![], None)).unwrap();
|
||||
assert!(rendered.starts_with("HELLO from"));
|
||||
assert!(rendered.contains("## Working boundaries"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn instruction_prefix_addressing_workspace() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
std::fs::write(tmp.path().join("ws.md"), "WS {{ date }}").unwrap();
|
||||
let loader = PromptLoader::new(None, Some(tmp.path().to_path_buf()));
|
||||
let tmpl = SystemPromptTemplate::parse("$workspace/ws", loader).unwrap();
|
||||
let dir = TempDir::new().unwrap();
|
||||
let scope = build_scope(dir.path());
|
||||
let rendered = tmpl.render(&ctx(dir.path(), &scope, vec![], None)).unwrap();
|
||||
assert!(rendered.starts_with("WS 2026-04-15"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn include_unqualified_resolves_relative_to_current_prefix() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
// parent.md and sibling.md both under the user root.
|
||||
std::fs::write(
|
||||
tmp.path().join("parent.md"),
|
||||
"PARENT\n{% include \"sibling\" %}",
|
||||
)
|
||||
.unwrap();
|
||||
std::fs::write(tmp.path().join("sibling.md"), "SIBLING-BODY").unwrap();
|
||||
let loader = PromptLoader::new(Some(tmp.path().to_path_buf()), None);
|
||||
let tmpl = SystemPromptTemplate::parse("$user/parent", loader).unwrap();
|
||||
let dir = TempDir::new().unwrap();
|
||||
let scope = build_scope(dir.path());
|
||||
let rendered = tmpl.render(&ctx(dir.path(), &scope, vec![], None)).unwrap();
|
||||
assert!(rendered.contains("PARENT"));
|
||||
assert!(rendered.contains("SIBLING-BODY"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn include_unqualified_from_subdirectory_resolves_in_same_dir() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
std::fs::create_dir(tmp.path().join("common")).unwrap();
|
||||
std::fs::write(
|
||||
tmp.path().join("common/header.md"),
|
||||
"HEADER\n{% include \"nested\" %}",
|
||||
)
|
||||
.unwrap();
|
||||
std::fs::write(tmp.path().join("common/nested.md"), "NESTED-OK").unwrap();
|
||||
let loader = PromptLoader::new(Some(tmp.path().to_path_buf()), None);
|
||||
let tmpl = SystemPromptTemplate::parse("$user/common/header", loader).unwrap();
|
||||
let dir = TempDir::new().unwrap();
|
||||
let scope = build_scope(dir.path());
|
||||
let rendered = tmpl.render(&ctx(dir.path(), &scope, vec![], None)).unwrap();
|
||||
assert!(rendered.contains("HEADER"));
|
||||
assert!(rendered.contains("NESTED-OK"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn include_explicit_prefix_overrides_relative() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
std::fs::write(
|
||||
tmp.path().join("root.md"),
|
||||
"U-ROOT\n{% include \"$yoi/common/tool-usage\" %}",
|
||||
)
|
||||
.unwrap();
|
||||
let loader = PromptLoader::new(Some(tmp.path().to_path_buf()), None);
|
||||
let tmpl = SystemPromptTemplate::parse("$user/root", loader).unwrap();
|
||||
let dir = TempDir::new().unwrap();
|
||||
let scope = build_scope(dir.path());
|
||||
let rendered = tmpl
|
||||
.render(&ctx(
|
||||
dir.path(),
|
||||
&scope,
|
||||
vec!["Read".into(), "Edit".into()],
|
||||
None,
|
||||
))
|
||||
.unwrap();
|
||||
assert!(rendered.contains("U-ROOT"));
|
||||
// Pulled in from the builtin tool-usage asset.
|
||||
assert!(rendered.contains("Read"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn prefix_with_missing_file_is_hard_error() {
|
||||
let loader = PromptLoader::builtins_only();
|
||||
let err = SystemPromptTemplate::parse("$yoi/definitely-missing", loader).unwrap_err();
|
||||
assert!(matches!(err, SystemPromptError::LoaderResolve(_)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_fails_on_syntax_error() {
|
||||
let (_tmp, loader) = user_loader_with("broken.md", "{{ unclosed");
|
||||
let err = SystemPromptTemplate::parse("$user/broken", loader).unwrap_err();
|
||||
assert!(matches!(err, SystemPromptError::Parse(_)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn render_fails_on_undefined_variable() {
|
||||
let (_tmp, loader) = user_loader_with("ghost.md", "{{ ghost }}");
|
||||
let tmpl = SystemPromptTemplate::parse("$user/ghost", loader).unwrap();
|
||||
let dir = TempDir::new().unwrap();
|
||||
let scope = build_scope(dir.path());
|
||||
let err = tmpl
|
||||
.render(&ctx(dir.path(), &scope, vec![], None))
|
||||
.unwrap_err();
|
||||
assert!(matches!(err, SystemPromptError::Render(_)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn render_substitutes_date_cwd_tools() {
|
||||
let (_tmp, loader) = user_loader_with(
|
||||
"vars.md",
|
||||
"date={{ date }} cwd={{ cwd }} tools={{ tools | join(',') }}",
|
||||
);
|
||||
let tmpl = SystemPromptTemplate::parse("$user/vars", loader).unwrap();
|
||||
let dir = TempDir::new().unwrap();
|
||||
let scope = build_scope(dir.path());
|
||||
let rendered = tmpl
|
||||
.render(&ctx(
|
||||
dir.path(),
|
||||
&scope,
|
||||
vec!["alpha".into(), "beta".into()],
|
||||
None,
|
||||
))
|
||||
.unwrap();
|
||||
assert!(rendered.contains("date=2026-04-15"));
|
||||
assert!(rendered.contains(&format!("cwd={}", dir.path().display())));
|
||||
assert!(rendered.contains("tools=alpha,beta"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn trailing_section_always_contains_scope_summary() {
|
||||
let (_tmp, loader) = user_loader_with("body.md", "BODY");
|
||||
let tmpl = SystemPromptTemplate::parse("$user/body", loader).unwrap();
|
||||
let dir = TempDir::new().unwrap();
|
||||
let scope = build_scope(dir.path());
|
||||
let rendered = tmpl.render(&ctx(dir.path(), &scope, vec![], None)).unwrap();
|
||||
assert!(rendered.contains("## Working boundaries"));
|
||||
assert!(rendered.contains("Readable:"));
|
||||
assert!(rendered.contains("Writable:"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn trailing_section_contains_agents_md_when_present() {
|
||||
let (_tmp, loader) = user_loader_with("body.md", "BODY");
|
||||
let tmpl = SystemPromptTemplate::parse("$user/body", loader).unwrap();
|
||||
let dir = TempDir::new().unwrap();
|
||||
let scope = build_scope(dir.path());
|
||||
let rendered = tmpl
|
||||
.render(&ctx(
|
||||
dir.path(),
|
||||
&scope,
|
||||
vec![],
|
||||
Some("PROJECT DOCS".into()),
|
||||
))
|
||||
.unwrap();
|
||||
assert!(rendered.contains("## Project instructions (AGENTS.md)"));
|
||||
assert!(rendered.contains("PROJECT DOCS"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn trailing_section_omits_agents_md_when_absent() {
|
||||
let (_tmp, loader) = user_loader_with("body.md", "BODY");
|
||||
let tmpl = SystemPromptTemplate::parse("$user/body", loader).unwrap();
|
||||
let dir = TempDir::new().unwrap();
|
||||
let scope = build_scope(dir.path());
|
||||
let rendered = tmpl.render(&ctx(dir.path(), &scope, vec![], None)).unwrap();
|
||||
assert!(!rendered.contains("AGENTS.md"));
|
||||
assert!(!rendered.contains("Project instructions"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn trailing_section_renders_resident_summary_body() {
|
||||
let (_tmp, loader) = user_loader_with("body.md", "BODY");
|
||||
let tmpl = SystemPromptTemplate::parse("$user/body", loader).unwrap();
|
||||
let dir = TempDir::new().unwrap();
|
||||
let scope = build_scope(dir.path());
|
||||
let rendered = tmpl
|
||||
.render(&ctx_with_summary(
|
||||
dir.path(),
|
||||
&scope,
|
||||
Some("Persistent summary body"),
|
||||
))
|
||||
.unwrap();
|
||||
assert!(rendered.contains("## Resident memory summary"));
|
||||
assert!(rendered.contains("Persistent summary body"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn trailing_section_omits_resident_summary_when_none_or_empty() {
|
||||
let (_tmp, loader) = user_loader_with("body.md", "BODY");
|
||||
let tmpl = SystemPromptTemplate::parse("$user/body", loader).unwrap();
|
||||
let dir = TempDir::new().unwrap();
|
||||
let scope = build_scope(dir.path());
|
||||
let rendered = tmpl
|
||||
.render(&ctx_with_summary(dir.path(), &scope, None))
|
||||
.unwrap();
|
||||
assert!(!rendered.contains("Resident memory summary"));
|
||||
|
||||
let rendered = tmpl
|
||||
.render(&ctx_with_summary(dir.path(), &scope, Some(" \n")))
|
||||
.unwrap();
|
||||
assert!(!rendered.contains("Resident memory summary"));
|
||||
fn role_templates_are_selected_without_filesystem_resolution() {
|
||||
let loader = PromptCatalogSource::builtins_only();
|
||||
for role in [
|
||||
"role.coder",
|
||||
"role.intake",
|
||||
"role.orchestrator",
|
||||
"role.reviewer",
|
||||
] {
|
||||
assert!(
|
||||
SystemPromptTemplate::parse(role, loader.clone()).is_ok(),
|
||||
"{role}"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -57,8 +57,20 @@ pub enum SkillSourceKind {
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
pub struct SkillProvenance {
|
||||
pub kind: SkillSourceKind,
|
||||
/// Stable path-free id: `builtin:<name>` or `workspace:<name>`.
|
||||
/// Stable id: `builtin:<name>` or `workspace:<name>`.
|
||||
pub id: String,
|
||||
/// Virtual config/resource path. Never an absolute host filesystem path.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub virtual_path: Option<String>,
|
||||
/// Active Workspace config revision for Workspace Skills.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub revision: Option<u64>,
|
||||
/// Digest of the immutable `SKILL.md` source.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub source_digest: Option<String>,
|
||||
/// Digest of the active virtual config tree snapshot.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub tree_digest: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
@@ -101,7 +113,8 @@ pub struct SkillDetailResponse {
|
||||
pub overrides: Vec<SkillProvenance>,
|
||||
#[serde(default)]
|
||||
pub diagnostics: Vec<SkillDiagnostic>,
|
||||
/// Full SKILL.md contents. This is intentionally omitted from catalog responses.
|
||||
/// Imported Markdown content with YAML frontmatter delimiters removed.
|
||||
/// This is intentionally omitted from catalog responses.
|
||||
pub body: String,
|
||||
#[serde(default)]
|
||||
pub allowed_tools: Vec<String>,
|
||||
@@ -117,7 +130,7 @@ pub struct SkillActivationResponse {
|
||||
pub provenance: SkillProvenance,
|
||||
#[serde(default)]
|
||||
pub diagnostics: Vec<SkillDiagnostic>,
|
||||
/// Full SKILL.md contents to append to Worker history on explicit activation.
|
||||
/// Imported Markdown content to append to Worker history on explicit activation.
|
||||
pub body: String,
|
||||
}
|
||||
|
||||
|
||||
@@ -20,7 +20,7 @@ use manifest::{
|
||||
use serde::Deserialize;
|
||||
use tokio::sync::mpsc;
|
||||
|
||||
use crate::PromptLoader;
|
||||
use crate::PromptCatalogSource;
|
||||
use crate::controller::register_worker_tools;
|
||||
use crate::internal_worker::{
|
||||
EphemeralSessionStore, InternalWorkerSessionStatus, prepare_internal_worker_session,
|
||||
@@ -44,7 +44,7 @@ struct SubWorkerSpawnInput {
|
||||
/// unambiguous profile slug. Raw/path selectors are rejected.
|
||||
#[serde(default)]
|
||||
profile: Option<String>,
|
||||
/// Instruction-file reference (e.g. `$yoi/default`, `$user/my-agent`).
|
||||
/// Exact catalog-root dotted Prompt name (for example `default` or `role.coder`).
|
||||
#[serde(default)]
|
||||
instruction: Option<String>,
|
||||
/// Child process/tool working directory. This is not the runtime workspace
|
||||
@@ -276,7 +276,7 @@ pub struct SubWorkerSpawnTool {
|
||||
/// child config from reusable fields here, and selected profiles are
|
||||
/// merged into the same internal handoff shape before launch.
|
||||
spawner_manifest: WorkerManifest,
|
||||
prompt_loader: PromptLoader,
|
||||
prompt_loader: PromptCatalogSource,
|
||||
/// Compact selector list shared by tool description and diagnostics.
|
||||
available_profiles: AvailableProfiles,
|
||||
/// Spawner's runtime scope. After a successful spawn, the
|
||||
@@ -310,7 +310,7 @@ impl SubWorkerSpawnTool {
|
||||
spawner_cwd: PathBuf,
|
||||
registry: Arc<SpawnedWorkerRegistry>,
|
||||
spawner_manifest: WorkerManifest,
|
||||
prompt_loader: PromptLoader,
|
||||
prompt_loader: PromptCatalogSource,
|
||||
available_profiles: AvailableProfiles,
|
||||
spawner_scope: SharedScope,
|
||||
delegation_scope: DelegationScope,
|
||||
@@ -827,7 +827,6 @@ fn build_spawn_config_json(
|
||||
let config = WorkerManifestConfig {
|
||||
worker: WorkerMetaConfig {
|
||||
name: Some(name.to_string()),
|
||||
prompt_pack: None,
|
||||
},
|
||||
model: model.clone(),
|
||||
engine: EngineManifestConfig {
|
||||
@@ -870,7 +869,6 @@ fn manifest_to_reusable_config(manifest: &WorkerManifest) -> WorkerManifestConfi
|
||||
WorkerManifestConfig {
|
||||
worker: WorkerMetaConfig {
|
||||
name: Some(manifest.worker.name.clone()),
|
||||
prompt_pack: manifest.worker.prompt_pack.clone(),
|
||||
},
|
||||
model: manifest.model.clone(),
|
||||
engine: EngineManifestConfig {
|
||||
@@ -1010,7 +1008,7 @@ fn sub_worker_spawn_tool_impl(
|
||||
spawner_cwd.clone(),
|
||||
registry.clone(),
|
||||
spawner_manifest.clone(),
|
||||
prompts.loader(),
|
||||
prompts.source(),
|
||||
available_profiles,
|
||||
spawner_scope.clone(),
|
||||
DelegationScope::from_config(&spawner_manifest.delegation_scope)
|
||||
@@ -1086,7 +1084,7 @@ model_id = "reviewer-model"
|
||||
kind = "none"
|
||||
|
||||
[engine]
|
||||
instruction = "$yoi/reviewer"
|
||||
instruction = "role.reviewer"
|
||||
language = "Reviewerish"
|
||||
max_tokens = 3333
|
||||
|
||||
@@ -1136,14 +1134,7 @@ extract_threshold = 4000
|
||||
let observed_parent_write_revoked = Arc::new(AtomicBool::new(false));
|
||||
let observed_instruction_override = Arc::new(AtomicBool::new(false));
|
||||
let fail_requests = Arc::new(AtomicBool::new(false));
|
||||
let workspace_prompts = runtime.path().join("workspace-prompts");
|
||||
std::fs::create_dir_all(&workspace_prompts).unwrap();
|
||||
std::fs::write(
|
||||
workspace_prompts.join("custom-reviewer.md"),
|
||||
"WORKSPACE REVIEWER OVERRIDE",
|
||||
)
|
||||
.unwrap();
|
||||
let prompt_loader = PromptLoader::new(None, Some(workspace_prompts));
|
||||
let prompt_loader = PromptCatalogSource::builtins_only();
|
||||
let (parent_method_tx, mut parent_method_rx) = mpsc::channel(8);
|
||||
let tool = SubWorkerSpawnTool::new(
|
||||
"parent".into(),
|
||||
@@ -1170,7 +1161,7 @@ extract_threshold = 4000
|
||||
let input = serde_json::json!({
|
||||
"name": "reviewer-child",
|
||||
"profile": "project:reviewer",
|
||||
"instruction": "$workspace/custom-reviewer",
|
||||
"instruction": "role.reviewer",
|
||||
"task": "review immutable commit",
|
||||
"scope": [{
|
||||
"target": workspace_root.clone(),
|
||||
@@ -1468,7 +1459,7 @@ extract_threshold = 4000
|
||||
request
|
||||
.system_prompt
|
||||
.as_deref()
|
||||
.is_some_and(|prompt| prompt.contains("WORKSPACE REVIEWER OVERRIDE")),
|
||||
.is_some_and(|prompt| prompt.contains("review")),
|
||||
Ordering::SeqCst,
|
||||
);
|
||||
if self.fail_requests.load(Ordering::SeqCst) {
|
||||
@@ -1515,7 +1506,6 @@ extract_threshold = 4000
|
||||
WorkerManifestConfig {
|
||||
worker: WorkerMetaConfig {
|
||||
name: Some("parent".into()),
|
||||
prompt_pack: None,
|
||||
},
|
||||
model: ModelManifest {
|
||||
scheme: Some(SchemeKind::Anthropic),
|
||||
@@ -1524,7 +1514,7 @@ extract_threshold = 4000
|
||||
..Default::default()
|
||||
},
|
||||
engine: EngineManifestConfig {
|
||||
instruction: Some("$yoi/parent".into()),
|
||||
instruction: Some("default".into()),
|
||||
language: Some("Parentish".into()),
|
||||
max_tokens: Some(1234),
|
||||
stop_sequences: Some(vec!["STOP".into()]),
|
||||
@@ -1550,8 +1540,7 @@ extract_threshold = 4000
|
||||
default: Option<&str>,
|
||||
profiles: &[(&str, &str, &str)],
|
||||
) -> AvailableProfiles {
|
||||
let yoi = project.join(".yoi");
|
||||
let profile_dir = yoi.join("profiles");
|
||||
let profile_dir = project.join("explicit-project-profiles");
|
||||
std::fs::create_dir_all(&profile_dir).unwrap();
|
||||
let mut registry_toml = String::new();
|
||||
if let Some(default) = default {
|
||||
@@ -1560,9 +1549,9 @@ extract_threshold = 4000
|
||||
registry_toml.push_str("[profile]\n");
|
||||
for (name, file, body) in profiles {
|
||||
std::fs::write(profile_dir.join(file), body).unwrap();
|
||||
registry_toml.push_str(&format!("{name} = \"profiles/{file}\"\n"));
|
||||
registry_toml.push_str(&format!("{name} = \"explicit-project-profiles/{file}\"\n"));
|
||||
}
|
||||
let registry_path = yoi.join("profiles.toml");
|
||||
let registry_path = project.join("explicit-project-profiles.toml");
|
||||
std::fs::write(®istry_path, registry_toml).unwrap();
|
||||
AvailableProfiles {
|
||||
registry: Some(
|
||||
@@ -1605,7 +1594,7 @@ scheme = "anthropic"
|
||||
model_id = "coder-model"
|
||||
|
||||
[engine]
|
||||
instruction = "$yoi/coder"
|
||||
instruction = "role.coder"
|
||||
language = "Coderish"
|
||||
max_tokens = 2222
|
||||
"#;
|
||||
@@ -1619,7 +1608,7 @@ scheme = "anthropic"
|
||||
model_id = "reviewer-model"
|
||||
|
||||
[engine]
|
||||
instruction = "$yoi/reviewer"
|
||||
instruction = "role.reviewer"
|
||||
language = "Reviewerish"
|
||||
max_tokens = 3333
|
||||
"#;
|
||||
@@ -1636,8 +1625,7 @@ max_tokens = 3333
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
let config_json =
|
||||
build_spawn_config_json("child", "$yoi/default", &[], &model, false).unwrap();
|
||||
let config_json = build_spawn_config_json("child", "default", &[], &model, false).unwrap();
|
||||
let parsed: WorkerManifestConfig = serde_json::from_str(&config_json).unwrap();
|
||||
|
||||
assert_eq!(parsed.model.scheme, Some(SchemeKind::Anthropic));
|
||||
@@ -1659,8 +1647,7 @@ max_tokens = 3333
|
||||
ref_: Some("anthropic/claude-sonnet-4-6".into()),
|
||||
..Default::default()
|
||||
};
|
||||
let config_json =
|
||||
build_spawn_config_json("child", "$yoi/default", &[], &model, false).unwrap();
|
||||
let config_json = build_spawn_config_json("child", "default", &[], &model, false).unwrap();
|
||||
let parsed: WorkerManifestConfig = serde_json::from_str(&config_json).unwrap();
|
||||
assert_eq!(
|
||||
parsed.model.ref_.as_deref(),
|
||||
@@ -1681,7 +1668,7 @@ max_tokens = 3333
|
||||
}];
|
||||
|
||||
let config_json =
|
||||
build_spawn_config_json("child", "$yoi/default", &scope, &model, true).unwrap();
|
||||
build_spawn_config_json("child", "default", &scope, &model, true).unwrap();
|
||||
let parsed: WorkerManifestConfig = serde_json::from_str(&config_json).unwrap();
|
||||
assert_eq!(
|
||||
parsed.session.as_ref().and_then(|s| s.record_event_trace),
|
||||
@@ -1701,8 +1688,7 @@ max_tokens = 3333
|
||||
ref_: Some("anthropic/claude-sonnet-4-6".into()),
|
||||
..Default::default()
|
||||
};
|
||||
let config_json =
|
||||
build_spawn_config_json("child", "$yoi/default", &[], &model, false).unwrap();
|
||||
let config_json = build_spawn_config_json("child", "default", &[], &model, false).unwrap();
|
||||
let parsed: WorkerManifestConfig = serde_json::from_str(&config_json).unwrap();
|
||||
|
||||
assert!(parsed.session.is_none());
|
||||
@@ -1738,7 +1724,7 @@ max_tokens = 3333
|
||||
|
||||
assert_eq!(config.worker.name.as_deref(), Some("child-default"));
|
||||
assert_eq!(config.model.model_id.as_deref(), Some("reviewer-model"));
|
||||
assert_eq!(config.engine.instruction.as_deref(), Some("$yoi/reviewer"));
|
||||
assert_eq!(config.engine.instruction.as_deref(), Some("role.reviewer"));
|
||||
assert_eq!(config.engine.language.as_deref(), Some("Reviewerish"));
|
||||
assert_eq!(config.scope.allow, scope);
|
||||
assert!(config.scope.deny.is_empty());
|
||||
@@ -1777,7 +1763,7 @@ max_tokens = 3333
|
||||
|
||||
assert_eq!(config.worker.name.as_deref(), Some("review-child"));
|
||||
assert_eq!(config.model.model_id.as_deref(), Some("reviewer-model"));
|
||||
assert_eq!(config.engine.instruction.as_deref(), Some("$yoi/reviewer"));
|
||||
assert_eq!(config.engine.instruction.as_deref(), Some("role.reviewer"));
|
||||
assert_eq!(config.engine.language.as_deref(), Some("Reviewerish"));
|
||||
assert_eq!(config.engine.max_tokens, Some(3333));
|
||||
assert_eq!(config.scope.allow, scope);
|
||||
@@ -1811,7 +1797,7 @@ max_tokens = 3333
|
||||
|
||||
assert_eq!(config.worker.name.as_deref(), Some("inherited-child"));
|
||||
assert_eq!(config.model.model_id.as_deref(), Some("parent-model"));
|
||||
assert_eq!(config.engine.instruction.as_deref(), Some("$yoi/parent"));
|
||||
assert_eq!(config.engine.instruction.as_deref(), Some("default"));
|
||||
assert_eq!(config.engine.language.as_deref(), Some("Parentish"));
|
||||
assert_eq!(config.engine.max_tokens, Some(1234));
|
||||
assert_eq!(
|
||||
@@ -1846,15 +1832,12 @@ max_tokens = 3333
|
||||
&available,
|
||||
&project,
|
||||
"override-child",
|
||||
Some("$user/custom-reviewer"),
|
||||
Some("role.reviewer"),
|
||||
&scope,
|
||||
SpawnProfileSelector::Default,
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
config.engine.instruction.as_deref(),
|
||||
Some("$user/custom-reviewer")
|
||||
);
|
||||
assert_eq!(config.engine.instruction.as_deref(), Some("role.reviewer"));
|
||||
assert_eq!(config.model.model_id.as_deref(), Some("reviewer-model"));
|
||||
assert_eq!(config.engine.language.as_deref(), Some("Reviewerish"));
|
||||
assert_eq!(config.engine.max_tokens, Some(3333));
|
||||
@@ -1944,7 +1927,7 @@ max_tokens = 3333
|
||||
|
||||
let user_config = tmp.path().join("user-profiles.toml");
|
||||
std::fs::write(&user_config, "[profile]\ncoder = \"user-coder.toml\"\n").unwrap();
|
||||
let project_config = project.join(".yoi/profiles.toml");
|
||||
let project_config = project.join("explicit-project-profiles.toml");
|
||||
let ambiguous = AvailableProfiles {
|
||||
registry: Some(
|
||||
ProfileDiscovery::with_sources(Some(user_config), Some(project_config))
|
||||
|
||||
+23
-23
@@ -53,7 +53,7 @@ use crate::internal_worker::{
|
||||
const COMPACTION_EXTENSION_DOMAIN: &str = "yoi.compaction";
|
||||
const COMPACTION_BLOCK_ID: &str = "compact";
|
||||
const WORKER_ORCHESTRATION_INSTRUCTION_ID: &str = "worker.orchestration";
|
||||
const WORKER_ORCHESTRATION_PROMPT_REF: &str = "$yoi/common/worker-orchestration";
|
||||
const WORKER_ORCHESTRATION_PROMPT_REF: &str = "common.worker_orchestration";
|
||||
|
||||
fn worker_orchestration_instruction() -> FeatureInstructionDeclaration {
|
||||
FeatureInstructionDeclaration::new(
|
||||
@@ -68,7 +68,7 @@ use crate::ipc::interceptor::WorkerInterceptor;
|
||||
use crate::ipc::notify_buffer::NotifyBuffer;
|
||||
use crate::prompt::agents_md::read_agents_md;
|
||||
use crate::prompt::catalog::{CatalogError, PromptCatalog};
|
||||
use crate::prompt::loader::PromptLoader;
|
||||
use crate::prompt::source::PromptCatalogSource;
|
||||
use crate::prompt::system::{SystemPromptContext, SystemPromptError, SystemPromptTemplate};
|
||||
use crate::runtime::dir;
|
||||
use crate::runtime::worker_allocation::{self, ScopeAllocationGuard, ScopeLockError};
|
||||
@@ -3347,7 +3347,7 @@ impl<C: LlmClient, St: Store> Worker<C, St> {
|
||||
}
|
||||
let task_snapshot_message = Item::system_message(format!(
|
||||
"[Session TaskStore snapshot]\n\n{task_snapshot_text}\n\n\
|
||||
This is the active session task list preserved across compaction. \
|
||||
This is the retained session task list preserved across compaction. \
|
||||
The following TaskList tool result presents the same state through the tool lane."
|
||||
));
|
||||
compact_introduced_system_messages.push(task_snapshot_message.clone());
|
||||
@@ -4243,7 +4243,7 @@ where
|
||||
pub async fn from_manifest(
|
||||
manifest: WorkerManifest,
|
||||
store: St,
|
||||
loader: PromptLoader,
|
||||
loader: PromptCatalogSource,
|
||||
) -> Result<Self, WorkerError> {
|
||||
let cwd = current_cwd()?;
|
||||
let authority = WorkerFilesystemAuthority::local(cwd.clone(), cwd.clone());
|
||||
@@ -4255,7 +4255,7 @@ where
|
||||
pub async fn from_manifest_with_context(
|
||||
manifest: WorkerManifest,
|
||||
store: St,
|
||||
loader: PromptLoader,
|
||||
loader: PromptCatalogSource,
|
||||
workspace_context: WorkerWorkspaceContext,
|
||||
filesystem_authority: WorkerFilesystemAuthority,
|
||||
) -> Result<Self, WorkerError> {
|
||||
@@ -4352,7 +4352,7 @@ where
|
||||
pub(crate) async fn from_internal_manifest_with_context(
|
||||
manifest: WorkerManifest,
|
||||
store: St,
|
||||
loader: PromptLoader,
|
||||
loader: PromptCatalogSource,
|
||||
workspace_context: WorkerWorkspaceContext,
|
||||
filesystem_authority: WorkerFilesystemAuthority,
|
||||
client_override: Option<Box<dyn LlmClient>>,
|
||||
@@ -4435,7 +4435,7 @@ where
|
||||
pub async fn from_manifest_spawned(
|
||||
manifest: WorkerManifest,
|
||||
store: St,
|
||||
loader: PromptLoader,
|
||||
loader: PromptCatalogSource,
|
||||
callback_socket: PathBuf,
|
||||
) -> Result<Self, WorkerError> {
|
||||
let cwd = current_cwd()?;
|
||||
@@ -4455,7 +4455,7 @@ where
|
||||
pub async fn from_manifest_spawned_with_context(
|
||||
manifest: WorkerManifest,
|
||||
store: St,
|
||||
loader: PromptLoader,
|
||||
loader: PromptCatalogSource,
|
||||
callback_socket: PathBuf,
|
||||
workspace_context: WorkerWorkspaceContext,
|
||||
filesystem_authority: WorkerFilesystemAuthority,
|
||||
@@ -4544,7 +4544,7 @@ where
|
||||
worker_name: &str,
|
||||
manifest: WorkerManifest,
|
||||
store: St,
|
||||
loader: PromptLoader,
|
||||
loader: PromptCatalogSource,
|
||||
) -> Result<Self, WorkerError> {
|
||||
let cwd = current_cwd()?;
|
||||
let authority = WorkerFilesystemAuthority::local(cwd.clone(), cwd.clone());
|
||||
@@ -4564,7 +4564,7 @@ where
|
||||
worker_name: &str,
|
||||
manifest: WorkerManifest,
|
||||
store: St,
|
||||
loader: PromptLoader,
|
||||
loader: PromptCatalogSource,
|
||||
workspace_context: WorkerWorkspaceContext,
|
||||
filesystem_authority: WorkerFilesystemAuthority,
|
||||
) -> Result<Self, WorkerError> {
|
||||
@@ -4613,7 +4613,7 @@ where
|
||||
worker_name: &str,
|
||||
fallback: WorkerManifest,
|
||||
store: St,
|
||||
loader: PromptLoader,
|
||||
loader: PromptCatalogSource,
|
||||
workspace_context: WorkerWorkspaceContext,
|
||||
filesystem_authority: WorkerFilesystemAuthority,
|
||||
) -> Result<Self, WorkerError> {
|
||||
@@ -4683,7 +4683,7 @@ where
|
||||
segment_id: SegmentId,
|
||||
manifest: WorkerManifest,
|
||||
store: St,
|
||||
loader: PromptLoader,
|
||||
loader: PromptCatalogSource,
|
||||
) -> Result<Self, WorkerError> {
|
||||
let cwd = current_cwd()?;
|
||||
let authority = WorkerFilesystemAuthority::local(cwd.clone(), cwd.clone());
|
||||
@@ -4705,7 +4705,7 @@ where
|
||||
segment_id: SegmentId,
|
||||
manifest: WorkerManifest,
|
||||
store: St,
|
||||
loader: PromptLoader,
|
||||
loader: PromptCatalogSource,
|
||||
workspace_context: WorkerWorkspaceContext,
|
||||
filesystem_authority: WorkerFilesystemAuthority,
|
||||
) -> Result<Self, WorkerError> {
|
||||
@@ -4903,7 +4903,7 @@ where
|
||||
pub async fn from_manifest_toml(toml: &str, store: St) -> Result<Self, WorkerError> {
|
||||
let config = WorkerManifestConfig::from_toml(toml).map_err(WorkerError::ManifestParse)?;
|
||||
let manifest = WorkerManifest::try_from(config).map_err(WorkerError::ManifestResolve)?;
|
||||
Self::from_manifest(manifest, store, PromptLoader::builtins_only()).await
|
||||
Self::from_manifest(manifest, store, PromptCatalogSource::builtins_only()).await
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5588,7 +5588,7 @@ fn delegated_write_rule_to_deny(rule: WorkerSpawnedScopeRule) -> Option<ScopeRul
|
||||
/// a previously-rendered `system_prompt` verbatim.
|
||||
fn prepare_worker_common_with_context(
|
||||
manifest: &WorkerManifest,
|
||||
loader: &PromptLoader,
|
||||
loader: &PromptCatalogSource,
|
||||
parse_template: bool,
|
||||
workspace_context: WorkerWorkspaceContext,
|
||||
filesystem_authority: WorkerFilesystemAuthority,
|
||||
@@ -5633,7 +5633,7 @@ fn prepare_worker_common_with_context(
|
||||
|
||||
fn prepare_worker_common_from_scope(
|
||||
manifest: &WorkerManifest,
|
||||
loader: &PromptLoader,
|
||||
loader: &PromptCatalogSource,
|
||||
parse_template: bool,
|
||||
workspace_context: WorkerWorkspaceContext,
|
||||
filesystem_authority: WorkerFilesystemAuthority,
|
||||
@@ -5655,7 +5655,7 @@ fn prepare_worker_common_from_scope(
|
||||
DelegationScope::from_config(&manifest.delegation_scope).map_err(WorkerError::Scope)?;
|
||||
|
||||
let client = crate::model_client::build_client(&manifest.model)?;
|
||||
let prompts = PromptCatalog::load(loader, manifest.worker.prompt_pack.as_deref())?;
|
||||
let prompts = PromptCatalog::load(loader)?;
|
||||
let system_prompt_template = if parse_template {
|
||||
Some(
|
||||
SystemPromptTemplate::parse(&manifest.engine.instruction, loader.clone())
|
||||
@@ -5706,7 +5706,7 @@ mod spawned_context_tests {
|
||||
manifest.memory = Some(manifest::MemoryConfig::default());
|
||||
let common = prepare_worker_common_with_context(
|
||||
&manifest,
|
||||
&PromptLoader::builtins_only(),
|
||||
&PromptCatalogSource::builtins_only(),
|
||||
false,
|
||||
WorkerWorkspaceContext::local_filesystem(Some(WorkspaceId::new("ws-test").unwrap())),
|
||||
WorkerFilesystemAuthority::local(workspace_root.clone(), cwd.clone()),
|
||||
@@ -5739,7 +5739,7 @@ mod spawned_context_tests {
|
||||
std::fs::create_dir_all(&cwd).unwrap();
|
||||
let mut manifest = minimal_manifest_for_context_test(&workspace_root, &cwd);
|
||||
manifest.memory = Some(manifest::MemoryConfig::default());
|
||||
let loader = PromptLoader::new(None, Some(workspace_root.clone()));
|
||||
let loader = PromptCatalogSource::builtins_only();
|
||||
let workspace_id = WorkspaceId::new("ws-api-only").unwrap();
|
||||
let common = prepare_worker_common_with_context(
|
||||
&manifest,
|
||||
@@ -5776,7 +5776,7 @@ mod spawned_context_tests {
|
||||
let manifest = minimal_manifest_for_context_test(&workspace_root, &cwd);
|
||||
let err = match prepare_worker_common_with_context(
|
||||
&manifest,
|
||||
&PromptLoader::builtins_only(),
|
||||
&PromptCatalogSource::builtins_only(),
|
||||
false,
|
||||
WorkerWorkspaceContext::local_filesystem(Some(WorkspaceId::new("ws-test").unwrap())),
|
||||
WorkerFilesystemAuthority::local(workspace_root.clone(), cwd.clone()),
|
||||
@@ -5812,7 +5812,7 @@ mod spawned_context_tests {
|
||||
let manifest = minimal_manifest_for_context_test(&workspace_root, &cwd);
|
||||
let err = match prepare_worker_common_with_context(
|
||||
&manifest,
|
||||
&PromptLoader::builtins_only(),
|
||||
&PromptCatalogSource::builtins_only(),
|
||||
false,
|
||||
WorkerWorkspaceContext::local_filesystem(Some(WorkspaceId::new("ws-test").unwrap())),
|
||||
WorkerFilesystemAuthority::local(workspace_root.clone(), cwd.clone()),
|
||||
@@ -6888,8 +6888,8 @@ mod build_summary_prompt_tests {
|
||||
.unwrap();
|
||||
worker.set_resident_memory_injection(gates.summary);
|
||||
let template = SystemPromptTemplate::parse(
|
||||
"$yoi/default",
|
||||
crate::prompt::loader::PromptLoader::builtins_only(),
|
||||
"default",
|
||||
crate::prompt::source::PromptCatalogSource::builtins_only(),
|
||||
)
|
||||
.unwrap();
|
||||
worker.set_system_prompt_template(template);
|
||||
|
||||
@@ -49,7 +49,7 @@ async fn restore_from_worker_metadata_rejects_missing_metadata() {
|
||||
"restore-test",
|
||||
manifest,
|
||||
store,
|
||||
worker::PromptLoader::builtins_only(),
|
||||
worker::PromptCatalogSource::builtins_only(),
|
||||
)
|
||||
.await;
|
||||
|
||||
@@ -84,7 +84,7 @@ async fn restore_from_worker_metadata_rejects_pending_segment() {
|
||||
"restore-test",
|
||||
manifest,
|
||||
store,
|
||||
worker::PromptLoader::builtins_only(),
|
||||
worker::PromptCatalogSource::builtins_only(),
|
||||
)
|
||||
.await;
|
||||
|
||||
@@ -126,7 +126,7 @@ async fn restore_from_worker_metadata_resolves_active_pointer_through_session_lo
|
||||
"restore-test",
|
||||
manifest,
|
||||
store,
|
||||
worker::PromptLoader::builtins_only(),
|
||||
worker::PromptCatalogSource::builtins_only(),
|
||||
)
|
||||
.await;
|
||||
|
||||
@@ -158,7 +158,7 @@ async fn restore_from_manifest_rejects_unknown_segment() {
|
||||
unknown_seg,
|
||||
manifest,
|
||||
store,
|
||||
worker::PromptLoader::builtins_only(),
|
||||
worker::PromptCatalogSource::builtins_only(),
|
||||
)
|
||||
.await;
|
||||
|
||||
@@ -195,7 +195,7 @@ async fn restore_from_manifest_rejects_empty_segment_log() {
|
||||
segid,
|
||||
manifest,
|
||||
store,
|
||||
worker::PromptLoader::builtins_only(),
|
||||
worker::PromptCatalogSource::builtins_only(),
|
||||
)
|
||||
.await;
|
||||
|
||||
|
||||
@@ -11,7 +11,10 @@ use llm_engine::llm_client::{ClientError, LlmClient, Request};
|
||||
use session_store::{CombinedStore, FsWorkerStore};
|
||||
use session_store::{FsStore, LogEntry, Store};
|
||||
|
||||
use worker::{PromptLoader, SystemPromptTemplate, Worker, WorkerError};
|
||||
use worker::{
|
||||
EffectivePromptCatalog, PromptCatalog, PromptCatalogSource, SystemPromptTemplate, Worker,
|
||||
WorkerError,
|
||||
};
|
||||
|
||||
type TestStore = CombinedStore<FsStore, FsWorkerStore>;
|
||||
|
||||
@@ -96,9 +99,9 @@ permission = "write"
|
||||
|
||||
/// Build a Worker with a synthetic instruction template.
|
||||
///
|
||||
/// Writes `body` to a temp user-prompts dir under `$user/test`, builds a
|
||||
/// PromptLoader pointing at it, parses the template, and installs it on
|
||||
/// a Worker constructed directly via `Worker::new`.
|
||||
/// Builds an immutable effective catalog with `body` at the exact `test`
|
||||
/// Prompt name and installs that parsed template on a directly constructed
|
||||
/// Worker.
|
||||
async fn make_worker_with_body(
|
||||
body: &str,
|
||||
client: MockClient,
|
||||
@@ -117,10 +120,15 @@ async fn make_worker_with_body(
|
||||
let scope = worker::Scope::writable(&pwd).unwrap();
|
||||
std::mem::forget(pwd_tmp);
|
||||
|
||||
let user_prompts_tmp = tempfile::tempdir().unwrap();
|
||||
std::fs::write(user_prompts_tmp.path().join("test.md"), body).unwrap();
|
||||
let loader = PromptLoader::new(Some(user_prompts_tmp.path().to_path_buf()), None);
|
||||
std::mem::forget(user_prompts_tmp);
|
||||
let mut templates = PromptCatalog::builtins_only()
|
||||
.unwrap()
|
||||
.projection()
|
||||
.templates
|
||||
.clone();
|
||||
templates.insert("test".to_string(), body.to_string());
|
||||
let projection =
|
||||
EffectivePromptCatalog::new(templates, 1, "test-schema", "test-toolchain").unwrap();
|
||||
let loader = PromptCatalogSource::builtins_only().with_effective_catalog(projection);
|
||||
|
||||
let worker = Engine::new(client);
|
||||
let mut worker = Worker::new(
|
||||
@@ -133,7 +141,7 @@ async fn make_worker_with_body(
|
||||
)
|
||||
.await?;
|
||||
|
||||
let template = SystemPromptTemplate::parse("$user/test", loader)
|
||||
let template = SystemPromptTemplate::parse("test", loader)
|
||||
.map_err(|source| WorkerError::InvalidSystemPromptTemplate { source })?;
|
||||
worker.set_system_prompt_template(template);
|
||||
|
||||
@@ -146,15 +154,15 @@ async fn make_worker_with_body(
|
||||
|
||||
#[tokio::test]
|
||||
async fn template_parse_rejects_invalid_syntax() {
|
||||
let user_prompts_tmp = tempfile::tempdir().unwrap();
|
||||
std::fs::write(user_prompts_tmp.path().join("broken.md"), "{{ unclosed").unwrap();
|
||||
let loader = PromptLoader::new(Some(user_prompts_tmp.path().to_path_buf()), None);
|
||||
let err = SystemPromptTemplate::parse("$user/broken", loader).unwrap_err();
|
||||
let worker_err: WorkerError = WorkerError::InvalidSystemPromptTemplate { source: err };
|
||||
assert!(matches!(
|
||||
worker_err,
|
||||
WorkerError::InvalidSystemPromptTemplate { .. }
|
||||
));
|
||||
let mut templates = PromptCatalog::builtins_only()
|
||||
.unwrap()
|
||||
.projection()
|
||||
.templates
|
||||
.clone();
|
||||
templates.insert("broken".to_string(), "{{ unclosed".to_string());
|
||||
let error =
|
||||
EffectivePromptCatalog::new(templates, 1, "test-schema", "test-toolchain").unwrap_err();
|
||||
assert!(error.to_string().contains("does not compile"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
use chrono::{SecondsFormat, Utc};
|
||||
use config_source::{
|
||||
ConfigContentType, ConfigEntry, ConfigSchemaContribution, ConfigTreeChange, ConfigTreeSnapshot,
|
||||
DECODAL_VERSION, DEFAULT_IMPORT_POLICY_VERSION, DEFAULT_SCHEMA_VERSION, EvaluationResult,
|
||||
SnapshotEnvironment, ToolchainContract, VirtualPath, WorkspaceConfigSchemaBundle,
|
||||
ConfigContentType, ConfigDiagnostic, ConfigEntry, ConfigSchemaContribution, ConfigTreeChange,
|
||||
ConfigTreeSnapshot, DECODAL_VERSION, DEFAULT_IMPORT_POLICY_VERSION, DEFAULT_SCHEMA_VERSION,
|
||||
EvaluationResult, SnapshotEnvironment, ToolchainContract, VirtualPath,
|
||||
WorkspaceConfigSchemaBundle,
|
||||
};
|
||||
use rusqlite::{OptionalExtension, TransactionBehavior, params};
|
||||
use serde::{Deserialize, Serialize};
|
||||
@@ -10,7 +11,26 @@ use serde::{Deserialize, Serialize};
|
||||
use crate::{Error, Result, SqliteWorkspaceStore};
|
||||
|
||||
pub const MAIN_CONFIG_ENTRYPOINT: &str = "main.dcdl";
|
||||
pub const DEFAULT_MAIN_CONFIG_SOURCE: &str = "{}\n";
|
||||
pub const DEFAULT_MAIN_CONFIG_SOURCE: &str = "{} as WorkspaceConfigSchema\n";
|
||||
const WORKSPACE_CONFIG_SCHEMA_ASSERTION: &str = "WorkspaceConfigSchema";
|
||||
const MAX_TOOLCHAIN_UPGRADE_DIAGNOSTICS: usize = 20;
|
||||
|
||||
fn toolchain_upgrade_diagnostics(mut diagnostics: Vec<ConfigDiagnostic>) -> Error {
|
||||
let omitted = diagnostics
|
||||
.len()
|
||||
.saturating_sub(MAX_TOOLCHAIN_UPGRADE_DIAGNOSTICS);
|
||||
diagnostics.truncate(MAX_TOOLCHAIN_UPGRADE_DIAGNOSTICS);
|
||||
let rendered = serde_json::to_string(&diagnostics)
|
||||
.unwrap_or_else(|_| "[diagnostics could not be serialized]".to_string());
|
||||
let suffix = if omitted == 0 {
|
||||
String::new()
|
||||
} else {
|
||||
format!("; {omitted} additional diagnostic(s) omitted")
|
||||
};
|
||||
Error::InvalidInput(format!(
|
||||
"workspace configuration is invalid under Decodal {DECODAL_VERSION}: {rendered}{suffix}"
|
||||
))
|
||||
}
|
||||
|
||||
fn main_config_path() -> VirtualPath {
|
||||
VirtualPath::parse(MAIN_CONFIG_ENTRYPOINT).expect("main config entrypoint is a valid path")
|
||||
@@ -45,6 +65,30 @@ impl WorkspaceConfigSchemaRegistry {
|
||||
}
|
||||
}
|
||||
|
||||
pub fn evaluate_workspace_config_state(
|
||||
state: &WorkspaceConfigState,
|
||||
schema_bundle: WorkspaceConfigSchemaBundle,
|
||||
) -> Result<EvaluationResult> {
|
||||
let expected_fingerprint = state.contract.fingerprint.clone();
|
||||
let contract = main_config_contract_with_schema(schema_bundle);
|
||||
if !state.contract.schema_bundle.contributions.is_empty()
|
||||
&& contract.fingerprint != expected_fingerprint
|
||||
{
|
||||
return Err(Error::RegistryInconsistency(
|
||||
"active Workspace config schema fingerprint does not match the current provider bundle"
|
||||
.to_string(),
|
||||
));
|
||||
}
|
||||
SnapshotEnvironment::new(state.snapshot.clone())
|
||||
.evaluate_contract(&contract)
|
||||
.map_err(|diagnostics| {
|
||||
Error::InvalidInput(
|
||||
serde_json::to_string(&diagnostics)
|
||||
.unwrap_or_else(|_| "virtual config evaluation failed".to_string()),
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
fn main_config_contract_with_schema(
|
||||
schema_bundle: WorkspaceConfigSchemaBundle,
|
||||
) -> ToolchainContract {
|
||||
@@ -56,10 +100,6 @@ fn main_config_contract_with_schema(
|
||||
)
|
||||
}
|
||||
|
||||
fn main_config_contract() -> ToolchainContract {
|
||||
main_config_contract_with_schema(WorkspaceConfigSchemaBundle::empty())
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, ts_rs::TS)]
|
||||
#[ts(export)]
|
||||
pub struct WorkspaceConfigState {
|
||||
@@ -68,10 +108,8 @@ pub struct WorkspaceConfigState {
|
||||
pub projection_digest: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, ts_rs::TS)]
|
||||
#[ts(export)]
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
pub struct EvaluatedConfigCandidate {
|
||||
#[ts(type = "number")]
|
||||
pub base_revision: u64,
|
||||
pub base_digest: String,
|
||||
pub snapshot: ConfigTreeSnapshot,
|
||||
@@ -87,23 +125,17 @@ pub struct ConfigCommitRequest {
|
||||
pub base_digest: String,
|
||||
pub changes: Vec<ConfigTreeChange>,
|
||||
pub entrypoints: Vec<VirtualPath>,
|
||||
pub toolchain_fingerprint: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, ts_rs::TS)]
|
||||
#[ts(export)]
|
||||
pub struct ConfigPreviewRequest {
|
||||
pub changes: Vec<ConfigTreeChange>,
|
||||
pub entrypoints: Vec<VirtualPath>,
|
||||
}
|
||||
|
||||
impl SqliteWorkspaceStore {
|
||||
pub fn ensure_workspace_config_materialized(
|
||||
pub fn ensure_workspace_config_materialized_with_schema(
|
||||
&self,
|
||||
workspace_id: &str,
|
||||
materialized_at: &str,
|
||||
schema_bundle: WorkspaceConfigSchemaBundle,
|
||||
) -> Result<WorkspaceConfigState> {
|
||||
self.with_conn_mut(|conn| {
|
||||
let desired_schema = schema_bundle.clone();
|
||||
let (state, requires_toolchain_refresh) = self.with_conn_mut(|conn| {
|
||||
let tx = conn.transaction_with_behavior(TransactionBehavior::Immediate)?;
|
||||
let workspace_exists: bool = tx.query_row(
|
||||
"SELECT EXISTS(SELECT 1 FROM workspaces WHERE workspace_id = ?1)",
|
||||
@@ -113,17 +145,39 @@ impl SqliteWorkspaceStore {
|
||||
if !workspace_exists {
|
||||
return Err(Error::WorkspaceIdMismatch);
|
||||
}
|
||||
let stored_decodal_version = tx
|
||||
.query_row(
|
||||
"SELECT decodal_version FROM workspace_config_trees WHERE workspace_id = ?1",
|
||||
[workspace_id],
|
||||
|row| row.get::<_, String>(0),
|
||||
)
|
||||
.optional()?;
|
||||
let requires_toolchain_refresh = stored_decodal_version
|
||||
.as_deref()
|
||||
.is_some_and(|version| version != DECODAL_VERSION);
|
||||
let state = match load_state(&tx, workspace_id)? {
|
||||
Some(state) => state,
|
||||
None => {
|
||||
let state = initial_state()?;
|
||||
let state = initial_state_with_schema(schema_bundle.clone())?;
|
||||
insert_materialized_state(&tx, workspace_id, &state, materialized_at)?;
|
||||
state
|
||||
}
|
||||
};
|
||||
tx.commit()?;
|
||||
Ok(state)
|
||||
})
|
||||
Ok((state, requires_toolchain_refresh))
|
||||
})?;
|
||||
let main_needs_normalization = state
|
||||
.snapshot
|
||||
.get(&main_config_path())
|
||||
.is_some_and(|entry| !main_config_has_schema_assertion(&entry.content));
|
||||
if requires_toolchain_refresh
|
||||
|| state.contract.schema_bundle.fingerprint != desired_schema.fingerprint
|
||||
|| main_needs_normalization
|
||||
{
|
||||
let candidate = evaluate_candidate(state, &[], desired_schema)?;
|
||||
return self.commit_evaluated_workspace_config(workspace_id, &candidate);
|
||||
}
|
||||
Ok(state)
|
||||
}
|
||||
|
||||
pub fn load_workspace_config(
|
||||
@@ -181,13 +235,6 @@ impl SqliteWorkspaceStore {
|
||||
current.snapshot.revision
|
||||
)));
|
||||
}
|
||||
let expected_contract = main_config_contract_with_schema(schema_bundle.clone());
|
||||
if expected_contract.fingerprint != request.toolchain_fingerprint {
|
||||
return Err(config_conflict(format!(
|
||||
"toolchain fingerprint mismatch; current fingerprint is {}",
|
||||
expected_contract.fingerprint
|
||||
)));
|
||||
}
|
||||
evaluate_candidate(current, &request.changes, schema_bundle)
|
||||
}
|
||||
|
||||
@@ -203,31 +250,6 @@ impl SqliteWorkspaceStore {
|
||||
)
|
||||
}
|
||||
|
||||
pub fn preview_workspace_config_with_schema(
|
||||
&self,
|
||||
workspace_id: &str,
|
||||
request: &ConfigPreviewRequest,
|
||||
schema_bundle: WorkspaceConfigSchemaBundle,
|
||||
) -> Result<EvaluatedConfigCandidate> {
|
||||
validate_entrypoint_request(&request.entrypoints)?;
|
||||
let current = self
|
||||
.load_workspace_config(workspace_id)?
|
||||
.ok_or_else(config_not_materialized)?;
|
||||
evaluate_candidate(current, &request.changes, schema_bundle)
|
||||
}
|
||||
|
||||
pub fn preview_workspace_config(
|
||||
&self,
|
||||
workspace_id: &str,
|
||||
request: &ConfigPreviewRequest,
|
||||
) -> Result<EvaluatedConfigCandidate> {
|
||||
self.preview_workspace_config_with_schema(
|
||||
workspace_id,
|
||||
request,
|
||||
WorkspaceConfigSchemaBundle::empty(),
|
||||
)
|
||||
}
|
||||
|
||||
pub fn commit_evaluated_workspace_config(
|
||||
&self,
|
||||
workspace_id: &str,
|
||||
@@ -345,6 +367,87 @@ impl SqliteWorkspaceStore {
|
||||
}
|
||||
}
|
||||
|
||||
fn main_config_has_schema_assertion(source: &str) -> bool {
|
||||
source.starts_with('{')
|
||||
&& source
|
||||
.trim_end()
|
||||
.ends_with(&format!("}} as {WORKSPACE_CONFIG_SCHEMA_ASSERTION}"))
|
||||
}
|
||||
|
||||
fn normalize_main_config_schema_assertion(
|
||||
snapshot: ConfigTreeSnapshot,
|
||||
) -> Result<ConfigTreeSnapshot> {
|
||||
let main_path = main_config_path();
|
||||
let main = snapshot.get(&main_path).ok_or_else(|| {
|
||||
Error::InvalidInput(format!(
|
||||
"workspace config snapshot must contain {MAIN_CONFIG_ENTRYPOINT}"
|
||||
))
|
||||
})?;
|
||||
if main_config_has_schema_assertion(&main.content) {
|
||||
return Ok(snapshot);
|
||||
}
|
||||
|
||||
let source = main.content.trim();
|
||||
let normalized = if source.starts_with('{')
|
||||
&& source.ends_with(&format!("}} as {WORKSPACE_CONFIG_SCHEMA_ASSERTION}"))
|
||||
{
|
||||
format!("{source}\n")
|
||||
} else if source.starts_with('{') && source.ends_with('}') {
|
||||
format!("{source} as {WORKSPACE_CONFIG_SCHEMA_ASSERTION}\n")
|
||||
} else {
|
||||
return Err(Error::InvalidInput(format!(
|
||||
"{MAIN_CONFIG_ENTRYPOINT} must be a top-level object so it can be asserted as {WORKSPACE_CONFIG_SCHEMA_ASSERTION}"
|
||||
)));
|
||||
};
|
||||
let revision = snapshot.revision;
|
||||
let mut entries = Vec::with_capacity(snapshot.entries.len());
|
||||
for entry in snapshot.entries.into_values() {
|
||||
if entry.path == main_path {
|
||||
entries.push(
|
||||
ConfigEntry::new(entry.path, entry.content_type, normalized.clone())
|
||||
.map_err(config_error)?,
|
||||
);
|
||||
} else {
|
||||
entries.push(entry);
|
||||
}
|
||||
}
|
||||
ConfigTreeSnapshot::from_entries(revision, entries).map_err(config_error)
|
||||
}
|
||||
|
||||
fn format_candidate_sources(
|
||||
snapshot: ConfigTreeSnapshot,
|
||||
changes: &[ConfigTreeChange],
|
||||
) -> Result<ConfigTreeSnapshot> {
|
||||
let mut paths = std::collections::BTreeSet::from([main_config_path()]);
|
||||
for change in changes {
|
||||
match change {
|
||||
ConfigTreeChange::Create { path, .. } | ConfigTreeChange::Update { path, .. } => {
|
||||
paths.insert(path.clone());
|
||||
}
|
||||
ConfigTreeChange::Rename { to, .. } => {
|
||||
paths.insert(to.clone());
|
||||
}
|
||||
ConfigTreeChange::Delete { .. } => {}
|
||||
}
|
||||
}
|
||||
|
||||
let environment = SnapshotEnvironment::new(snapshot.clone());
|
||||
let revision = snapshot.revision;
|
||||
let mut entries = Vec::with_capacity(snapshot.entries.len());
|
||||
for entry in snapshot.entries.into_values() {
|
||||
if paths.contains(&entry.path) && entry.content_type == ConfigContentType::Decodal {
|
||||
let formatted = environment.format(&entry.content).map_err(config_error)?;
|
||||
entries.push(
|
||||
ConfigEntry::new(entry.path, entry.content_type, formatted)
|
||||
.map_err(config_error)?,
|
||||
);
|
||||
} else {
|
||||
entries.push(entry);
|
||||
}
|
||||
}
|
||||
ConfigTreeSnapshot::from_entries(revision, entries).map_err(config_error)
|
||||
}
|
||||
|
||||
fn evaluate_candidate(
|
||||
current: WorkspaceConfigState,
|
||||
changes: &[ConfigTreeChange],
|
||||
@@ -353,6 +456,8 @@ fn evaluate_candidate(
|
||||
reject_main_entrypoint_mutation(changes)?;
|
||||
let snapshot = current.snapshot.apply(changes).map_err(config_error)?;
|
||||
ensure_main_entrypoint(&snapshot)?;
|
||||
let snapshot = normalize_main_config_schema_assertion(snapshot)?;
|
||||
let snapshot = format_candidate_sources(snapshot, changes)?;
|
||||
let contract = main_config_contract_with_schema(schema_bundle);
|
||||
let evaluation = SnapshotEnvironment::new(snapshot.clone())
|
||||
.evaluate_contract(&contract)
|
||||
@@ -478,22 +583,42 @@ pub(crate) fn load_state(
|
||||
}
|
||||
let entrypoints: Vec<VirtualPath> = serde_json::from_str(&entrypoints_json)
|
||||
.map_err(|error| Error::RegistryInconsistency(error.to_string()))?;
|
||||
let schema_bundle = match schema_bundle_json {
|
||||
let stored_schema_bundle: WorkspaceConfigSchemaBundle = match schema_bundle_json {
|
||||
Some(schema_bundle_json) => serde_json::from_str(&schema_bundle_json)
|
||||
.map_err(|error| Error::RegistryInconsistency(error.to_string()))?,
|
||||
None => WorkspaceConfigSchemaBundle::empty(),
|
||||
};
|
||||
let requires_toolchain_refresh = decodal_version != DECODAL_VERSION;
|
||||
if requires_toolchain_refresh && !matches!(decodal_version.as_str(), "0.2.0" | "0.3.0") {
|
||||
return Err(Error::RegistryInconsistency(format!(
|
||||
"unsupported virtual config Decodal version {decodal_version} for Workspace {workspace_id}"
|
||||
)));
|
||||
}
|
||||
let schema_bundle = if requires_toolchain_refresh {
|
||||
WorkspaceConfigSchemaBundle::compose(stored_schema_bundle.contributions)
|
||||
.map_err(config_error)?
|
||||
} else {
|
||||
stored_schema_bundle
|
||||
};
|
||||
let contract = ToolchainContract::with_schema_bundle(
|
||||
schema_version,
|
||||
entrypoints,
|
||||
import_policy_version,
|
||||
schema_bundle,
|
||||
);
|
||||
if decodal_version != DECODAL_VERSION || contract.fingerprint != fingerprint {
|
||||
if !requires_toolchain_refresh && contract.fingerprint != fingerprint {
|
||||
return Err(Error::RegistryInconsistency(format!(
|
||||
"virtual config toolchain metadata mismatch for Workspace {workspace_id}"
|
||||
)));
|
||||
}
|
||||
let projection_digest = if requires_toolchain_refresh {
|
||||
SnapshotEnvironment::new(snapshot.clone())
|
||||
.evaluate_contract(&contract)
|
||||
.map_err(toolchain_upgrade_diagnostics)?
|
||||
.projection_digest
|
||||
} else {
|
||||
projection_digest
|
||||
};
|
||||
Ok(Some(WorkspaceConfigState {
|
||||
snapshot,
|
||||
contract,
|
||||
@@ -502,6 +627,12 @@ pub(crate) fn load_state(
|
||||
}
|
||||
|
||||
pub(crate) fn initial_state() -> Result<WorkspaceConfigState> {
|
||||
initial_state_with_schema(WorkspaceConfigSchemaBundle::empty())
|
||||
}
|
||||
|
||||
pub(crate) fn initial_state_with_schema(
|
||||
schema_bundle: WorkspaceConfigSchemaBundle,
|
||||
) -> Result<WorkspaceConfigState> {
|
||||
let path = main_config_path();
|
||||
let snapshot = ConfigTreeSnapshot::empty()
|
||||
.apply(&[ConfigTreeChange::Create {
|
||||
@@ -510,7 +641,7 @@ pub(crate) fn initial_state() -> Result<WorkspaceConfigState> {
|
||||
content: DEFAULT_MAIN_CONFIG_SOURCE.to_string(),
|
||||
}])
|
||||
.map_err(config_error)?;
|
||||
let contract = main_config_contract();
|
||||
let contract = main_config_contract_with_schema(schema_bundle);
|
||||
let projection_digest = SnapshotEnvironment::new(snapshot.clone())
|
||||
.evaluate_contract(&contract)
|
||||
.map_err(|diagnostics| {
|
||||
@@ -770,7 +901,6 @@ mod tests {
|
||||
base_digest: current.snapshot.digest.clone(),
|
||||
changes,
|
||||
entrypoints: vec![path(MAIN_CONFIG_ENTRYPOINT)],
|
||||
toolchain_fingerprint: current.contract.fingerprint.clone(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -818,7 +948,6 @@ mod tests {
|
||||
content: "{ web = {}; }".to_string(),
|
||||
}],
|
||||
entrypoints: vec![path(MAIN_CONFIG_ENTRYPOINT)],
|
||||
toolchain_fingerprint: expected_contract.fingerprint.clone(),
|
||||
},
|
||||
schema,
|
||||
)
|
||||
@@ -845,32 +974,294 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn commit_rejects_stale_schema_bundle_fingerprint() {
|
||||
let store = open_store().await;
|
||||
let current = store.load_workspace_config("w-config").unwrap().unwrap();
|
||||
let schema = WorkspaceConfigSchemaBundle::compose([ConfigSchemaContribution::new(
|
||||
"builtin:web",
|
||||
"web",
|
||||
"1",
|
||||
"{ web = {}; }",
|
||||
#[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 = store
|
||||
.evaluate_workspace_config_candidate_with_schema(
|
||||
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",
|
||||
&ConfigCommitRequest {
|
||||
base_revision: current.snapshot.revision,
|
||||
base_digest: current.snapshot.digest,
|
||||
changes: Vec::new(),
|
||||
entrypoints: vec![path(MAIN_CONFIG_ENTRYPOINT)],
|
||||
toolchain_fingerprint: current.contract.fingerprint,
|
||||
},
|
||||
schema,
|
||||
"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();
|
||||
assert!(error.to_string().contains("toolchain fingerprint mismatch"));
|
||||
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]
|
||||
@@ -890,6 +1281,103 @@ mod tests {
|
||||
.content,
|
||||
DEFAULT_MAIN_CONFIG_SOURCE
|
||||
);
|
||||
assert!(main_config_has_schema_assertion(DEFAULT_MAIN_CONFIG_SOURCE));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn candidate_normalizes_main_and_formats_changed_decodal_sources() {
|
||||
let store = open_store().await;
|
||||
let current = store.load_workspace_config("w-config").unwrap().unwrap();
|
||||
let candidate = store
|
||||
.evaluate_workspace_config_candidate(
|
||||
"w-config",
|
||||
&commit_request(
|
||||
¤t,
|
||||
vec![
|
||||
update_main(¤t, "{}"),
|
||||
ConfigTreeChange::Create {
|
||||
path: path("module.dcdl"),
|
||||
content_type: ConfigContentType::Decodal,
|
||||
content: "{}".into(),
|
||||
},
|
||||
],
|
||||
),
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(
|
||||
candidate
|
||||
.snapshot
|
||||
.get(&path(MAIN_CONFIG_ENTRYPOINT))
|
||||
.unwrap()
|
||||
.content,
|
||||
DEFAULT_MAIN_CONFIG_SOURCE
|
||||
);
|
||||
assert_eq!(
|
||||
candidate
|
||||
.snapshot
|
||||
.get(&path("module.dcdl"))
|
||||
.unwrap()
|
||||
.content,
|
||||
"{}\n"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn materialization_upgrades_legacy_main_and_preserves_prior_revision() {
|
||||
let store = open_store().await;
|
||||
let current = store.load_workspace_config("w-config").unwrap().unwrap();
|
||||
let legacy_snapshot = ConfigTreeSnapshot::from_entries(
|
||||
current.snapshot.revision + 1,
|
||||
[ConfigEntry::new(
|
||||
path(MAIN_CONFIG_ENTRYPOINT),
|
||||
ConfigContentType::Decodal,
|
||||
"{}",
|
||||
)
|
||||
.unwrap()],
|
||||
)
|
||||
.unwrap();
|
||||
let legacy = WorkspaceConfigState {
|
||||
projection_digest: current.projection_digest.clone(),
|
||||
snapshot: legacy_snapshot,
|
||||
contract: current.contract.clone(),
|
||||
};
|
||||
store
|
||||
.with_conn_mut(|conn| {
|
||||
conn.execute(
|
||||
"DELETE FROM workspace_config_entries WHERE workspace_id = ?1",
|
||||
params!["w-config"],
|
||||
)?;
|
||||
insert_materialized_state(conn, "w-config", &legacy, "2026-08-14T00:00:00Z")
|
||||
})
|
||||
.unwrap();
|
||||
|
||||
let upgraded = store
|
||||
.ensure_workspace_config_materialized_with_schema(
|
||||
"w-config",
|
||||
"2026-08-14T00:00:01Z",
|
||||
current.contract.schema_bundle.clone(),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(upgraded.snapshot.revision, legacy.snapshot.revision + 1);
|
||||
assert_eq!(
|
||||
upgraded
|
||||
.snapshot
|
||||
.get(&path(MAIN_CONFIG_ENTRYPOINT))
|
||||
.unwrap()
|
||||
.content,
|
||||
DEFAULT_MAIN_CONFIG_SOURCE
|
||||
);
|
||||
assert_eq!(
|
||||
store
|
||||
.load_workspace_config_revision("w-config", legacy.snapshot.revision)
|
||||
.unwrap()
|
||||
.unwrap()
|
||||
.get(&path(MAIN_CONFIG_ENTRYPOINT))
|
||||
.unwrap()
|
||||
.content,
|
||||
"{}"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
@@ -909,12 +1397,9 @@ mod tests {
|
||||
},
|
||||
] {
|
||||
let error = store
|
||||
.preview_workspace_config(
|
||||
.evaluate_workspace_config_candidate(
|
||||
"w-config",
|
||||
&ConfigPreviewRequest {
|
||||
changes: vec![change],
|
||||
entrypoints: vec![path(MAIN_CONFIG_ENTRYPOINT)],
|
||||
},
|
||||
&commit_request(¤t, vec![change]),
|
||||
)
|
||||
.unwrap_err();
|
||||
assert!(error.to_string().contains("cannot be"));
|
||||
@@ -924,14 +1409,11 @@ mod tests {
|
||||
#[tokio::test]
|
||||
async fn browser_cannot_replace_server_owned_entrypoint_contract() {
|
||||
let store = open_store().await;
|
||||
let current = store.load_workspace_config("w-config").unwrap().unwrap();
|
||||
let mut request = commit_request(¤t, Vec::new());
|
||||
request.entrypoints = vec![path("other.dcdl")];
|
||||
let error = store
|
||||
.preview_workspace_config(
|
||||
"w-config",
|
||||
&ConfigPreviewRequest {
|
||||
changes: Vec::new(),
|
||||
entrypoints: vec![path("other.dcdl")],
|
||||
},
|
||||
)
|
||||
.evaluate_workspace_config_candidate("w-config", &request)
|
||||
.unwrap_err();
|
||||
assert!(error.to_string().contains("must be exactly [main.dcdl]"));
|
||||
}
|
||||
@@ -954,12 +1436,6 @@ mod tests {
|
||||
content: "{ broken = ; }".into(),
|
||||
}],
|
||||
entrypoints: vec![path(MAIN_CONFIG_ENTRYPOINT)],
|
||||
toolchain_fingerprint: ToolchainContract::new(
|
||||
DEFAULT_SCHEMA_VERSION,
|
||||
vec![path(MAIN_CONFIG_ENTRYPOINT)],
|
||||
DEFAULT_IMPORT_POLICY_VERSION,
|
||||
)
|
||||
.fingerprint,
|
||||
},
|
||||
)
|
||||
.unwrap_err();
|
||||
@@ -1027,7 +1503,6 @@ mod tests {
|
||||
content: "{ answer = 2; }".into(),
|
||||
}],
|
||||
entrypoints: first.contract.entrypoints.clone(),
|
||||
toolchain_fingerprint: first.contract.fingerprint.clone(),
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
@@ -1038,27 +1513,6 @@ mod tests {
|
||||
assert_eq!(revision, first.snapshot);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn commit_rejects_mismatched_toolchain_fingerprint() {
|
||||
let store = SqliteWorkspaceStore::in_memory().unwrap();
|
||||
store.upsert_workspace(&workspace()).await.unwrap();
|
||||
let current = store.load_workspace_config("w-config").unwrap().unwrap();
|
||||
let error = store
|
||||
.evaluate_and_commit_workspace_config(
|
||||
"w-config",
|
||||
&ConfigCommitRequest {
|
||||
base_revision: current.snapshot.revision,
|
||||
base_digest: current.snapshot.digest.clone(),
|
||||
changes: vec![update_main(¤t, "{ answer = 42; }")],
|
||||
entrypoints: vec![path(MAIN_CONFIG_ENTRYPOINT)],
|
||||
toolchain_fingerprint: "sha256:stale-toolchain".into(),
|
||||
},
|
||||
)
|
||||
.unwrap_err();
|
||||
assert!(matches!(error, Error::WorkspaceConfigConflict(_)));
|
||||
assert!(store.load_workspace_config("w-config").unwrap().is_some());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn migration_materializes_main_for_existing_workspace_without_config() {
|
||||
let conn = rusqlite::Connection::open_in_memory().unwrap();
|
||||
@@ -1093,9 +1547,7 @@ mod tests {
|
||||
.join("../../web/workspace/src/lib/workspace/config-source/generated/types");
|
||||
let config = ts_rs::Config::default().with_out_dir(&output);
|
||||
WorkspaceConfigState::export_all(&config).unwrap();
|
||||
EvaluatedConfigCandidate::export_all(&config).unwrap();
|
||||
ConfigCommitRequest::export_all(&config).unwrap();
|
||||
ConfigPreviewRequest::export_all(&config).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -1201,6 +1201,21 @@ impl RuntimeRegistry {
|
||||
_ => {}
|
||||
}
|
||||
let runtime = self.runtime(runtime_id)?;
|
||||
if let Some(bundle) = request.resolved_config_bundle.clone() {
|
||||
let sync = runtime.sync_config_bundle(bundle);
|
||||
if sync.state != WorkerOperationState::Accepted {
|
||||
let message = sync
|
||||
.diagnostics
|
||||
.first()
|
||||
.map(|diagnostic| diagnostic.message.clone())
|
||||
.unwrap_or_else(|| "Runtime rejected the resolved config bundle".to_string());
|
||||
return Err(RuntimeRegistryError::RuntimeOperationFailed {
|
||||
runtime_id: runtime_id.to_string(),
|
||||
code: "worker_config_bundle_sync_rejected".to_string(),
|
||||
message,
|
||||
});
|
||||
}
|
||||
}
|
||||
Ok(runtime.spawn_worker(request))
|
||||
}
|
||||
|
||||
@@ -1960,12 +1975,13 @@ impl WorkspaceWorkerRuntime for EmbeddedWorkerRuntime {
|
||||
}
|
||||
};
|
||||
let workspace_id = workspace_api.workspace_id.clone();
|
||||
let config_bundle = spawn_config_bundle_ref(&request);
|
||||
let create_request = CreateWorkerRequest {
|
||||
idempotency_key,
|
||||
idempotency_fingerprint,
|
||||
profile,
|
||||
display_name: request.requested_worker_name.clone(),
|
||||
config_bundle: None,
|
||||
config_bundle,
|
||||
profile_source,
|
||||
initial_input: initial_worker_input(&request.initial_submit),
|
||||
working_directory_request: request.resolved_working_directory_request.clone(),
|
||||
@@ -3090,12 +3106,13 @@ impl WorkspaceWorkerRuntime for RemoteWorkerRuntime {
|
||||
};
|
||||
}
|
||||
};
|
||||
let config_bundle = spawn_config_bundle_ref(&request);
|
||||
let create = CreateWorkerRequest {
|
||||
idempotency_key,
|
||||
idempotency_fingerprint,
|
||||
profile,
|
||||
display_name: request.requested_worker_name.clone(),
|
||||
config_bundle: None,
|
||||
config_bundle,
|
||||
profile_source,
|
||||
initial_input: initial_worker_input(&request.initial_submit),
|
||||
working_directory_request: request.resolved_working_directory_request.clone(),
|
||||
@@ -3360,6 +3377,16 @@ fn embedded_worker_projection_diagnostics() -> Vec<RuntimeDiagnostic> {
|
||||
)]
|
||||
}
|
||||
|
||||
fn spawn_config_bundle_ref(request: &WorkerSpawnRequest) -> Option<ConfigBundleRef> {
|
||||
request
|
||||
.resolved_config_bundle
|
||||
.as_ref()
|
||||
.map(|bundle| ConfigBundleRef {
|
||||
id: bundle.metadata.id.clone(),
|
||||
digest: bundle.metadata.digest.clone(),
|
||||
})
|
||||
}
|
||||
|
||||
fn profile_source_archive_for_request(
|
||||
request: &WorkerSpawnRequest,
|
||||
profile: &ProfileSelector,
|
||||
@@ -3469,6 +3496,7 @@ fn builtin_profile_config_bundle(
|
||||
label: embedded_profile_label(profile),
|
||||
}],
|
||||
declarations: Vec::new(),
|
||||
prompt_catalog: None,
|
||||
profile_source_archive,
|
||||
profile_source_archive_handle,
|
||||
}
|
||||
@@ -4409,6 +4437,7 @@ mod tests {
|
||||
name: "read".to_string(),
|
||||
reference: "capability:read".to_string(),
|
||||
}],
|
||||
prompt_catalog: None,
|
||||
profile_source_archive: None,
|
||||
profile_source_archive_handle: None,
|
||||
}
|
||||
@@ -4776,6 +4805,46 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn spawn_config_bundle_ref_preserves_bundle_identity() {
|
||||
let mut request = embedded_spawn_request();
|
||||
let bundle = test_config_bundle();
|
||||
let expected_id = bundle.metadata.id.clone();
|
||||
let expected_digest = bundle.metadata.digest.clone();
|
||||
request.resolved_config_bundle = Some(bundle);
|
||||
|
||||
let bundle_ref = spawn_config_bundle_ref(&request).expect("bundle reference");
|
||||
assert_eq!(bundle_ref.id, expected_id);
|
||||
assert_eq!(bundle_ref.digest, expected_digest);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn registry_syncs_bundle_before_embedded_spawn() {
|
||||
let runtime = EmbeddedWorkerRuntime::new_memory_with_execution_backend(
|
||||
"local:test",
|
||||
Arc::new(AcceptingExecutionBackend::default()),
|
||||
)
|
||||
.expect("test backend should connect");
|
||||
let registry = RuntimeRegistry::for_workspace(runtime);
|
||||
let mut request = embedded_spawn_request();
|
||||
let bundle = test_config_bundle();
|
||||
let bundle_ref = ConfigBundleRef {
|
||||
id: bundle.metadata.id.clone(),
|
||||
digest: bundle.metadata.digest.clone(),
|
||||
};
|
||||
request.resolved_config_bundle = Some(bundle);
|
||||
|
||||
let result = registry
|
||||
.spawn_worker("embedded-worker-runtime", request)
|
||||
.expect("spawn request");
|
||||
assert_eq!(result.state, WorkerOperationState::Accepted);
|
||||
let check = registry
|
||||
.check_config_bundle("embedded-worker-runtime", bundle_ref)
|
||||
.expect("bundle check");
|
||||
assert_eq!(check.state, WorkerOperationState::Accepted);
|
||||
assert!(check.availability.is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn embedded_runtime_rejects_missing_workspace_api_binding() {
|
||||
let runtime = EmbeddedWorkerRuntime::new_memory_with_execution_backend(
|
||||
|
||||
@@ -15,6 +15,7 @@ pub mod memory_backend;
|
||||
pub mod memory_staging;
|
||||
pub mod observation;
|
||||
pub mod profile_settings;
|
||||
pub mod prompt_settings;
|
||||
pub mod records;
|
||||
#[cfg(feature = "typescript")]
|
||||
pub use records::ticket_api_typescript;
|
||||
|
||||
@@ -42,11 +42,16 @@ struct WorkspacePathOptions {
|
||||
workspace: PathBuf,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
struct SkillWorkspaceOptions {
|
||||
workspace_id: String,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
enum SkillsCommand {
|
||||
List(WorkspacePathOptions),
|
||||
Lint(WorkspacePathOptions),
|
||||
Show { workspace: PathBuf, name: String },
|
||||
List(SkillWorkspaceOptions),
|
||||
Lint(SkillWorkspaceOptions),
|
||||
Show { workspace_id: String, name: String },
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
@@ -513,11 +518,13 @@ fn ensure_no_inline_value(flag: &str, inline_value: Option<&str>) -> Result<(),
|
||||
fn run_skills(command: SkillsCommand) -> Result<(), Box<dyn std::error::Error>> {
|
||||
match command {
|
||||
SkillsCommand::List(options) => {
|
||||
let catalog = yoi_workspace_server::skills::catalog(&options.workspace);
|
||||
let state = load_skill_workspace_config(&options.workspace_id)?;
|
||||
let catalog = yoi_workspace_server::skills::catalog(&state)?;
|
||||
println!("{}", serde_json::to_string_pretty(&catalog)?);
|
||||
}
|
||||
SkillsCommand::Lint(options) => {
|
||||
let catalog = yoi_workspace_server::skills::lint(&options.workspace);
|
||||
let state = load_skill_workspace_config(&options.workspace_id)?;
|
||||
let catalog = yoi_workspace_server::skills::lint(&state)?;
|
||||
println!("{}", serde_json::to_string_pretty(&catalog)?);
|
||||
if catalog
|
||||
.diagnostics
|
||||
@@ -535,14 +542,26 @@ fn run_skills(command: SkillsCommand) -> Result<(), Box<dyn std::error::Error>>
|
||||
return Err(Box::new(CliError("Skill lint found errors".to_string())));
|
||||
}
|
||||
}
|
||||
SkillsCommand::Show { workspace, name } => {
|
||||
let detail = yoi_workspace_server::skills::detail(&workspace, &name)?;
|
||||
SkillsCommand::Show { workspace_id, name } => {
|
||||
let state = load_skill_workspace_config(&workspace_id)?;
|
||||
let detail = yoi_workspace_server::skills::detail(&state, &name)?;
|
||||
println!("{}", serde_json::to_string_pretty(&detail)?);
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn load_skill_workspace_config(
|
||||
workspace_id: &str,
|
||||
) -> Result<yoi_workspace_server::config_source::WorkspaceConfigState, Box<dyn std::error::Error>> {
|
||||
let store = SqliteWorkspaceStore::open(ServerConfig::default_server_database_path())?;
|
||||
store.load_workspace_config(workspace_id)?.ok_or_else(|| {
|
||||
Box::new(CliError(format!(
|
||||
"Workspace `{workspace_id}` has no active config revision"
|
||||
))) as Box<dyn std::error::Error>
|
||||
})
|
||||
}
|
||||
|
||||
async fn run_serve(options: ServeOptions) -> Result<(), Box<dyn std::error::Error>> {
|
||||
let database_path = ServerConfig::default_server_database_path();
|
||||
if let Some(parent) = database_path.parent() {
|
||||
@@ -706,17 +725,17 @@ fn parse_skills_command(args: &[String]) -> Result<Command, CliError> {
|
||||
};
|
||||
match subcommand.as_str() {
|
||||
"list" => Ok(Command::Skills(SkillsCommand::List(
|
||||
parse_workspace_path_options(rest)?,
|
||||
parse_skill_workspace_options(rest)?,
|
||||
))),
|
||||
"lint" => Ok(Command::Skills(SkillsCommand::Lint(
|
||||
parse_workspace_path_options(rest)?,
|
||||
parse_skill_workspace_options(rest)?,
|
||||
))),
|
||||
"show" => {
|
||||
let Some((name, rest)) = rest.split_first() else {
|
||||
return Err(CliError("skills show requires a Skill name".to_string()));
|
||||
};
|
||||
Ok(Command::Skills(SkillsCommand::Show {
|
||||
workspace: parse_workspace_path_options(rest)?.workspace,
|
||||
workspace_id: parse_skill_workspace_options(rest)?.workspace_id,
|
||||
name: name.to_string(),
|
||||
}))
|
||||
}
|
||||
@@ -730,6 +749,32 @@ fn parse_skills_command(args: &[String]) -> Result<Command, CliError> {
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_skill_workspace_options(args: &[String]) -> Result<SkillWorkspaceOptions, CliError> {
|
||||
let mut workspace_id = None;
|
||||
let mut iter = args.iter();
|
||||
while let Some(arg) = iter.next() {
|
||||
match arg.as_str() {
|
||||
"--workspace" => {
|
||||
let value = iter
|
||||
.next()
|
||||
.ok_or_else(|| CliError("--workspace requires a Workspace id".to_string()))?;
|
||||
workspace_id = Some(value.clone());
|
||||
}
|
||||
value if value.starts_with("--workspace=") => {
|
||||
workspace_id = Some(value_after_equals(arg, "--workspace")?.to_string());
|
||||
}
|
||||
other => return Err(CliError(format!("unknown skills option `{other}`"))),
|
||||
}
|
||||
}
|
||||
let workspace_id = workspace_id.ok_or_else(|| {
|
||||
CliError("skills commands require --workspace <workspace-id>".to_string())
|
||||
})?;
|
||||
if workspace_id.trim().is_empty() {
|
||||
return Err(CliError("--workspace must not be empty".to_string()));
|
||||
}
|
||||
Ok(SkillWorkspaceOptions { workspace_id })
|
||||
}
|
||||
|
||||
fn parse_workspace_path_options(args: &[String]) -> Result<WorkspacePathOptions, CliError> {
|
||||
let mut workspace = std::env::current_dir()
|
||||
.map_err(|error| CliError(format!("failed to read current dir: {error}")))?;
|
||||
@@ -848,7 +893,7 @@ fn print_config_help() {
|
||||
|
||||
fn print_skills_help() {
|
||||
println!(
|
||||
"yoi-server skills\n\nUsage:\n yoi-server skills list [OPTIONS]\n yoi-server skills lint [OPTIONS]\n yoi-server skills show <NAME> [OPTIONS]\n\nDescription:\n Uses the Workspace backend Skill catalog/lint/detail authority. Catalog output is lightweight and omits full SKILL.md bodies; detail output includes the body. allowed-tools and scripts are diagnostics only.\n\nOptions:\n --workspace <PATH> Workspace root (defaults to cwd)\n -h, --help Print help"
|
||||
"yoi-server skills\n\nUsage:\n yoi-server skills list --workspace <WORKSPACE_ID>\n yoi-server skills lint --workspace <WORKSPACE_ID>\n yoi-server skills show <NAME> --workspace <WORKSPACE_ID>\n\nDescription:\n Reads the active Server DB virtual-config revision. Catalog output is lightweight and omits imported Markdown content; detail output includes that content. allowed-tools and scripts are diagnostics only.\n\nOptions:\n --workspace <WORKSPACE_ID> Workspace id in the Server DB (required)\n -h, --help Print help"
|
||||
);
|
||||
}
|
||||
|
||||
@@ -874,6 +919,26 @@ mod tests {
|
||||
assert_eq!(options.workspace, temp.path().canonicalize().unwrap());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_skills_requires_server_workspace_id() {
|
||||
let error = parse_skills_command(&["list".to_string()]).unwrap_err();
|
||||
assert_eq!(
|
||||
error.to_string(),
|
||||
"skills commands require --workspace <workspace-id>"
|
||||
);
|
||||
let command = parse_skills_command(&[
|
||||
"show".to_string(),
|
||||
"debug-rust".to_string(),
|
||||
"--workspace=workspace-a".to_string(),
|
||||
])
|
||||
.unwrap();
|
||||
let Command::Skills(SkillsCommand::Show { workspace_id, name }) = command else {
|
||||
panic!("expected skills show command");
|
||||
};
|
||||
assert_eq!(workspace_id, "workspace-a");
|
||||
assert_eq!(name, "debug-rust");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_serve_accepts_listen_only() {
|
||||
let args = vec!["--listen".to_string(), "127.0.0.1:0".to_string()];
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,194 @@
|
||||
use config_source::{ConfigProjectionValidator, ConfigSchemaContribution};
|
||||
use worker::{EffectivePromptCatalog, prompt_schema_source};
|
||||
|
||||
use crate::config_source::{
|
||||
WorkspaceConfigSchemaProvider, WorkspaceConfigState, evaluate_workspace_config_state,
|
||||
};
|
||||
use crate::{Error, Result};
|
||||
|
||||
#[derive(Debug, Default)]
|
||||
pub struct PromptConfigSchemaProvider;
|
||||
|
||||
impl WorkspaceConfigSchemaProvider for PromptConfigSchemaProvider {
|
||||
fn contribution(&self) -> Result<ConfigSchemaContribution> {
|
||||
ConfigSchemaContribution::new(
|
||||
"builtin:prompts",
|
||||
"prompts",
|
||||
"1",
|
||||
prompt_schema_source().map_err(|error| Error::Config(error.to_string()))?,
|
||||
)
|
||||
.map(|contribution| {
|
||||
contribution.with_projection_validator(
|
||||
ConfigProjectionValidator::StaticTemplateCatalog {
|
||||
namespace: "prompts".to_string(),
|
||||
key_aliases: std::collections::BTreeMap::from([(
|
||||
"default_prompt".to_string(),
|
||||
"default".to_string(),
|
||||
)]),
|
||||
},
|
||||
)
|
||||
})
|
||||
.map_err(|error| Error::Config(error.to_string()))
|
||||
}
|
||||
}
|
||||
|
||||
pub fn validate_evaluated_prompt_catalog(
|
||||
evaluation: &config_source::EvaluationResult,
|
||||
) -> Result<()> {
|
||||
let projection = evaluation.projections.first().ok_or_else(|| {
|
||||
Error::InvalidInput("Workspace config produced no active projection".to_string())
|
||||
})?;
|
||||
let prompts = projection.data_json.get("prompts").ok_or_else(|| {
|
||||
Error::InvalidInput("Workspace config projection has no prompts namespace".to_string())
|
||||
})?;
|
||||
EffectivePromptCatalog::from_projection(prompts, 0, "preview", "preview")
|
||||
.map(|_| ())
|
||||
.map_err(|error| Error::InvalidInput(format!("invalid Prompt catalog: {error}")))
|
||||
}
|
||||
|
||||
pub fn project_prompts_from_workspace_config(
|
||||
state: &WorkspaceConfigState,
|
||||
) -> Result<EffectivePromptCatalog> {
|
||||
let evaluation = evaluate_workspace_config_state(state, state.contract.schema_bundle.clone())?;
|
||||
if evaluation.projection_digest != state.projection_digest {
|
||||
return Err(Error::RegistryInconsistency(
|
||||
"Prompt projection digest does not match the active Workspace config revision"
|
||||
.to_string(),
|
||||
));
|
||||
}
|
||||
let projection = evaluation.projections.first().ok_or_else(|| {
|
||||
Error::RegistryInconsistency("Workspace config has no active projection".to_string())
|
||||
})?;
|
||||
let prompts = projection.data_json.get("prompts").ok_or_else(|| {
|
||||
Error::RegistryInconsistency(
|
||||
"active Workspace config projection has no prompts namespace".to_string(),
|
||||
)
|
||||
})?;
|
||||
EffectivePromptCatalog::from_projection(
|
||||
prompts,
|
||||
state.snapshot.revision,
|
||||
state.contract.schema_bundle.fingerprint.clone(),
|
||||
state.contract.fingerprint.clone(),
|
||||
)
|
||||
.map_err(|error| Error::RegistryInconsistency(error.to_string()))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use config_source::{
|
||||
ConfigContentType, ConfigEntry, ConfigTreeSnapshot, SnapshotEnvironment, ToolchainContract,
|
||||
VirtualPath, WorkspaceConfigSchemaBundle,
|
||||
};
|
||||
|
||||
fn state(source: &str) -> WorkspaceConfigState {
|
||||
let schema = WorkspaceConfigSchemaBundle::compose([PromptConfigSchemaProvider
|
||||
.contribution()
|
||||
.unwrap()])
|
||||
.unwrap();
|
||||
let snapshot = ConfigTreeSnapshot::from_entries(
|
||||
7,
|
||||
[ConfigEntry::new(
|
||||
VirtualPath::parse("main.dcdl").unwrap(),
|
||||
ConfigContentType::Decodal,
|
||||
source,
|
||||
)
|
||||
.unwrap()],
|
||||
)
|
||||
.unwrap();
|
||||
let contract = ToolchainContract::with_schema_bundle(
|
||||
config_source::DEFAULT_SCHEMA_VERSION,
|
||||
vec![VirtualPath::parse("main.dcdl").unwrap()],
|
||||
config_source::DEFAULT_IMPORT_POLICY_VERSION,
|
||||
schema,
|
||||
);
|
||||
let projection_digest = SnapshotEnvironment::new(snapshot.clone())
|
||||
.evaluate_contract(&contract)
|
||||
.unwrap()
|
||||
.projection_digest;
|
||||
WorkspaceConfigState {
|
||||
snapshot,
|
||||
contract,
|
||||
projection_digest,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn workspace_override_deep_patches_builtin_and_preserves_other_leaves() {
|
||||
let state = state(r#"{ prompts = { common = { language = "OVERRIDE"; }; }; }"#);
|
||||
let catalog = project_prompts_from_workspace_config(&state).unwrap();
|
||||
assert_eq!(catalog.config_revision, 7);
|
||||
assert_eq!(catalog.templates["common.language"], "OVERRIDE");
|
||||
assert!(!catalog.templates["common.workspace"].is_empty());
|
||||
assert!(catalog.templates["default"].contains("common.workspace"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn preview_commit_validator_rejects_dynamic_missing_and_cyclic_includes() {
|
||||
let schema = WorkspaceConfigSchemaBundle::compose([PromptConfigSchemaProvider
|
||||
.contribution()
|
||||
.unwrap()])
|
||||
.unwrap();
|
||||
for source in [
|
||||
r#"{ prompts = { common = { language = "{%- include target -%}"; }; }; }"#,
|
||||
r#"{ prompts = { common = { language = "{%- include \"missing\" -%}"; }; }; }"#,
|
||||
r#"{ prompts = { common = { language = "{% include \"common.workspace\" %}"; workspace = "{% include \"common.language\" %}"; }; }; }"#,
|
||||
] {
|
||||
let snapshot = ConfigTreeSnapshot::from_entries(
|
||||
0,
|
||||
[ConfigEntry::new(
|
||||
VirtualPath::parse("main.dcdl").unwrap(),
|
||||
ConfigContentType::Decodal,
|
||||
source,
|
||||
)
|
||||
.unwrap()],
|
||||
)
|
||||
.unwrap();
|
||||
let contract = ToolchainContract::with_schema_bundle(
|
||||
config_source::DEFAULT_SCHEMA_VERSION,
|
||||
vec![VirtualPath::parse("main.dcdl").unwrap()],
|
||||
config_source::DEFAULT_IMPORT_POLICY_VERSION,
|
||||
schema.clone(),
|
||||
);
|
||||
assert!(
|
||||
SnapshotEnvironment::new(snapshot)
|
||||
.evaluate_contract(&contract)
|
||||
.is_err()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn closed_prompt_schema_rejects_unknown_and_non_string_leaves() {
|
||||
let schema = WorkspaceConfigSchemaBundle::compose([PromptConfigSchemaProvider
|
||||
.contribution()
|
||||
.unwrap()])
|
||||
.unwrap();
|
||||
for source in [
|
||||
"{ prompts = { common = { unknown = \"bad\"; }; }; }",
|
||||
"{ prompts = { common = { language = 42; }; }; }",
|
||||
] {
|
||||
let snapshot = ConfigTreeSnapshot::from_entries(
|
||||
0,
|
||||
[ConfigEntry::new(
|
||||
VirtualPath::parse("main.dcdl").unwrap(),
|
||||
ConfigContentType::Decodal,
|
||||
source,
|
||||
)
|
||||
.unwrap()],
|
||||
)
|
||||
.unwrap();
|
||||
let contract = ToolchainContract::with_schema_bundle(
|
||||
config_source::DEFAULT_SCHEMA_VERSION,
|
||||
vec![VirtualPath::parse("main.dcdl").unwrap()],
|
||||
config_source::DEFAULT_IMPORT_POLICY_VERSION,
|
||||
schema.clone(),
|
||||
);
|
||||
assert!(
|
||||
SnapshotEnvironment::new(snapshot)
|
||||
.evaluate_contract(&contract)
|
||||
.is_err()
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -62,7 +62,7 @@ use crate::companion::{
|
||||
CompanionStatusResponse, CompanionTranscriptProjection,
|
||||
};
|
||||
use crate::config::{BackendRuntimesConfigFile, RemoteRuntimeConfigFile, resolve_remote_runtime};
|
||||
use crate::config_source::{ConfigCommitRequest, ConfigPreviewRequest};
|
||||
use crate::config_source::ConfigCommitRequest;
|
||||
use crate::hosts::{
|
||||
ConfigBundleCheckResult, ConfigBundleSyncResult, DiagnosticSeverity, EMBEDDED_RUNTIME_ID,
|
||||
EmbeddedWorkerRuntime, HostSummary, RemoteRuntimeConfig, RemoteWorkerRuntime,
|
||||
@@ -84,12 +84,7 @@ use crate::observation::{
|
||||
BackendObservationProxy, ObservationProxyError, RuntimeObservationClient,
|
||||
RuntimeObservationSource, RuntimeObservationSourceConfig,
|
||||
};
|
||||
use crate::profile_settings::{
|
||||
CreateWorkspaceProfileSourceRequest, DeleteWorkspaceProfileSourceRequest,
|
||||
DeleteWorkspaceProfileTreeFileRequest, ReadWorkspaceProfileTreeFileQuery,
|
||||
UpdateWorkspaceMetadataRequest, UpdateWorkspaceProfileRegistryRequest,
|
||||
UpdateWorkspaceProfileSourceRequest, WriteWorkspaceProfileTreeFileRequest,
|
||||
};
|
||||
use crate::profile_settings::UpdateWorkspaceMetadataRequest;
|
||||
use crate::records::{ObjectiveDetail, ProjectRecordList, TicketDetail};
|
||||
use crate::repositories::{
|
||||
ConfiguredRepository, RepositoryListProjection, RepositoryLogRead, RepositoryLookupError,
|
||||
@@ -245,10 +240,7 @@ impl ServerConfig {
|
||||
}
|
||||
|
||||
const ORCHESTRATOR_ATTENTION_TICKET_LIMIT: usize = 20;
|
||||
const ORCHESTRATOR_ATTENTION_PROMPT: &str = include_str!(concat!(
|
||||
env!("CARGO_MANIFEST_DIR"),
|
||||
"/../../resources/prompts/internal/workspace_orchestrator_queue_attention.md"
|
||||
));
|
||||
const ORCHESTRATOR_ATTENTION_PROMPT_NAME: &str = "internal.workspace_orchestrator_queue_attention";
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct WorkspaceApi {
|
||||
@@ -756,9 +748,20 @@ impl WorkspaceApi {
|
||||
let config_store = Arc::new(crate::SqliteWorkspaceStore::open(
|
||||
config.database_path.clone(),
|
||||
)?);
|
||||
let config_schema_registry = crate::config_source::WorkspaceConfigSchemaRegistry::default()
|
||||
.with_provider(Arc::new(
|
||||
crate::profile_settings::ProfileConfigSchemaProvider,
|
||||
))
|
||||
.with_provider(Arc::new(crate::prompt_settings::PromptConfigSchemaProvider))
|
||||
.with_provider(Arc::new(skills::SkillConfigSchemaProvider));
|
||||
config_store.ensure_workspace_config_materialized_with_schema(
|
||||
&config.workspace_id,
|
||||
&Utc::now().to_rfc3339_opts(SecondsFormat::Secs, true),
|
||||
config_schema_registry.compose()?,
|
||||
)?;
|
||||
let api = Self {
|
||||
config_store,
|
||||
config_schema_registry: crate::config_source::WorkspaceConfigSchemaRegistry::default(),
|
||||
config_schema_registry,
|
||||
authority: SqliteWorkspaceAuthority::new(
|
||||
config.database_path.clone(),
|
||||
config.workspace_id.clone(),
|
||||
@@ -1153,10 +1156,6 @@ pub fn build_router(api: WorkspaceApi) -> Router {
|
||||
"/api/w/{workspace_id}/config/source-tree",
|
||||
get(scoped_get_workspace_config_tree),
|
||||
)
|
||||
.route(
|
||||
"/api/w/{workspace_id}/config/source-tree/preview",
|
||||
post(scoped_preview_workspace_config_tree),
|
||||
)
|
||||
.route(
|
||||
"/api/w/{workspace_id}/config/source-tree/commit",
|
||||
post(scoped_commit_workspace_config_tree),
|
||||
@@ -1171,27 +1170,7 @@ pub fn build_router(api: WorkspaceApi) -> Router {
|
||||
)
|
||||
.route(
|
||||
"/api/w/{workspace_id}/settings/profiles",
|
||||
get(scoped_get_profile_settings).post(scoped_create_profile_source),
|
||||
)
|
||||
.route(
|
||||
"/api/w/{workspace_id}/settings/profiles/registry",
|
||||
put(scoped_update_profile_registry),
|
||||
)
|
||||
.route(
|
||||
"/api/w/{workspace_id}/settings/profiles/trees/{source_tree_id}",
|
||||
get(scoped_get_profile_source_tree),
|
||||
)
|
||||
.route(
|
||||
"/api/w/{workspace_id}/settings/profiles/trees/{source_tree_id}/file",
|
||||
get(scoped_get_profile_tree_file)
|
||||
.put(scoped_write_profile_tree_file)
|
||||
.delete(scoped_delete_profile_tree_file),
|
||||
)
|
||||
.route(
|
||||
"/api/w/{workspace_id}/settings/profiles/{profile_source_id}",
|
||||
get(scoped_get_profile_source)
|
||||
.put(scoped_update_profile_source)
|
||||
.delete(scoped_delete_profile_source),
|
||||
get(scoped_get_profile_settings),
|
||||
)
|
||||
.route(
|
||||
"/api/w/{workspace_id}/flows",
|
||||
@@ -2531,21 +2510,6 @@ async fn scoped_get_workspace_config_entry(
|
||||
Ok(Json(entry))
|
||||
}
|
||||
|
||||
async fn scoped_preview_workspace_config_tree(
|
||||
State(api): State<WorkspaceApi>,
|
||||
AxumPath(path): AxumPath<ScopedWorkspacePath>,
|
||||
Json(request): Json<ConfigPreviewRequest>,
|
||||
) -> ApiResult<Json<crate::config_source::EvaluatedConfigCandidate>> {
|
||||
validate_workspace_scope(&api, &path.workspace_id)?;
|
||||
Ok(Json(
|
||||
api.config_store.preview_workspace_config_with_schema(
|
||||
&path.workspace_id,
|
||||
&request,
|
||||
api.config_schema_registry.compose()?,
|
||||
)?,
|
||||
))
|
||||
}
|
||||
|
||||
async fn scoped_commit_workspace_config_tree(
|
||||
State(api): State<WorkspaceApi>,
|
||||
AxumPath(path): AxumPath<ScopedWorkspacePath>,
|
||||
@@ -2559,6 +2523,7 @@ async fn scoped_commit_workspace_config_tree(
|
||||
&request,
|
||||
api.config_schema_registry.compose()?,
|
||||
)?;
|
||||
crate::prompt_settings::validate_evaluated_prompt_catalog(&candidate.evaluation)?;
|
||||
let state = api
|
||||
.config_store
|
||||
.commit_evaluated_workspace_config(&path.workspace_id, &candidate)?;
|
||||
@@ -2577,130 +2542,19 @@ async fn scoped_get_profile_settings(
|
||||
AxumPath(path): AxumPath<ScopedWorkspacePath>,
|
||||
) -> ApiResult<Json<crate::profile_settings::ProfileSettingsResponse>> {
|
||||
validate_workspace_scope(&api, &path.workspace_id)?;
|
||||
Ok(Json(crate::profile_settings::load_profile_settings(
|
||||
&api.config.workspace_id,
|
||||
&api.config.workspace_root,
|
||||
)))
|
||||
}
|
||||
|
||||
async fn scoped_create_profile_source(
|
||||
State(api): State<WorkspaceApi>,
|
||||
AxumPath(path): AxumPath<ScopedWorkspacePath>,
|
||||
Json(request): Json<CreateWorkspaceProfileSourceRequest>,
|
||||
) -> ApiResult<Json<crate::profile_settings::ProfileSettingsMutationResponse>> {
|
||||
validate_workspace_scope(&api, &path.workspace_id)?;
|
||||
Ok(Json(crate::profile_settings::create_profile_source(
|
||||
&api.config.workspace_id,
|
||||
&api.config.workspace_root,
|
||||
request,
|
||||
)?))
|
||||
}
|
||||
|
||||
async fn scoped_update_profile_registry(
|
||||
State(api): State<WorkspaceApi>,
|
||||
AxumPath(path): AxumPath<ScopedWorkspacePath>,
|
||||
Json(request): Json<UpdateWorkspaceProfileRegistryRequest>,
|
||||
) -> ApiResult<Json<crate::profile_settings::ProfileSettingsMutationResponse>> {
|
||||
validate_workspace_scope(&api, &path.workspace_id)?;
|
||||
Ok(Json(crate::profile_settings::update_profile_registry(
|
||||
&api.config.workspace_id,
|
||||
&api.config.workspace_root,
|
||||
request,
|
||||
)?))
|
||||
}
|
||||
|
||||
async fn scoped_get_profile_source_tree(
|
||||
State(api): State<WorkspaceApi>,
|
||||
AxumPath((workspace_id, source_tree_id)): AxumPath<(String, String)>,
|
||||
) -> ApiResult<Json<crate::profile_settings::WorkspaceProfileSourceTreeResponse>> {
|
||||
validate_workspace_scope(&api, &workspace_id)?;
|
||||
Ok(Json(crate::profile_settings::read_profile_source_tree(
|
||||
&workspace_id,
|
||||
&api.config.workspace_root,
|
||||
&source_tree_id,
|
||||
)?))
|
||||
}
|
||||
|
||||
async fn scoped_get_profile_tree_file(
|
||||
State(api): State<WorkspaceApi>,
|
||||
AxumPath((workspace_id, source_tree_id)): AxumPath<(String, String)>,
|
||||
Query(query): Query<ReadWorkspaceProfileTreeFileQuery>,
|
||||
) -> ApiResult<Json<crate::profile_settings::WorkspaceProfileSourceTreeFileResponse>> {
|
||||
validate_workspace_scope(&api, &workspace_id)?;
|
||||
Ok(Json(crate::profile_settings::read_profile_tree_file(
|
||||
&workspace_id,
|
||||
&api.config.workspace_root,
|
||||
&source_tree_id,
|
||||
query,
|
||||
)?))
|
||||
}
|
||||
|
||||
async fn scoped_write_profile_tree_file(
|
||||
State(api): State<WorkspaceApi>,
|
||||
AxumPath((workspace_id, source_tree_id)): AxumPath<(String, String)>,
|
||||
Json(request): Json<WriteWorkspaceProfileTreeFileRequest>,
|
||||
) -> ApiResult<Json<crate::profile_settings::WorkspaceProfileSourceTreeFileResponse>> {
|
||||
validate_workspace_scope(&api, &workspace_id)?;
|
||||
Ok(Json(crate::profile_settings::write_profile_tree_file(
|
||||
&workspace_id,
|
||||
&api.config.workspace_root,
|
||||
&source_tree_id,
|
||||
request,
|
||||
)?))
|
||||
}
|
||||
|
||||
async fn scoped_delete_profile_tree_file(
|
||||
State(api): State<WorkspaceApi>,
|
||||
AxumPath((workspace_id, source_tree_id)): AxumPath<(String, String)>,
|
||||
Json(request): Json<DeleteWorkspaceProfileTreeFileRequest>,
|
||||
) -> ApiResult<Json<crate::profile_settings::WorkspaceProfileSourceTreeResponse>> {
|
||||
validate_workspace_scope(&api, &workspace_id)?;
|
||||
Ok(Json(crate::profile_settings::delete_profile_tree_file(
|
||||
&workspace_id,
|
||||
&api.config.workspace_root,
|
||||
&source_tree_id,
|
||||
request,
|
||||
)?))
|
||||
}
|
||||
|
||||
async fn scoped_get_profile_source(
|
||||
State(api): State<WorkspaceApi>,
|
||||
AxumPath((workspace_id, profile_source_id)): AxumPath<(String, String)>,
|
||||
) -> ApiResult<Json<crate::profile_settings::WorkspaceProfileSourceDetailResponse>> {
|
||||
validate_workspace_scope(&api, &workspace_id)?;
|
||||
Ok(Json(crate::profile_settings::read_profile_source(
|
||||
&api.config.workspace_id,
|
||||
&api.config.workspace_root,
|
||||
&profile_source_id,
|
||||
)?))
|
||||
}
|
||||
|
||||
async fn scoped_update_profile_source(
|
||||
State(api): State<WorkspaceApi>,
|
||||
AxumPath((workspace_id, profile_source_id)): AxumPath<(String, String)>,
|
||||
Json(request): Json<UpdateWorkspaceProfileSourceRequest>,
|
||||
) -> ApiResult<Json<crate::profile_settings::ProfileSettingsMutationResponse>> {
|
||||
validate_workspace_scope(&api, &workspace_id)?;
|
||||
Ok(Json(crate::profile_settings::update_profile_source(
|
||||
&api.config.workspace_id,
|
||||
&api.config.workspace_root,
|
||||
&profile_source_id,
|
||||
request,
|
||||
)?))
|
||||
}
|
||||
|
||||
async fn scoped_delete_profile_source(
|
||||
State(api): State<WorkspaceApi>,
|
||||
AxumPath((workspace_id, profile_source_id)): AxumPath<(String, String)>,
|
||||
Json(request): Json<DeleteWorkspaceProfileSourceRequest>,
|
||||
) -> ApiResult<Json<crate::profile_settings::ProfileSettingsMutationResponse>> {
|
||||
validate_workspace_scope(&api, &workspace_id)?;
|
||||
Ok(Json(crate::profile_settings::delete_profile_source(
|
||||
&api.config.workspace_id,
|
||||
&api.config.workspace_root,
|
||||
&profile_source_id,
|
||||
request,
|
||||
)?))
|
||||
let state = api
|
||||
.config_store
|
||||
.load_workspace_config(&path.workspace_id)?
|
||||
.ok_or_else(|| {
|
||||
ApiError::from(Error::InvalidRecordId("virtual config source tree".into()))
|
||||
})?;
|
||||
Ok(Json(
|
||||
crate::profile_settings::project_profiles_from_workspace_config(
|
||||
&path.workspace_id,
|
||||
&state,
|
||||
)?
|
||||
.settings,
|
||||
))
|
||||
}
|
||||
|
||||
async fn scoped_list_tickets(
|
||||
@@ -4935,10 +4789,31 @@ fn dispatch_orchestrator_queue_attention(api: &WorkspaceApi) {
|
||||
} else {
|
||||
format!("Additional queued Tickets omitted from this notice: {omitted}\n")
|
||||
};
|
||||
let content = ORCHESTRATOR_ATTENTION_PROMPT
|
||||
.replace("{{omitted_line}}", &omitted_line)
|
||||
.replace("{{workspace_id}}", &api.config.workspace_id)
|
||||
.replace("{{ticket_lines}}", &shown);
|
||||
let Ok(Some(config_state)) = api
|
||||
.config_store
|
||||
.load_workspace_config(&api.config.workspace_id)
|
||||
else {
|
||||
return;
|
||||
};
|
||||
let Ok(projection) =
|
||||
crate::prompt_settings::project_prompts_from_workspace_config(&config_state)
|
||||
else {
|
||||
return;
|
||||
};
|
||||
let Ok(catalog) = worker::PromptCatalog::from_projection(projection) else {
|
||||
return;
|
||||
};
|
||||
let content = match catalog.render_serializable(
|
||||
ORCHESTRATOR_ATTENTION_PROMPT_NAME,
|
||||
&BTreeMap::from([
|
||||
("omitted_line", omitted_line.as_str()),
|
||||
("workspace_id", api.config.workspace_id.as_str()),
|
||||
("ticket_lines", shown.as_str()),
|
||||
]),
|
||||
) {
|
||||
Ok(content) => content,
|
||||
Err(_) => return,
|
||||
};
|
||||
let accepted = api
|
||||
.runtime
|
||||
.send_input(
|
||||
@@ -5134,12 +5009,7 @@ fn start_memory_staging_consolidation(
|
||||
}
|
||||
|
||||
let profile_selector = ProfileSelector::Builtin(MEMORY_CONSOLIDATION_PROFILE.to_string());
|
||||
let resolved_config_bundle = crate::profile_settings::build_workspace_profile_config_bundle(
|
||||
&api.config.workspace_root,
|
||||
&api.config.workspace_id,
|
||||
&api.config.workspace_created_at,
|
||||
MEMORY_CONSOLIDATION_PROFILE,
|
||||
)?;
|
||||
let resolved_config_bundle = None;
|
||||
let initial_submit = vec![Segment::text(input_content)];
|
||||
let result = api.spawn_workspace_worker(
|
||||
&runtime_id,
|
||||
@@ -5300,7 +5170,16 @@ async fn scoped_list_skills(
|
||||
AxumPath(path): AxumPath<ScopedWorkspacePath>,
|
||||
) -> ApiResult<Json<worker::skill::SkillCatalogResponse>> {
|
||||
validate_workspace_scope(&api, &path.workspace_id)?;
|
||||
Ok(Json(skills::catalog(&api.config.workspace_root)))
|
||||
let state = api
|
||||
.config_store
|
||||
.load_workspace_config(&path.workspace_id)?
|
||||
.ok_or_else(|| {
|
||||
Error::RegistryInconsistency(format!(
|
||||
"Workspace {} has no active config revision",
|
||||
path.workspace_id
|
||||
))
|
||||
})?;
|
||||
skills::catalog(&state).map(Json).map_err(skill_api_error)
|
||||
}
|
||||
|
||||
async fn scoped_lint_skills(
|
||||
@@ -5308,7 +5187,16 @@ async fn scoped_lint_skills(
|
||||
AxumPath(path): AxumPath<ScopedWorkspacePath>,
|
||||
) -> ApiResult<Json<worker::skill::SkillCatalogResponse>> {
|
||||
validate_workspace_scope(&api, &path.workspace_id)?;
|
||||
Ok(Json(skills::lint(&api.config.workspace_root)))
|
||||
let state = api
|
||||
.config_store
|
||||
.load_workspace_config(&path.workspace_id)?
|
||||
.ok_or_else(|| {
|
||||
Error::RegistryInconsistency(format!(
|
||||
"Workspace {} has no active config revision",
|
||||
path.workspace_id
|
||||
))
|
||||
})?;
|
||||
skills::lint(&state).map(Json).map_err(skill_api_error)
|
||||
}
|
||||
|
||||
async fn scoped_get_skill(
|
||||
@@ -5316,7 +5204,16 @@ async fn scoped_get_skill(
|
||||
AxumPath(path): AxumPath<ScopedSkillPath>,
|
||||
) -> ApiResult<Json<worker::skill::SkillDetailResponse>> {
|
||||
validate_workspace_scope(&api, &path.workspace_id)?;
|
||||
skills::detail(&api.config.workspace_root, &path.name)
|
||||
let state = api
|
||||
.config_store
|
||||
.load_workspace_config(&path.workspace_id)?
|
||||
.ok_or_else(|| {
|
||||
Error::RegistryInconsistency(format!(
|
||||
"Workspace {} has no active config revision",
|
||||
path.workspace_id
|
||||
))
|
||||
})?;
|
||||
skills::detail(&state, &path.name)
|
||||
.map(Json)
|
||||
.map_err(skill_api_error)
|
||||
}
|
||||
@@ -5326,7 +5223,16 @@ async fn scoped_activate_skill(
|
||||
AxumPath(path): AxumPath<ScopedSkillPath>,
|
||||
) -> ApiResult<Json<worker::skill::SkillActivationResponse>> {
|
||||
validate_workspace_scope(&api, &path.workspace_id)?;
|
||||
skills::activation(&api.config.workspace_root, &path.name)
|
||||
let state = api
|
||||
.config_store
|
||||
.load_workspace_config(&path.workspace_id)?
|
||||
.ok_or_else(|| {
|
||||
Error::RegistryInconsistency(format!(
|
||||
"Workspace {} has no active config revision",
|
||||
path.workspace_id
|
||||
))
|
||||
})?;
|
||||
skills::activation(&state, &path.name)
|
||||
.map(Json)
|
||||
.map_err(skill_api_error)
|
||||
}
|
||||
@@ -5345,7 +5251,14 @@ fn skill_api_error(error: skills::SkillError) -> ApiError {
|
||||
message: format!("unknown Skill `{name}`"),
|
||||
}],
|
||||
),
|
||||
skills::SkillError::Io(error) => ApiError::from(Error::Io(error)),
|
||||
skills::SkillError::InvalidSkill(name) => ApiError::from(Error::InvalidInput(format!(
|
||||
"Skill `{name}` has blocking diagnostics"
|
||||
))),
|
||||
error => ApiError::from(Error::RuntimeOperationFailed {
|
||||
runtime_id: "workspace".to_string(),
|
||||
code: "skill_projection_failed".to_string(),
|
||||
message: error.to_string(),
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8142,7 +8055,7 @@ async fn test_remote_runtime_connection(
|
||||
async fn get_worker_launch_options(
|
||||
State(api): State<WorkspaceApi>,
|
||||
) -> ApiResult<Json<WorkerLaunchOptionsResponse>> {
|
||||
Ok(Json(worker_launch_options_response(&api)))
|
||||
Ok(Json(worker_launch_options_response(&api)?))
|
||||
}
|
||||
|
||||
fn working_directory_request_from_repository(
|
||||
@@ -8298,18 +8211,20 @@ async fn create_workspace_worker(
|
||||
initial_submit,
|
||||
working_directory,
|
||||
} = request;
|
||||
let config_state = api
|
||||
.config_store
|
||||
.load_workspace_config(&api.config.workspace_id)?
|
||||
.ok_or_else(|| Error::InvalidRecordId("virtual config source tree".into()))?;
|
||||
let profile_projection = crate::profile_settings::project_profiles_from_workspace_config(
|
||||
&api.config.workspace_id,
|
||||
&config_state,
|
||||
)?;
|
||||
let profile = profile
|
||||
.as_deref()
|
||||
.map(str::trim)
|
||||
.filter(|profile| !profile.is_empty())
|
||||
.map(ToOwned::to_owned)
|
||||
.or_else(|| {
|
||||
crate::profile_settings::load_profile_settings(
|
||||
&api.config.workspace_id,
|
||||
&api.config.workspace_root,
|
||||
)
|
||||
.default_profile
|
||||
})
|
||||
.or_else(|| profile_projection.settings.default_profile.clone())
|
||||
.ok_or_else(|| {
|
||||
settings_bad_request(
|
||||
"workspace_default_profile_missing",
|
||||
@@ -8317,24 +8232,20 @@ async fn create_workspace_worker(
|
||||
)
|
||||
})?;
|
||||
let profile_selector =
|
||||
profile_selector_for_candidate_with_root(&api.config.workspace_root, &profile).ok_or_else(
|
||||
|| {
|
||||
crate::profile_settings::selector_for_workspace_candidate(&profile_projection, &profile)
|
||||
.ok_or_else(|| {
|
||||
settings_bad_request(
|
||||
"unsupported_worker_profile",
|
||||
"profile must be selected from Backend-published worker profile candidates",
|
||||
)
|
||||
},
|
||||
)?;
|
||||
let resolved_config_bundle = if profile.starts_with("project:") {
|
||||
crate::profile_settings::build_workspace_profile_config_bundle(
|
||||
&api.config.workspace_root,
|
||||
&api.config.workspace_id,
|
||||
&api.config.workspace_created_at,
|
||||
&profile,
|
||||
)?
|
||||
} else {
|
||||
None
|
||||
};
|
||||
})?;
|
||||
let resolved_config_bundle = crate::profile_settings::build_virtual_profile_config_bundle(
|
||||
&profile_projection,
|
||||
&config_state,
|
||||
&api.config.workspace_id,
|
||||
&api.config.workspace_created_at,
|
||||
&profile,
|
||||
)?;
|
||||
let display_name = sanitize_worker_display_name(&display_name).ok_or_else(|| {
|
||||
settings_bad_request(
|
||||
"invalid_worker_display_name",
|
||||
@@ -10437,7 +10348,7 @@ async fn probe_remote_json(
|
||||
})
|
||||
}
|
||||
|
||||
fn worker_launch_options_response(api: &WorkspaceApi) -> WorkerLaunchOptionsResponse {
|
||||
fn worker_launch_options_response(api: &WorkspaceApi) -> ApiResult<WorkerLaunchOptionsResponse> {
|
||||
let runtimes = api
|
||||
.runtime
|
||||
.list_runtimes(api.config.max_records.min(200))
|
||||
@@ -10456,10 +10367,17 @@ fn worker_launch_options_response(api: &WorkspaceApi) -> WorkerLaunchOptionsResp
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
let profile_settings = crate::profile_settings::load_profile_settings(
|
||||
let config_state = api
|
||||
.config_store
|
||||
.load_workspace_config(&api.config.workspace_id)?
|
||||
.ok_or_else(|| {
|
||||
ApiError::from(Error::InvalidRecordId("virtual config source tree".into()))
|
||||
})?;
|
||||
let profile_settings = crate::profile_settings::project_profiles_from_workspace_config(
|
||||
&api.config.workspace_id,
|
||||
&api.config.workspace_root,
|
||||
);
|
||||
&config_state,
|
||||
)?
|
||||
.settings;
|
||||
let profiles = profile_settings
|
||||
.profiles
|
||||
.into_iter()
|
||||
@@ -10477,7 +10395,7 @@ fn worker_launch_options_response(api: &WorkspaceApi) -> WorkerLaunchOptionsResp
|
||||
.unwrap_or_else(|| "Workspace profile.".to_string()),
|
||||
})
|
||||
.collect();
|
||||
WorkerLaunchOptionsResponse {
|
||||
Ok(WorkerLaunchOptionsResponse {
|
||||
workspace_id: api.config.workspace_id.clone(),
|
||||
runtimes,
|
||||
default_profile: profile_settings.default_profile,
|
||||
@@ -10485,7 +10403,7 @@ fn worker_launch_options_response(api: &WorkspaceApi) -> WorkerLaunchOptionsResp
|
||||
repositories: working_directory_repository_options(api),
|
||||
working_directories: available_working_directory_summaries(api).unwrap_or_default(),
|
||||
diagnostics: Vec::new(),
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
fn working_directory_repository_options(
|
||||
@@ -11069,27 +10987,6 @@ fn working_directory_request_for_browser(
|
||||
})
|
||||
}
|
||||
|
||||
fn profile_selector_for_candidate_with_root(
|
||||
workspace_root: &Path,
|
||||
profile: &str,
|
||||
) -> Option<ProfileSelector> {
|
||||
if let Some(selector @ ProfileSelector::Builtin(_)) =
|
||||
crate::profile_settings::selector_for_builtin_candidate(profile)
|
||||
{
|
||||
return Some(selector);
|
||||
}
|
||||
crate::profile_settings::project_profile_candidates(workspace_root)
|
||||
.into_iter()
|
||||
.find(|candidate| {
|
||||
candidate.profile_id == profile
|
||||
&& !candidate
|
||||
.diagnostics
|
||||
.iter()
|
||||
.any(|diagnostic| diagnostic.severity == DiagnosticSeverity::Error)
|
||||
})
|
||||
.and_then(|_| crate::profile_settings::selector_for_builtin_candidate(profile))
|
||||
}
|
||||
|
||||
fn parse_runtime_worker_id_for_registry(worker_id: &str) -> ApiResult<u64> {
|
||||
worker_id.parse::<u64>().map_err(|_| {
|
||||
settings_bad_request(
|
||||
@@ -12776,93 +12673,6 @@ mod tests {
|
||||
assert!(!serialized.contains("materialized_path"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn worker_profile_candidates_are_backend_published_and_mapped() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
fs::create_dir_all(dir.path().join(".yoi")).unwrap();
|
||||
fs::write(
|
||||
dir.path().join(".yoi/profiles.toml"),
|
||||
"default = \"builtin:companion\"\n",
|
||||
)
|
||||
.unwrap();
|
||||
let settings = crate::profile_settings::load_profile_settings("workspace-test", dir.path());
|
||||
for expected in [
|
||||
"builtin:companion",
|
||||
"builtin:intake",
|
||||
"builtin:orchestrator",
|
||||
"builtin:coder",
|
||||
"builtin:reviewer",
|
||||
] {
|
||||
assert!(
|
||||
settings
|
||||
.profiles
|
||||
.iter()
|
||||
.any(|profile| profile.profile_id == expected),
|
||||
"missing {expected}"
|
||||
);
|
||||
}
|
||||
assert!(
|
||||
settings
|
||||
.profiles
|
||||
.iter()
|
||||
.all(|profile| profile.profile_id != "builtin:default")
|
||||
);
|
||||
assert_eq!(
|
||||
settings.default_profile.as_deref(),
|
||||
Some("builtin:companion")
|
||||
);
|
||||
assert!(matches!(
|
||||
profile_selector_for_candidate_with_root(dir.path(), "builtin:coder"),
|
||||
Some(ProfileSelector::Builtin(value)) if value == "builtin:coder"
|
||||
));
|
||||
assert!(
|
||||
profile_selector_for_candidate_with_root(dir.path(), "free-text-profile").is_none()
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn worker_launch_options_publish_every_valid_workspace_profile_and_default() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
fs::create_dir_all(dir.path().join(".yoi/profiles")).unwrap();
|
||||
fs::write(
|
||||
dir.path().join(".yoi/profiles.toml"),
|
||||
concat!(
|
||||
"default = \"builtin:companion\"\n",
|
||||
"[profile.custom]\n",
|
||||
"path = \".yoi/profiles/custom.dcdl\"\n",
|
||||
),
|
||||
)
|
||||
.unwrap();
|
||||
fs::write(
|
||||
dir.path().join(".yoi/profiles/custom.dcdl"),
|
||||
"slug = \"custom\"; description = \"Custom profile\";\n",
|
||||
)
|
||||
.unwrap();
|
||||
let api = test_app(dir.path()).await;
|
||||
|
||||
let response = get_json(api, "/api/workers/launch-options").await;
|
||||
assert_eq!(response["default_profile"], "builtin:companion");
|
||||
let profiles = response["profiles"].as_array().unwrap();
|
||||
for expected in [
|
||||
"builtin:companion",
|
||||
"builtin:intake",
|
||||
"builtin:orchestrator",
|
||||
"builtin:coder",
|
||||
"builtin:reviewer",
|
||||
"project:custom",
|
||||
] {
|
||||
assert!(
|
||||
profiles.iter().any(|profile| profile["id"] == expected),
|
||||
"missing {expected}: {profiles:?}"
|
||||
);
|
||||
}
|
||||
assert!(
|
||||
profiles
|
||||
.iter()
|
||||
.all(|profile| profile["id"] != "builtin:default")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn runtime_connection_request_validation_bounds_browser_input() {
|
||||
let ok = AddRemoteRuntimeConnectionRequest {
|
||||
@@ -12911,218 +12721,6 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn profile_settings_api_returns_typed_diagnostics_for_duplicate_selector() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let response = profile_settings_request(
|
||||
dir.path(),
|
||||
"POST",
|
||||
"/settings/profiles",
|
||||
json!({
|
||||
"name": "coder",
|
||||
"content": valid_profile_source("coder"),
|
||||
"registry_revision": "missing"
|
||||
}),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(response.0, StatusCode::BAD_REQUEST);
|
||||
assert_diagnostic(&response.1, "profile_selector_duplicate");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn profile_settings_api_returns_typed_diagnostics_for_invalid_decodal() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let response = profile_settings_request(
|
||||
dir.path(),
|
||||
"POST",
|
||||
"/settings/profiles",
|
||||
json!({
|
||||
"name": "bad",
|
||||
"content": "not decodal",
|
||||
"registry_revision": "missing"
|
||||
}),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(response.0, StatusCode::BAD_REQUEST);
|
||||
assert!(
|
||||
diagnostic_codes(&response.1)
|
||||
.iter()
|
||||
.any(|code| code.starts_with("profile_source_"))
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn profile_settings_api_returns_typed_diagnostic_for_invalid_registry_schema() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
fs::create_dir_all(dir.path().join(".yoi")).unwrap();
|
||||
fs::write(dir.path().join(".yoi/profiles.toml"), "[profile\n").unwrap();
|
||||
let response = profile_settings_request(
|
||||
dir.path(),
|
||||
"POST",
|
||||
"/settings/profiles",
|
||||
json!({
|
||||
"name": "schema",
|
||||
"content": valid_profile_source("schema"),
|
||||
"registry_revision": test_file_revision(&dir.path().join(".yoi/profiles.toml"))
|
||||
}),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(response.0, StatusCode::BAD_REQUEST);
|
||||
assert_diagnostic(&response.1, "profile_registry_schema_invalid");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn profile_settings_api_returns_conflict_diagnostic_for_stale_revision() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let response = profile_settings_request(
|
||||
dir.path(),
|
||||
"POST",
|
||||
"/settings/profiles",
|
||||
json!({
|
||||
"name": "alpha",
|
||||
"content": valid_profile_source("alpha"),
|
||||
"registry_revision": "stale"
|
||||
}),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(response.0, StatusCode::CONFLICT);
|
||||
assert_diagnostic(&response.1, "profile_registry_revision_conflict");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn profile_settings_api_returns_typed_diagnostic_for_too_large_source() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let response = profile_settings_request(
|
||||
dir.path(),
|
||||
"POST",
|
||||
"/settings/profiles",
|
||||
json!({
|
||||
"name": "large",
|
||||
"content": "x".repeat((256 * 1024) + 1),
|
||||
"registry_revision": "missing"
|
||||
}),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(response.0, StatusCode::BAD_REQUEST);
|
||||
assert_diagnostic(&response.1, "profile_source_too_large");
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[tokio::test]
|
||||
async fn profile_settings_api_redacts_symlink_escape_response() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
fs::create_dir_all(dir.path().join(".yoi/profiles")).unwrap();
|
||||
fs::write(dir.path().join(".yoi/profiles.toml"), "").unwrap();
|
||||
let outside = dir.path().join("outside.dcdl");
|
||||
fs::write(&outside, valid_profile_source("escape")).unwrap();
|
||||
std::os::unix::fs::symlink(&outside, dir.path().join(".yoi/profiles/escape.dcdl")).unwrap();
|
||||
let response = profile_settings_request(
|
||||
dir.path(),
|
||||
"PUT",
|
||||
"/settings/profiles/registry",
|
||||
json!({
|
||||
"registry_revision": test_file_revision(&dir.path().join(".yoi/profiles.toml")),
|
||||
"default_profile": null,
|
||||
"profiles": [{ "name": "escape", "profile_source_id": "project:escape" }]
|
||||
}),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(response.0, StatusCode::BAD_REQUEST);
|
||||
assert_diagnostic(&response.1, "profile_source_symlink_escape");
|
||||
let rendered = response.1.to_string();
|
||||
assert!(!rendered.contains(dir.path().to_string_lossy().as_ref()));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn worker_launch_rejects_invalid_project_profile_candidate() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
fs::create_dir_all(dir.path().join(".yoi/profiles")).unwrap();
|
||||
fs::write(
|
||||
dir.path().join(".yoi/profiles.toml"),
|
||||
"[profile.bad]\npath = \"profiles/bad.dcdl\"\n",
|
||||
)
|
||||
.unwrap();
|
||||
fs::write(dir.path().join(".yoi/profiles/bad.dcdl"), "not decodal").unwrap();
|
||||
let candidates =
|
||||
crate::profile_settings::load_profile_settings("workspace-test", dir.path())
|
||||
.profiles
|
||||
.into_iter()
|
||||
.filter(|profile| {
|
||||
!profile
|
||||
.diagnostics
|
||||
.iter()
|
||||
.any(|diagnostic| diagnostic.severity == DiagnosticSeverity::Error)
|
||||
})
|
||||
.map(|profile| profile.profile_id)
|
||||
.collect::<Vec<_>>();
|
||||
assert!(!candidates.iter().any(|profile| profile == "project:bad"));
|
||||
assert!(profile_selector_for_candidate_with_root(dir.path(), "project:bad").is_none());
|
||||
}
|
||||
|
||||
fn valid_profile_source(slug: &str) -> String {
|
||||
format!(
|
||||
r#"{{
|
||||
slug = "{slug}";
|
||||
description = "Test";
|
||||
scope = "workspace_read";
|
||||
}}"#
|
||||
)
|
||||
}
|
||||
|
||||
fn test_file_revision(path: &Path) -> String {
|
||||
let Ok(metadata) = fs::metadata(path) else {
|
||||
return "missing".to_string();
|
||||
};
|
||||
let modified = metadata
|
||||
.modified()
|
||||
.ok()
|
||||
.and_then(|time| time.duration_since(std::time::UNIX_EPOCH).ok())
|
||||
.map(|duration| duration.as_nanos())
|
||||
.unwrap_or_default();
|
||||
format!("rev:{modified}:{}", metadata.len())
|
||||
}
|
||||
|
||||
async fn profile_settings_request(
|
||||
workspace_root: &Path,
|
||||
method: &str,
|
||||
path: &str,
|
||||
body: Value,
|
||||
) -> (StatusCode, Value) {
|
||||
let app = test_app(workspace_root.to_path_buf()).await;
|
||||
let request = Request::builder()
|
||||
.method(method)
|
||||
.uri(format!("/api/w/{TEST_WORKSPACE_ID}{path}"))
|
||||
.header(CONTENT_TYPE, "application/json")
|
||||
.body(Body::from(body.to_string()))
|
||||
.unwrap();
|
||||
let response = app.oneshot(request).await.unwrap();
|
||||
let status = response.status();
|
||||
let bytes = to_bytes(response.into_body(), 1024 * 1024).await.unwrap();
|
||||
let json = serde_json::from_slice::<Value>(&bytes).unwrap();
|
||||
(status, json)
|
||||
}
|
||||
|
||||
fn diagnostic_codes(response: &Value) -> Vec<String> {
|
||||
response["diagnostics"]
|
||||
.as_array()
|
||||
.expect("diagnostics array")
|
||||
.iter()
|
||||
.map(|diagnostic| diagnostic["code"].as_str().unwrap().to_string())
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn assert_diagnostic(response: &Value, code: &str) {
|
||||
let codes = diagnostic_codes(response);
|
||||
assert!(
|
||||
!codes.is_empty(),
|
||||
"diagnostics must not be empty: {response}"
|
||||
);
|
||||
assert!(
|
||||
codes.iter().any(|actual| actual == code),
|
||||
"missing {code}: {codes:?}"
|
||||
);
|
||||
}
|
||||
|
||||
struct DeterministicExecutionBackend {
|
||||
contexts: std::sync::Mutex<
|
||||
std::collections::HashMap<
|
||||
@@ -13405,14 +13003,7 @@ mod tests {
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let resolved_config_bundle =
|
||||
crate::profile_settings::build_workspace_profile_config_bundle(
|
||||
&api.config.workspace_root,
|
||||
&api.config.workspace_id,
|
||||
&api.config.workspace_created_at,
|
||||
MEMORY_CONSOLIDATION_PROFILE,
|
||||
)
|
||||
.unwrap();
|
||||
let resolved_config_bundle = None;
|
||||
let existing = api
|
||||
.runtime
|
||||
.spawn_worker(
|
||||
@@ -14463,16 +14054,59 @@ mod tests {
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn skills_endpoints_use_workspace_backend_catalog_and_progressive_detail() {
|
||||
async fn skills_endpoints_use_active_virtual_config_and_ignore_repository_yoi() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let skill_dir = dir.path().join(".yoi/skills/triage-errors");
|
||||
fs::create_dir_all(&skill_dir).unwrap();
|
||||
fs::write(
|
||||
skill_dir.join("SKILL.md"),
|
||||
"---\nname: triage-errors\ndescription: Use when triaging backend errors and choosing safe diagnostics.\n---\n\n# Triage Errors\n\nInspect logs before changing code.",
|
||||
"---\nname: triage-errors\ndescription: stale filesystem authority\n---\nfilesystem body",
|
||||
)
|
||||
.unwrap();
|
||||
let api = test_api(dir.path()).await;
|
||||
let current = api
|
||||
.config_store
|
||||
.load_workspace_config(TEST_WORKSPACE_ID)
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
let main_path = config_source::VirtualPath::parse("main.dcdl").unwrap();
|
||||
let skill_path =
|
||||
config_source::VirtualPath::parse("skills/triage-errors/SKILL.md").unwrap();
|
||||
let main = format!(
|
||||
r#"{{ skills = {{ triage_errors = import "./skills/triage-errors/SKILL.md" as {}; }}; }}"#,
|
||||
skills::SKILL_DOCUMENT_SCHEMA_SOURCE
|
||||
);
|
||||
let request = crate::config_source::ConfigCommitRequest {
|
||||
base_revision: current.snapshot.revision,
|
||||
base_digest: current.snapshot.digest.clone(),
|
||||
changes: vec![
|
||||
config_source::ConfigTreeChange::Update {
|
||||
path: main_path.clone(),
|
||||
expected_digest: current.snapshot.entries[&main_path]
|
||||
.content_digest
|
||||
.clone(),
|
||||
content: main,
|
||||
},
|
||||
config_source::ConfigTreeChange::Create {
|
||||
path: skill_path,
|
||||
content_type: config_source::ConfigContentType::Text,
|
||||
content: "---\nname: triage-errors\ndescription: Use the active DB-backed virtual config when triaging errors.\n---\n# Triage Errors\n\nInspect logs before changing code."
|
||||
.to_string(),
|
||||
},
|
||||
],
|
||||
entrypoints: current.contract.entrypoints.clone(),
|
||||
};
|
||||
let candidate = api
|
||||
.config_store
|
||||
.evaluate_workspace_config_candidate_with_schema(
|
||||
TEST_WORKSPACE_ID,
|
||||
&request,
|
||||
api.config_schema_registry.compose().unwrap(),
|
||||
)
|
||||
.unwrap();
|
||||
api.config_store
|
||||
.commit_evaluated_workspace_config(TEST_WORKSPACE_ID, &candidate)
|
||||
.unwrap();
|
||||
|
||||
let Json(catalog) = scoped_list_skills(
|
||||
State(api.clone()),
|
||||
@@ -14488,6 +14122,13 @@ mod tests {
|
||||
.find(|entry| entry.name == "triage-errors")
|
||||
.expect("workspace Skill catalog entry");
|
||||
assert_eq!(entry.provenance.id, "workspace:triage-errors");
|
||||
assert_eq!(
|
||||
entry.provenance.virtual_path.as_deref(),
|
||||
Some("skills/triage-errors/SKILL.md")
|
||||
);
|
||||
assert!(entry.provenance.revision.is_some());
|
||||
assert!(entry.provenance.source_digest.is_some());
|
||||
assert_ne!(entry.description, "stale filesystem authority");
|
||||
assert!(
|
||||
!serde_json::to_string(&catalog)
|
||||
.unwrap()
|
||||
@@ -15615,6 +15256,7 @@ mod tests {
|
||||
label: Some("server-test".to_string()),
|
||||
}],
|
||||
declarations: Vec::new(),
|
||||
prompt_catalog: None,
|
||||
profile_source_archive: None,
|
||||
profile_source_archive_handle: None,
|
||||
}
|
||||
@@ -16064,12 +15706,6 @@ mod tests {
|
||||
#[tokio::test]
|
||||
async fn browser_worker_create_uses_workspace_default_and_preserves_unsupported_diagnostics() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
fs::create_dir_all(dir.path().join(".yoi")).unwrap();
|
||||
fs::write(
|
||||
dir.path().join(".yoi/profiles.toml"),
|
||||
"default = \"builtin:coder\"\n",
|
||||
)
|
||||
.unwrap();
|
||||
let app = test_app(dir.path()).await;
|
||||
let created = post_json(
|
||||
app.clone(),
|
||||
@@ -16090,7 +15726,7 @@ mod tests {
|
||||
.find(|worker| worker["worker_id"] == created["worker_id"])
|
||||
.expect("created Worker should be listed");
|
||||
assert_eq!(worker["label"], "Worker");
|
||||
assert_eq!(worker["profile"], "builtin:coder");
|
||||
assert_eq!(worker["profile"], "builtin:companion");
|
||||
assert!(worker.get("role").is_none());
|
||||
assert_eq!(worker["worker_id"], created["worker_id"]);
|
||||
let detail_path = format!(
|
||||
@@ -17745,6 +17381,37 @@ mod tests {
|
||||
assert_eq!(shown["linked_tickets"], json!(["00000000001J3"]));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn profile_settings_are_read_only_virtual_config_projection() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let app = test_app(dir.path()).await;
|
||||
let path = format!("/api/w/{TEST_WORKSPACE_ID}/settings/profiles");
|
||||
let settings = get_json(app.clone(), &path).await;
|
||||
assert_eq!(settings["default_profile"], "builtin:companion");
|
||||
assert_eq!(settings["config_revision"], 1);
|
||||
assert!(settings["tree_digest"].as_str().is_some());
|
||||
assert!(settings["projection_digest"].as_str().is_some());
|
||||
assert!(
|
||||
settings["profiles"]
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.all(|profile| profile["editable"] == false)
|
||||
);
|
||||
let mutation = app
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.method("POST")
|
||||
.uri(&path)
|
||||
.header(CONTENT_TYPE, "application/json")
|
||||
.body(Body::from("{}"))
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(mutation.status(), StatusCode::METHOD_NOT_ALLOWED);
|
||||
}
|
||||
|
||||
async fn get_json(app: Router, uri: &str) -> Value {
|
||||
let response = app
|
||||
.oneshot(Request::builder().uri(uri).body(Body::empty()).unwrap())
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -121,19 +121,19 @@ root = ".yoi/tickets"
|
||||
|
||||
[ticket.roles.intake]
|
||||
profile = "project:intake"
|
||||
launch_prompt = "$workspace/ticket/intake/launch"
|
||||
launch_prompt = "ticket.intake.launch"
|
||||
|
||||
[ticket.roles.orchestrator]
|
||||
profile = "project:orchestrator"
|
||||
launch_prompt = "$workspace/ticket/orchestrator/launch"
|
||||
launch_prompt = "ticket.orchestrator.launch"
|
||||
|
||||
[ticket.roles.coder]
|
||||
profile = "project:coder"
|
||||
launch_prompt = "$workspace/ticket/coder/launch"
|
||||
launch_prompt = "ticket.coder.launch"
|
||||
|
||||
[ticket.roles.reviewer]
|
||||
profile = "project:reviewer"
|
||||
launch_prompt = "$workspace/ticket/reviewer/launch"
|
||||
launch_prompt = "ticket.reviewer.launch"
|
||||
```
|
||||
|
||||
Fixed roles are:
|
||||
|
||||
+3
-10
@@ -30,12 +30,6 @@
|
||||
# 必須。Worker の表示名 (ResolveError::MissingField("worker.name") の対象)。
|
||||
name = "example-agent"
|
||||
|
||||
# 任意。デフォルト: なし。
|
||||
# PromptCatalog の 4 つ目の overlay 層として読み込む TOML pack のパス。
|
||||
# 相対パスは manifest base 起点で解決。`worker.instruction` (`$prefix/...`)
|
||||
# とは別系統の単なるファイルパス。
|
||||
# prompt_pack = "./prompts.local.toml"
|
||||
|
||||
|
||||
# ===== [model] ==============================================================
|
||||
# LLM モデル設定。次の 3 形態を受ける:
|
||||
@@ -95,10 +89,9 @@ ref = "anthropic/claude-sonnet-4-6"
|
||||
# ワーカーの生成パラメータ等。セクション自体省略可 (全フィールド任意)。
|
||||
[engine]
|
||||
|
||||
# 任意。デフォルト: "$yoi/default" (`defaults::DEFAULT_INSTRUCTION`)。
|
||||
# システムプロンプト本体の `PromptLoader` 参照。
|
||||
# プレフィクス: "$yoi/..." | "$user/..." | "$workspace/..."
|
||||
# instruction = "$yoi/default"
|
||||
# 任意。デフォルト: "default" (`defaults::DEFAULT_INSTRUCTION`)。
|
||||
# effective Prompt catalog の exact dotted name を選択する。
|
||||
# instruction = "default"
|
||||
|
||||
# 任意。デフォルト: なし (プロバイダ任せ)。
|
||||
# 1 レスポンスあたりの出力 token 上限。
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
# Web Console renders one Worker notification twice
|
||||
|
||||
Date: 2026-08-06
|
||||
|
||||
## Symptom
|
||||
|
||||
A single Workspace Orchestrator attention notification appears in Web Console as two adjacent system lines with identical content:
|
||||
|
||||
- `System · embedded worker notification`
|
||||
- `System · notification`
|
||||
|
||||
The first label is also misleading for Workers running on a non-embedded Runtime.
|
||||
|
||||
## Confirmed cause
|
||||
|
||||
`worker-runtime` projects one accepted `WorkerInputKind::Notify` through two independent protocol-event paths.
|
||||
|
||||
1. `Runtime::interact_worker` calls `input_protocol_event(&input)` after the execution backend acknowledges the input and pushes the result to the Worker observation bus. For `Notify`, `input_protocol_event` constructs a synthetic `protocol::Event::SystemItem` with kind `embedded_worker_notification`.
|
||||
2. The real Worker drains `NotifyBuffer`, commits `SystemItem::Notification` to Worker history, and the Runtime execution bridge republishes the committed log entry as another `protocol::Event::SystemItem` with kind `notification`.
|
||||
|
||||
The Workspace subscription forwards both events. `web/workspace/src/lib/workspace/console/model.ts` renders every `system_item` independently and has no semantic deduplication between these two different kinds/event identities.
|
||||
|
||||
The authoritative history item is the second event. The synthetic input-observation item is not the durable Worker-history authority, so the LLM should receive the notification once even though Web Console displays it twice.
|
||||
|
||||
## Relevant code
|
||||
|
||||
- `crates/worker-runtime/src/runtime.rs`
|
||||
- `input_protocol_event(WorkerInputKind::Notify)` created `embedded_worker_notification`.
|
||||
- `Runtime::interact_worker` unconditionally published that synthetic event after accepted input.
|
||||
- `crates/worker-runtime/src/worker_backend.rs`
|
||||
- the controller bridge republishes committed `SegmentLogSink` entries through `live_log_entry_event`.
|
||||
- `web/workspace/src/lib/workspace/console/model.ts`
|
||||
- both system-item variants are rendered as separate lines.
|
||||
|
||||
## Resolution
|
||||
|
||||
`worker-runtime` no longer converts accepted `Notify` input into a synthetic protocol event. `input_protocol_event` returns no event for Notify, while the normal interaction acknowledgement remains unchanged. The committed `SystemItem::Notification` is therefore the single Console-visible projection.
|
||||
|
||||
A caller-boundary regression test drives accepted user and Notify inputs, verifies that Notify adds no synthetic observation, then publishes the committed notification event and asserts that exactly one notification system item is present.
|
||||
@@ -2,6 +2,7 @@ import "./base.dcdl" // {
|
||||
slug = "coder";
|
||||
description = "Ticket implementation coder profile.";
|
||||
scope = "workspace_write";
|
||||
engine = { instruction = "role.coder"; };
|
||||
|
||||
feature = {
|
||||
task = { enabled = true; };
|
||||
|
||||
@@ -2,6 +2,7 @@ import "./base.dcdl" // {
|
||||
slug = "intake";
|
||||
description = "Ticket intake profile.";
|
||||
scope = "workspace_write";
|
||||
engine = { instruction = "role.intake"; };
|
||||
|
||||
feature = {
|
||||
task = { enabled = true; };
|
||||
|
||||
@@ -2,6 +2,7 @@ import "./base.dcdl" // {
|
||||
slug = "orchestrator";
|
||||
description = "Ticket orchestrator profile.";
|
||||
scope = "workspace_write";
|
||||
engine = { instruction = "role.orchestrator"; };
|
||||
|
||||
feature = {
|
||||
task = { enabled = true; };
|
||||
|
||||
@@ -2,6 +2,7 @@ import "./base.dcdl" // {
|
||||
slug = "reviewer";
|
||||
description = "Ticket review profile.";
|
||||
scope = "workspace_read";
|
||||
engine = { instruction = "role.reviewer"; };
|
||||
|
||||
feature = {
|
||||
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
|
||||
|
||||
|
||||
@@ -2,11 +2,11 @@ You are here as an agent of the "yoi system".
|
||||
|
||||
Stay precise, edit code directly when asked, and avoid speculative refactoring.
|
||||
|
||||
{% include "common/workspace" %}
|
||||
{% include "common.workspace" %}
|
||||
|
||||
{% include "common/tool-usage" %}
|
||||
{% include "common.tool_usage" %}
|
||||
|
||||
|
||||
{% include "common/language" %}
|
||||
{% include "common.language" %}
|
||||
|
||||
{% include "common/writing" %}
|
||||
{% include "common.writing" %}
|
||||
|
||||
@@ -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:backend": "cd ../.. && cargo run -p yoi-workspace-server --bin yoi-server -- serve --listen 127.0.0.1:8787",
|
||||
"check": "deno run -A npm:@sveltejs/kit@2.49.4 sync && deno run -A npm:svelte-check@4.3.4 --tsconfig ./tsconfig.json",
|
||||
"test": "deno test --allow-read=src --allow-env=VSCODE_TEXTMATE_DEBUG src/lib/workspace/auth/model.test.ts src/lib/workspace/api/http.test.ts src/lib/workspace/header/breadcrumb-model.test.ts src/lib/workspace/console/chat-submit.test.ts src/lib/workspace/console/composer-command.test.ts src/lib/workspace/console/composer-completion.test.ts src/lib/workspace/console/markdown.test.ts src/lib/workspace/console/model.test.ts src/lib/workspace/console/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/editor-state.test.ts test/config-source/fixed-schema-wrapper.test.ts test/config-source/toolchain.test.ts test/config-source/wasm-parity.test.ts",
|
||||
"build": "deno run -A npm:vite@7.2.7 build",
|
||||
"preview": "deno run -A npm:vite@7.2.7 preview"
|
||||
},
|
||||
@@ -16,9 +16,11 @@
|
||||
"@sveltejs/kit": "npm:@sveltejs/kit@2.49.4",
|
||||
"@sveltejs/vite-plugin-svelte": "npm:@sveltejs/vite-plugin-svelte@6.2.1",
|
||||
"@codemirror/autocomplete": "npm:@codemirror/autocomplete@6.20.0",
|
||||
"@codemirror/language": "npm:@codemirror/language@6.12.4",
|
||||
"@codemirror/state": "npm:@codemirror/state@6.7.1",
|
||||
"@codemirror/view": "npm:@codemirror/view@6.43.8",
|
||||
"decodal-codemirror": "npm:decodal-codemirror@0.1.6",
|
||||
"@lezer/highlight": "npm:@lezer/highlight@1.2.3",
|
||||
"decodal-codemirror": "npm:decodal-codemirror@0.3.0",
|
||||
"clsx": "npm:clsx@2.1.1",
|
||||
"cookie": "npm:cookie@0.6.0",
|
||||
"devalue": "npm:devalue@5.6.4",
|
||||
|
||||
Generated
+10
-5
@@ -4,15 +4,17 @@
|
||||
"jsr:@std/assert@*": "1.0.19",
|
||||
"jsr:@std/internal@^1.0.12": "1.0.14",
|
||||
"npm:@codemirror/autocomplete@6.20.0": "6.20.0",
|
||||
"npm:@codemirror/language@6.12.4": "6.12.4",
|
||||
"npm:@codemirror/state@6.7.1": "6.7.1",
|
||||
"npm:@codemirror/view@6.43.8": "6.43.8",
|
||||
"npm:@fontsource/ibm-plex-mono@5.3.0": "5.3.0",
|
||||
"npm:@lezer/highlight@1.2.3": "1.2.3",
|
||||
"npm:@sveltejs/adapter-static@3.0.9": "3.0.9_@sveltejs+kit@2.49.4__@sveltejs+vite-plugin-svelte@6.2.1___svelte@5.45.6___vite@7.2.7__svelte@5.45.6__typescript@5.9.3__vite@7.2.7_@sveltejs+vite-plugin-svelte@6.2.1__svelte@5.45.6__vite@7.2.7_svelte@5.45.6_typescript@5.9.3_vite@7.2.7",
|
||||
"npm:@sveltejs/kit@2.49.4": "2.49.4_@sveltejs+vite-plugin-svelte@6.2.1__svelte@5.45.6__vite@7.2.7_svelte@5.45.6_typescript@5.9.3_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:cookie@0.6.0": "0.6.0",
|
||||
"npm:decodal-codemirror@0.1.6": "0.1.6_@codemirror+view@6.43.8",
|
||||
"npm:decodal-codemirror@0.3.0": "0.3.0_@codemirror+language@6.12.4_@codemirror+view@6.43.8_@lezer+highlight@1.2.3_@lezer+lr@1.4.10",
|
||||
"npm:devalue@5.6.4": "5.6.4",
|
||||
"npm:gen-interface-jp@0.8.0": "0.8.0",
|
||||
"npm:set-cookie-parser@2.7.2": "2.7.2",
|
||||
@@ -509,7 +511,8 @@
|
||||
"integrity": "sha512-/iZM6FnM4tnx9csuTxspMW4BOSegshwX5oBDznJ7S4WggL7Vczz5d2W11ecc4vRrQMQHXRSxzrCsyG5EsPPTbA=="
|
||||
},
|
||||
"@ungap/structured-clone@1.3.0": {
|
||||
"integrity": "sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g=="
|
||||
"integrity": "sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g==",
|
||||
"deprecated": true
|
||||
},
|
||||
"acorn@8.16.0": {
|
||||
"integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==",
|
||||
@@ -554,8 +557,8 @@
|
||||
"ms"
|
||||
]
|
||||
},
|
||||
"decodal-codemirror@0.1.6_@codemirror+view@6.43.8": {
|
||||
"integrity": "sha512-XTS5vAY+vTb/yEg5n+1yORtBPuhozG4KO0YGbYEFR8oZX0KghZuHyFEsq/CJMXF223YMC/FQt3SC19NVYGWKMw==",
|
||||
"decodal-codemirror@0.3.0_@codemirror+language@6.12.4_@codemirror+view@6.43.8_@lezer+highlight@1.2.3_@lezer+lr@1.4.10": {
|
||||
"integrity": "sha512-M+Iod3UAZigpt46TmuLJIlSEBhL6KOOksyVxUOGGdlfKYCMN9oN6YG5G9tjkr3/r+Eq8ig1RipC7VZX5iiACTQ==",
|
||||
"dependencies": [
|
||||
"@codemirror/language",
|
||||
"@codemirror/view",
|
||||
@@ -1000,14 +1003,16 @@
|
||||
"workspace": {
|
||||
"dependencies": [
|
||||
"npm:@codemirror/autocomplete@6.20.0",
|
||||
"npm:@codemirror/language@6.12.4",
|
||||
"npm:@codemirror/state@6.7.1",
|
||||
"npm:@codemirror/view@6.43.8",
|
||||
"npm:@lezer/highlight@1.2.3",
|
||||
"npm:@sveltejs/adapter-static@3.0.9",
|
||||
"npm:@sveltejs/kit@2.49.4",
|
||||
"npm:@sveltejs/vite-plugin-svelte@6.2.1",
|
||||
"npm:clsx@2.1.1",
|
||||
"npm:cookie@0.6.0",
|
||||
"npm:decodal-codemirror@0.1.6",
|
||||
"npm:decodal-codemirror@0.3.0",
|
||||
"npm:devalue@5.6.4",
|
||||
"npm:set-cookie-parser@2.7.2",
|
||||
"npm:shiki@3.13.0",
|
||||
|
||||
@@ -15,6 +15,10 @@ export type SkillDiagnostic = {
|
||||
export type SkillProvenance = {
|
||||
kind: "builtin" | "workspace";
|
||||
id: string;
|
||||
virtual_path?: string;
|
||||
revision?: number;
|
||||
source_digest?: string;
|
||||
tree_digest?: string;
|
||||
};
|
||||
|
||||
export type SkillCatalogEntry = {
|
||||
|
||||
@@ -4,7 +4,6 @@
|
||||
import {
|
||||
commitConfigTree,
|
||||
fetchConfigTree,
|
||||
previewConfigTree,
|
||||
} from "./api.ts";
|
||||
import { ConfigSourceToolchain } from "./toolchain.ts";
|
||||
import type {
|
||||
@@ -23,15 +22,15 @@
|
||||
let diagnostics = $state<ConfigDiagnostic[]>([]);
|
||||
let status = $state("Loading source tree…");
|
||||
let busy = $state(false);
|
||||
let draftChanges = $state<ConfigTreeChange[]>([]);
|
||||
let workingChanges = $state<ConfigTreeChange[]>([]);
|
||||
let baseRevision = $state(0);
|
||||
let baseDigest = $state("");
|
||||
let renamePath = $state("");
|
||||
let baseSnapshot = $state<WorkspaceConfigTreeResponse["snapshot"] | null>(null);
|
||||
let preflightDigest = $state("");
|
||||
let baseSnapshot = $state.raw<WorkspaceConfigTreeResponse["snapshot"] | null>(null);
|
||||
let conflict = $state(false);
|
||||
let candidateContract = $state<WorkspaceConfigTreeResponse["contract"] | null>(null);
|
||||
let toolchain: ConfigSourceToolchain | null = null;
|
||||
let toolchain = $state.raw<ConfigSourceToolchain | null>(null);
|
||||
let analysisReady = $state(false);
|
||||
let analysisGeneration = 0;
|
||||
|
||||
const paths = $derived(
|
||||
treeState ? Object.keys(treeState.snapshot.entries).toSorted() : [],
|
||||
@@ -40,8 +39,7 @@
|
||||
treeState && selectedPath ? treeState.snapshot.entries[selectedPath] : undefined,
|
||||
);
|
||||
const mainSelected = $derived(selectedPath === MAIN_ENTRYPOINT);
|
||||
const dirty = $derived(draftChanges.length > 0 || (selected ? source !== selected.content : source.length > 0));
|
||||
const commitReady = $derived(dirty && preflightDigest === treeState?.snapshot.digest);
|
||||
const dirty = $derived(workingChanges.length > 0 || (selected ? source !== selected.content : source.length > 0));
|
||||
|
||||
onMount(() => {
|
||||
toolchain = new ConfigSourceToolchain();
|
||||
@@ -49,22 +47,45 @@
|
||||
return () => toolchain?.close();
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
const analyzer = toolchain;
|
||||
const path = selectedPath;
|
||||
const value = source;
|
||||
const ready = analysisReady;
|
||||
const generation = ++analysisGeneration;
|
||||
diagnostics = [];
|
||||
if (!analyzer || !path || !ready) return;
|
||||
|
||||
const timer = setTimeout(() => {
|
||||
void analyzer.analyze(path, value).then((result) => {
|
||||
if (generation === analysisGeneration) diagnostics = result;
|
||||
}).catch((error) => {
|
||||
if (generation === analysisGeneration) status = `Analyze failed: ${String(error)}`;
|
||||
});
|
||||
}, 250);
|
||||
return () => {
|
||||
clearTimeout(timer);
|
||||
if (generation === analysisGeneration) analysisGeneration += 1;
|
||||
};
|
||||
});
|
||||
|
||||
async function reload() {
|
||||
analysisReady = false;
|
||||
try {
|
||||
treeState = await fetchConfigTree(workspaceId);
|
||||
if (!selectedPath || !treeState.snapshot.entries[selectedPath]) {
|
||||
selectedPath = Object.keys(treeState.snapshot.entries).toSorted()[0] ?? "";
|
||||
}
|
||||
source = selectedPath ? treeState.snapshot.entries[selectedPath].content : "";
|
||||
baseSnapshot = structuredClone(treeState.snapshot);
|
||||
baseSnapshot = $state.snapshot(treeState.snapshot);
|
||||
baseRevision = treeState.snapshot.revision;
|
||||
baseDigest = treeState.snapshot.digest;
|
||||
await toolchain?.setSnapshot(treeState.snapshot);
|
||||
draftChanges = [];
|
||||
await toolchain?.setSnapshot(treeState.snapshot, treeState.contract.schema_bundle);
|
||||
analysisReady = true;
|
||||
workingChanges = [];
|
||||
renamePath = selectedPath;
|
||||
diagnostics = [];
|
||||
conflict = false;
|
||||
candidateContract = null;
|
||||
status = `Revision ${treeState.snapshot.revision} · ${treeState.snapshot.digest.slice(0, 20)}…`;
|
||||
} catch (error) {
|
||||
status = String(error);
|
||||
@@ -76,9 +97,7 @@
|
||||
if (!change || !toolchain) return;
|
||||
const candidate = await toolchain.applyChanges([change]);
|
||||
if (treeState) treeState = { ...treeState, snapshot: candidate };
|
||||
if (baseSnapshot) draftChanges = await toolchain.changesBetween(baseSnapshot, candidate);
|
||||
preflightDigest = "";
|
||||
candidateContract = null;
|
||||
if (baseSnapshot) workingChanges = await toolchain.changesBetween(baseSnapshot, candidate);
|
||||
conflict = false;
|
||||
}
|
||||
|
||||
@@ -114,113 +133,107 @@
|
||||
return [MAIN_ENTRYPOINT];
|
||||
}
|
||||
|
||||
async function analyze() {
|
||||
if (!toolchain || !treeState || !selectedPath) return;
|
||||
diagnostics = await toolchain.analyze(selectedPath, source);
|
||||
status = diagnostics.length === 0 ? "No diagnostics." : `${diagnostics.length} diagnostic(s).`;
|
||||
}
|
||||
|
||||
async function format() {
|
||||
if (!toolchain) return;
|
||||
try {
|
||||
source = await toolchain.format(source);
|
||||
await analyze();
|
||||
status = "Formatted source. Changes remain local until Commit succeeds.";
|
||||
} catch (error) {
|
||||
status = String(error);
|
||||
}
|
||||
}
|
||||
|
||||
async function preview() {
|
||||
if (!treeState) return;
|
||||
async function formatWorkingSources() {
|
||||
if (!toolchain || !treeState || !baseSnapshot) return;
|
||||
await stageCurrent();
|
||||
if (draftChanges.length === 0) {
|
||||
status = "No draft changes to preview.";
|
||||
return;
|
||||
const paths = new Set<string>();
|
||||
for (const change of workingChanges) {
|
||||
if (change.kind === "create" || change.kind === "update") paths.add(change.path);
|
||||
if (change.kind === "rename") paths.add(change.to);
|
||||
}
|
||||
busy = true;
|
||||
try {
|
||||
const candidate = await previewConfigTree(workspaceId, {
|
||||
changes: draftChanges,
|
||||
entrypoints: entrypoints(),
|
||||
});
|
||||
await toolchain?.evaluate(candidate.contract);
|
||||
diagnostics = [];
|
||||
candidateContract = candidate.contract;
|
||||
preflightDigest = candidate.snapshot.digest;
|
||||
status = `Preview valid · projection ${candidate.evaluation.projection_digest.slice(0, 20)}…`;
|
||||
} catch (error) {
|
||||
const message = String(error);
|
||||
conflict = message.includes("conflict") || message.includes("base revision/digest mismatch");
|
||||
status = conflict ? `${message} Reload the authoritative tree before editing again.` : message;
|
||||
} finally {
|
||||
busy = false;
|
||||
|
||||
let candidate = treeState.snapshot;
|
||||
let formattedAny = false;
|
||||
for (const path of paths) {
|
||||
const entry = candidate.entries[path];
|
||||
if (!entry || entry.content_type !== "decodal") continue;
|
||||
const formatted = await toolchain.format(entry.content);
|
||||
if (formatted === entry.content) continue;
|
||||
candidate = await toolchain.applyChanges([{
|
||||
kind: "update",
|
||||
path,
|
||||
expected_digest: entry.content_digest,
|
||||
content: formatted,
|
||||
}]);
|
||||
formattedAny = true;
|
||||
}
|
||||
if (!formattedAny) return;
|
||||
|
||||
treeState = { ...treeState, snapshot: candidate };
|
||||
workingChanges = await toolchain.changesBetween(baseSnapshot, candidate);
|
||||
source = candidate.entries[selectedPath]?.content ?? source;
|
||||
conflict = false;
|
||||
}
|
||||
|
||||
function recordCommitError(error: unknown) {
|
||||
const message = String(error);
|
||||
conflict = message.includes("conflict") || message.includes("base revision/digest mismatch");
|
||||
status = conflict ? `${message} Reload the authoritative tree before editing again.` : message;
|
||||
}
|
||||
|
||||
async function commit() {
|
||||
if (!treeState) return;
|
||||
await stageCurrent();
|
||||
if (draftChanges.length === 0) {
|
||||
status = "No draft changes to commit.";
|
||||
return;
|
||||
}
|
||||
if (preflightDigest !== treeState.snapshot.digest || !candidateContract) {
|
||||
status = "Preview the complete candidate successfully before Commit.";
|
||||
return;
|
||||
}
|
||||
busy = true;
|
||||
try {
|
||||
await formatWorkingSources();
|
||||
if (workingChanges.length === 0) {
|
||||
status = "No working changes to commit.";
|
||||
return;
|
||||
}
|
||||
treeState = await commitConfigTree(workspaceId, {
|
||||
base_revision: baseRevision,
|
||||
base_digest: baseDigest,
|
||||
changes: draftChanges,
|
||||
entrypoints: candidateContract.entrypoints,
|
||||
toolchain_fingerprint: candidateContract.fingerprint,
|
||||
changes: workingChanges,
|
||||
entrypoints: entrypoints(),
|
||||
});
|
||||
draftChanges = [];
|
||||
preflightDigest = "";
|
||||
candidateContract = null;
|
||||
conflict = false;
|
||||
baseSnapshot = structuredClone(treeState.snapshot);
|
||||
workingChanges = [];
|
||||
conflict = false;
|
||||
baseSnapshot = $state.snapshot(treeState.snapshot);
|
||||
baseRevision = treeState.snapshot.revision;
|
||||
baseDigest = treeState.snapshot.digest;
|
||||
await toolchain?.setSnapshot(treeState.snapshot);
|
||||
await toolchain?.setSnapshot(treeState.snapshot, treeState.contract.schema_bundle);
|
||||
source = treeState.snapshot.entries[selectedPath]?.content ?? "";
|
||||
diagnostics = [];
|
||||
status = `Committed revision ${treeState.snapshot.revision}.`;
|
||||
status = `Committed formatted revision ${treeState.snapshot.revision}.`;
|
||||
} catch (error) {
|
||||
const message = String(error);
|
||||
conflict = message.includes("conflict") || message.includes("base revision/digest mismatch");
|
||||
status = conflict ? `${message} Reload the authoritative tree before editing again.` : message;
|
||||
recordCommitError(error);
|
||||
} finally {
|
||||
busy = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function discardAndReload() {
|
||||
draftChanges = [];
|
||||
workingChanges = [];
|
||||
source = "";
|
||||
await reload();
|
||||
}
|
||||
|
||||
async function reloadAndReapply() {
|
||||
if (!toolchain || !treeState) return;
|
||||
const localChanges = [...draftChanges];
|
||||
const localChanges = [...workingChanges];
|
||||
const remote = await fetchConfigTree(workspaceId);
|
||||
baseSnapshot = structuredClone(remote.snapshot);
|
||||
baseRevision = remote.snapshot.revision;
|
||||
baseDigest = remote.snapshot.digest;
|
||||
await toolchain.setSnapshot(remote.snapshot);
|
||||
await toolchain.setSnapshot(remote.snapshot, remote.contract.schema_bundle);
|
||||
try {
|
||||
const candidate = await toolchain.applyChanges(localChanges);
|
||||
draftChanges = localChanges;
|
||||
workingChanges = localChanges;
|
||||
treeState = { ...remote, snapshot: candidate };
|
||||
selectedPath = candidate.entries[selectedPath] ? selectedPath : Object.keys(candidate.entries).toSorted()[0] ?? "";
|
||||
source = selectedPath ? candidate.entries[selectedPath].content : "";
|
||||
conflict = false;
|
||||
preflightDigest = "";
|
||||
candidateContract = null;
|
||||
status = "Local changes reapplied to the latest revision. Preview again before Commit.";
|
||||
status = "Local changes reapplied to the latest revision. Commit to persist them.";
|
||||
} catch (error) {
|
||||
conflict = true;
|
||||
status = `Local changes conflict with the latest revision: ${String(error)}. Discard local changes or resolve against a fresh reload.`;
|
||||
@@ -233,7 +246,7 @@
|
||||
selectedPath = path;
|
||||
source = "{}\n";
|
||||
diagnostics = [];
|
||||
status = `Drafting new source ${path}. It is not persisted until Commit succeeds.`;
|
||||
status = `Creating local source ${path}. It is not persisted until Commit succeeds.`;
|
||||
}
|
||||
|
||||
async function deleteEntry() {
|
||||
@@ -245,14 +258,12 @@
|
||||
};
|
||||
const candidate = await toolchain.applyChanges([change]);
|
||||
treeState = { ...treeState, snapshot: candidate };
|
||||
if (baseSnapshot) draftChanges = await toolchain.changesBetween(baseSnapshot, candidate);
|
||||
preflightDigest = "";
|
||||
candidateContract = null;
|
||||
if (baseSnapshot) workingChanges = await toolchain.changesBetween(baseSnapshot, candidate);
|
||||
conflict = false;
|
||||
selectedPath = Object.keys(candidate.entries).toSorted()[0] ?? "";
|
||||
source = selectedPath ? candidate.entries[selectedPath].content : "";
|
||||
renamePath = selectedPath;
|
||||
status = "Delete staged. Preview and Commit to persist the candidate tree.";
|
||||
status = "Delete staged locally. Commit to persist the working tree.";
|
||||
}
|
||||
|
||||
async function renameEntry() {
|
||||
@@ -268,13 +279,11 @@
|
||||
};
|
||||
const candidate = await toolchain.applyChanges([change]);
|
||||
treeState = { ...treeState, snapshot: candidate };
|
||||
if (baseSnapshot) draftChanges = await toolchain.changesBetween(baseSnapshot, candidate);
|
||||
preflightDigest = "";
|
||||
candidateContract = null;
|
||||
if (baseSnapshot) workingChanges = await toolchain.changesBetween(baseSnapshot, candidate);
|
||||
conflict = false;
|
||||
selectedPath = to;
|
||||
source = candidate.entries[to]?.content ?? "";
|
||||
status = `Rename to ${to} staged. Preview and Commit to persist.`;
|
||||
status = `Rename to ${to} staged locally. Commit to persist it.`;
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -299,7 +308,7 @@
|
||||
<form class="config-source-create" onsubmit={(event) => { event.preventDefault(); createEntry(); }}>
|
||||
<label for="new-config-path">New path</label>
|
||||
<input id="new-config-path" bind:value={newPath} placeholder="module.dcdl" />
|
||||
<button type="submit">Create draft</button>
|
||||
<button type="submit">Create local source</button>
|
||||
</form>
|
||||
</aside>
|
||||
|
||||
@@ -313,15 +322,14 @@
|
||||
<input aria-label="Rename path" bind:value={renamePath} disabled={!selected || mainSelected || busy} />
|
||||
<button type="button" onclick={renameEntry} disabled={!selected || mainSelected || renamePath === selectedPath || busy}>Rename</button>
|
||||
<button type="button" onclick={format} disabled={!selectedPath || busy}>Format</button>
|
||||
<button type="button" onclick={analyze} disabled={!selectedPath || busy}>Analyze</button>
|
||||
<button type="button" onclick={preview} disabled={!dirty || busy}>Preview</button>
|
||||
<button class="primary" type="button" onclick={commit} disabled={!commitReady || busy}>Commit</button>
|
||||
<button class="primary" type="button" onclick={commit} disabled={!dirty || busy}>Commit</button>
|
||||
<button class="danger" type="button" onclick={deleteEntry} disabled={!selected || mainSelected || busy}>Delete</button>
|
||||
</div>
|
||||
</header>
|
||||
<DecodalSourceEditor
|
||||
value={source}
|
||||
readonly={!selectedPath || busy}
|
||||
fixedSchemaWrapper={mainSelected}
|
||||
onChange={(value) => source = value}
|
||||
onComplete={(value, offset, explicit) => toolchain?.complete(selectedPath, value, offset, explicit) ?? Promise.resolve(null)}
|
||||
/>
|
||||
|
||||
@@ -1,9 +1,7 @@
|
||||
import type {
|
||||
ConfigCommitRequest,
|
||||
ConfigEntry,
|
||||
ConfigPreviewRequest,
|
||||
ConfigTreeSnapshot,
|
||||
EvaluatedConfigCandidate,
|
||||
WorkspaceConfigTreeResponse,
|
||||
} from "./types.ts";
|
||||
|
||||
@@ -55,20 +53,6 @@ export async function fetchConfigRevision(
|
||||
);
|
||||
}
|
||||
|
||||
export async function previewConfigTree(
|
||||
workspaceId: string,
|
||||
request: ConfigPreviewRequest,
|
||||
fetcher: typeof fetch = fetch,
|
||||
): Promise<EvaluatedConfigCandidate> {
|
||||
return await readJson(
|
||||
await fetcher(`${sourceTreeUrl(workspaceId)}/preview`, {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify(request),
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
export async function commitConfigTree(
|
||||
workspaceId: string,
|
||||
request: ConfigCommitRequest,
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
import type { CompletionResult } from "@codemirror/autocomplete";
|
||||
|
||||
export type ConfigSourceCompletionResult = {
|
||||
from: number;
|
||||
items: ConfigSourceCompletionItem[];
|
||||
};
|
||||
|
||||
type ConfigSourceCompletionItem = {
|
||||
label: string;
|
||||
kind: string;
|
||||
detail: string | null;
|
||||
priority: number;
|
||||
};
|
||||
|
||||
export function toCodeMirrorCompletion(
|
||||
source: string,
|
||||
result: ConfigSourceCompletionResult | null,
|
||||
): CompletionResult | null {
|
||||
if (!result) return null;
|
||||
return {
|
||||
from: utf8ByteOffsetToUtf16(source, result.from),
|
||||
options: result.items.map((item) => ({
|
||||
label: item.label,
|
||||
type: item.kind,
|
||||
detail: item.detail ?? undefined,
|
||||
boost: item.priority,
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
function utf8ByteOffsetToUtf16(source: string, byteOffset: number): number {
|
||||
if (!Number.isSafeInteger(byteOffset) || byteOffset < 0) {
|
||||
throw new RangeError(
|
||||
"completion byte offset must be a non-negative integer",
|
||||
);
|
||||
}
|
||||
|
||||
let bytes = 0;
|
||||
let utf16 = 0;
|
||||
for (const character of source) {
|
||||
if (bytes === byteOffset) return utf16;
|
||||
const codePoint = character.codePointAt(0)!;
|
||||
bytes += codePoint <= 0x7f
|
||||
? 1
|
||||
: codePoint <= 0x7ff
|
||||
? 2
|
||||
: codePoint <= 0xffff
|
||||
? 3
|
||||
: 4;
|
||||
utf16 += character.length;
|
||||
if (bytes > byteOffset) {
|
||||
throw new RangeError("completion byte offset splits a UTF-8 code point");
|
||||
}
|
||||
}
|
||||
if (bytes === byteOffset) return utf16;
|
||||
throw new RangeError("completion byte offset is outside the source");
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
import {
|
||||
EditorSelection,
|
||||
EditorState,
|
||||
type Extension,
|
||||
Prec,
|
||||
} from "@codemirror/state";
|
||||
import { Decoration, EditorView, keymap } from "@codemirror/view";
|
||||
|
||||
const WORKSPACE_SCHEMA_SUFFIX = "} as WorkspaceConfigSchema";
|
||||
const fixedWrapperMark = Decoration.mark({
|
||||
class: "cm-fixed-schema-wrapper",
|
||||
});
|
||||
|
||||
export type FixedWrapperBounds = {
|
||||
bodyFrom: number;
|
||||
bodyTo: number;
|
||||
};
|
||||
|
||||
export function fixedWrapperBounds(
|
||||
state: EditorState,
|
||||
): FixedWrapperBounds | null {
|
||||
const source = state.doc.toString();
|
||||
if (!source.startsWith("{")) return null;
|
||||
const sourceWithoutTrailingWhitespace = source.trimEnd();
|
||||
if (!sourceWithoutTrailingWhitespace.endsWith(WORKSPACE_SCHEMA_SUFFIX)) {
|
||||
return null;
|
||||
}
|
||||
const bodyTo = sourceWithoutTrailingWhitespace.length -
|
||||
WORKSPACE_SCHEMA_SUFFIX.length;
|
||||
if (bodyTo < 1) return null;
|
||||
return { bodyFrom: 1, bodyTo };
|
||||
}
|
||||
|
||||
function fixedWrapperDecorations(state: EditorState) {
|
||||
const bounds = fixedWrapperBounds(state);
|
||||
if (!bounds) return Decoration.none;
|
||||
return Decoration.set([
|
||||
fixedWrapperMark.range(0, bounds.bodyFrom),
|
||||
fixedWrapperMark.range(bounds.bodyTo, state.doc.length),
|
||||
]);
|
||||
}
|
||||
|
||||
export function fixedSchemaWrapperExtension(): Extension {
|
||||
return [
|
||||
EditorView.decorations.of((editor) =>
|
||||
fixedWrapperDecorations(editor.state)
|
||||
),
|
||||
EditorView.atomicRanges.of((editor) =>
|
||||
fixedWrapperDecorations(editor.state)
|
||||
),
|
||||
EditorState.transactionFilter.of((transaction) => {
|
||||
const bounds = fixedWrapperBounds(transaction.startState);
|
||||
if (!bounds || !transaction.docChanged) return transaction;
|
||||
let allowed = true;
|
||||
transaction.changes.iterChangedRanges((from, to) => {
|
||||
if (from < bounds.bodyFrom || to > bounds.bodyTo) allowed = false;
|
||||
});
|
||||
return allowed ? transaction : [];
|
||||
}),
|
||||
Prec.highest(
|
||||
keymap.of([{
|
||||
key: "Mod-a",
|
||||
run: (editor) => {
|
||||
const bounds = fixedWrapperBounds(editor.state);
|
||||
if (!bounds) return false;
|
||||
editor.dispatch({
|
||||
selection: EditorSelection.range(bounds.bodyFrom, bounds.bodyTo),
|
||||
});
|
||||
return true;
|
||||
},
|
||||
}]),
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
export function moveSelectionIntoFixedWrapper(editor: EditorView) {
|
||||
const bounds = fixedWrapperBounds(editor.state);
|
||||
if (!bounds) return;
|
||||
const selectionInsideBody = editor.state.selection.ranges.every((range) =>
|
||||
range.from >= bounds.bodyFrom && range.to <= bounds.bodyTo
|
||||
);
|
||||
if (!selectionInsideBody) {
|
||||
editor.dispatch({
|
||||
selection: EditorSelection.cursor(bounds.bodyFrom),
|
||||
filter: false,
|
||||
});
|
||||
}
|
||||
}
|
||||
+6
@@ -9,6 +9,8 @@ export function changes_between(base: any, candidate: any): any;
|
||||
|
||||
export function complete_current(entrypoint: string, source: string, utf16_offset: number, explicit: boolean): any;
|
||||
|
||||
export function compose_schema_bundle(contributions: any): any;
|
||||
|
||||
export function evaluate_current(contract: any): any;
|
||||
|
||||
export function evaluate_snapshot(snapshot: any, contract: any): any;
|
||||
@@ -17,6 +19,8 @@ export function formatSource(source: string): string;
|
||||
|
||||
export function format_source(source: string): string;
|
||||
|
||||
export function set_schema_bundle(schema_bundle: any): void;
|
||||
|
||||
export function set_snapshot(snapshot: any): void;
|
||||
|
||||
export type InitInput = RequestInfo | URL | Response | BufferSource | WebAssembly.Module;
|
||||
@@ -27,9 +31,11 @@ export interface InitOutput {
|
||||
readonly apply_changes: (a: any) => [number, number, number];
|
||||
readonly changes_between: (a: any, b: any) => [number, number, number];
|
||||
readonly complete_current: (a: number, b: number, c: number, d: number, e: number, f: number) => [number, number, number];
|
||||
readonly compose_schema_bundle: (a: any) => [number, number, number];
|
||||
readonly evaluate_current: (a: any) => [number, number, number];
|
||||
readonly evaluate_snapshot: (a: any, b: any) => [number, number, number];
|
||||
readonly format_source: (a: number, b: number) => [number, number, number, number];
|
||||
readonly set_schema_bundle: (a: any) => [number, number];
|
||||
readonly set_snapshot: (a: any) => [number, number];
|
||||
readonly formatSource: (a: number, b: number) => [number, number];
|
||||
readonly __wbindgen_malloc: (a: number, b: number) => number;
|
||||
|
||||
@@ -62,6 +62,18 @@ export function complete_current(entrypoint, source, utf16_offset, explicit) {
|
||||
return takeFromExternrefTable0(ret[0]);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {any} contributions
|
||||
* @returns {any}
|
||||
*/
|
||||
export function compose_schema_bundle(contributions) {
|
||||
const ret = wasm.compose_schema_bundle(contributions);
|
||||
if (ret[2]) {
|
||||
throw takeFromExternrefTable0(ret[1]);
|
||||
}
|
||||
return takeFromExternrefTable0(ret[0]);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {any} contract
|
||||
* @returns {any}
|
||||
@@ -131,6 +143,16 @@ export function format_source(source) {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {any} schema_bundle
|
||||
*/
|
||||
export function set_schema_bundle(schema_bundle) {
|
||||
const ret = wasm.set_schema_bundle(schema_bundle);
|
||||
if (ret[1]) {
|
||||
throw takeFromExternrefTable0(ret[0]);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {any} snapshot
|
||||
*/
|
||||
|
||||
Binary file not shown.
+2
@@ -5,9 +5,11 @@ export const analyze_snapshot: (a: any, b: number, c: number, d: number, e: numb
|
||||
export const apply_changes: (a: any) => [number, number, number];
|
||||
export const changes_between: (a: any, b: any) => [number, number, number];
|
||||
export const complete_current: (a: number, b: number, c: number, d: number, e: number, f: number) => [number, number, number];
|
||||
export const compose_schema_bundle: (a: any) => [number, number, number];
|
||||
export const evaluate_current: (a: any) => [number, number, number];
|
||||
export const evaluate_snapshot: (a: any, b: any) => [number, number, number];
|
||||
export const format_source: (a: number, b: number) => [number, number, number, number];
|
||||
export const set_schema_bundle: (a: any) => [number, number];
|
||||
export const set_snapshot: (a: any) => [number, number];
|
||||
export const formatSource: (a: number, b: number) => [number, number];
|
||||
export const __wbindgen_malloc: (a: number, b: number) => number;
|
||||
|
||||
+1
-1
@@ -2,4 +2,4 @@
|
||||
import type { ConfigTreeChange } from "./ConfigTreeChange";
|
||||
import type { VirtualPath } from "./VirtualPath";
|
||||
|
||||
export type ConfigCommitRequest = { base_revision: number, base_digest: string, changes: Array<ConfigTreeChange>, entrypoints: Array<VirtualPath>, toolchain_fingerprint: string, };
|
||||
export type ConfigCommitRequest = { base_revision: number, base_digest: string, changes: Array<ConfigTreeChange>, entrypoints: Array<VirtualPath>, };
|
||||
|
||||
@@ -1,5 +0,0 @@
|
||||
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
|
||||
import type { ConfigTreeChange } from "./ConfigTreeChange";
|
||||
import type { VirtualPath } from "./VirtualPath";
|
||||
|
||||
export type ConfigPreviewRequest = { changes: Array<ConfigTreeChange>, entrypoints: Array<VirtualPath>, };
|
||||
+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.
|
||||
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
@@ -1,6 +0,0 @@
|
||||
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
|
||||
import type { ConfigTreeSnapshot } from "./ConfigTreeSnapshot";
|
||||
import type { EvaluationResult } from "./EvaluationResult";
|
||||
import type { ToolchainContract } from "./ToolchainContract";
|
||||
|
||||
export type EvaluatedConfigCandidate = { base_revision: number, base_digest: string, snapshot: ConfigTreeSnapshot, contract: ToolchainContract, evaluation: EvaluationResult, };
|
||||
@@ -0,0 +1,3 @@
|
||||
export function jsonWorkerMessage<T>(request: T): T {
|
||||
return JSON.parse(JSON.stringify(request)) as T;
|
||||
}
|
||||
@@ -1,62 +1,105 @@
|
||||
import type { ConfigDiagnostic, ConfigTreeChange, ConfigTreeSnapshot, ToolchainContract } from "./types.ts";
|
||||
import type { ConfigSourceWorkerRequest, ConfigSourceWorkerResponse } from "./toolchain.worker.ts";
|
||||
import type {
|
||||
ConfigDiagnostic,
|
||||
ConfigTreeChange,
|
||||
ConfigTreeSnapshot,
|
||||
WorkspaceConfigSchemaBundle,
|
||||
} from "./types.ts";
|
||||
import { jsonWorkerMessage } from "./toolchain-message.ts";
|
||||
import {
|
||||
type ConfigSourceCompletionResult,
|
||||
toCodeMirrorCompletion,
|
||||
} from "./completion.ts";
|
||||
import type {
|
||||
ConfigSourceWorkerRequest,
|
||||
ConfigSourceWorkerResponse,
|
||||
} from "./toolchain.worker.ts";
|
||||
|
||||
type Command =
|
||||
| Omit<Extract<ConfigSourceWorkerRequest, { kind: "set_snapshot" }>, "id">
|
||||
| Omit<Extract<ConfigSourceWorkerRequest, { kind: "apply_changes" }>, "id">
|
||||
| Omit<Extract<ConfigSourceWorkerRequest, { kind: "changes_between" }>, "id">
|
||||
| Omit<Extract<ConfigSourceWorkerRequest, { kind: "analyze" }>, "id">
|
||||
| Omit<Extract<ConfigSourceWorkerRequest, { kind: "evaluate" }>, "id">
|
||||
| Omit<Extract<ConfigSourceWorkerRequest, { kind: "complete" }>, "id">
|
||||
| Omit<Extract<ConfigSourceWorkerRequest, { kind: "format" }>, "id">;
|
||||
|
||||
export class ConfigSourceToolchain {
|
||||
#worker: Worker;
|
||||
#nextId = 1;
|
||||
#pending = new Map<number, { resolve: (value: unknown) => void; reject: (reason: unknown) => void }>();
|
||||
#pending = new Map<
|
||||
number,
|
||||
{ resolve: (value: unknown) => void; reject: (reason: unknown) => void }
|
||||
>();
|
||||
|
||||
constructor(worker = new Worker(new URL("./toolchain.worker.ts", import.meta.url), { type: "module" })) {
|
||||
constructor(
|
||||
worker = new Worker(new URL("./toolchain.worker.ts", import.meta.url), {
|
||||
type: "module",
|
||||
}),
|
||||
) {
|
||||
this.#worker = worker;
|
||||
worker.addEventListener("message", (event: MessageEvent<ConfigSourceWorkerResponse>) => {
|
||||
const pending = this.#pending.get(event.data.id);
|
||||
if (!pending) return;
|
||||
this.#pending.delete(event.data.id);
|
||||
if (event.data.ok) pending.resolve(event.data.result);
|
||||
else pending.reject(event.data.error);
|
||||
});
|
||||
worker.addEventListener(
|
||||
"message",
|
||||
(event: MessageEvent<ConfigSourceWorkerResponse>) => {
|
||||
const pending = this.#pending.get(event.data.id);
|
||||
if (!pending) return;
|
||||
this.#pending.delete(event.data.id);
|
||||
if (event.data.ok) pending.resolve(event.data.result);
|
||||
else pending.reject(event.data.error);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
setSnapshot(snapshot: ConfigTreeSnapshot): Promise<void> {
|
||||
return this.#request({ kind: "set_snapshot", snapshot });
|
||||
setSnapshot(
|
||||
snapshot: ConfigTreeSnapshot,
|
||||
schemaBundle: WorkspaceConfigSchemaBundle,
|
||||
): Promise<void> {
|
||||
return this.#request({ kind: "set_snapshot", snapshot, schemaBundle });
|
||||
}
|
||||
applyChanges(changes: ConfigTreeChange[]): Promise<ConfigTreeSnapshot> {
|
||||
return this.#request({ kind: "apply_changes", changes });
|
||||
}
|
||||
changesBetween(base: ConfigTreeSnapshot, candidate: ConfigTreeSnapshot): Promise<ConfigTreeChange[]> {
|
||||
changesBetween(
|
||||
base: ConfigTreeSnapshot,
|
||||
candidate: ConfigTreeSnapshot,
|
||||
): Promise<ConfigTreeChange[]> {
|
||||
return this.#request({ kind: "changes_between", base, candidate });
|
||||
}
|
||||
analyze(path: string, source?: string): Promise<ConfigDiagnostic[]> {
|
||||
return this.#request({ kind: "analyze", path, source });
|
||||
}
|
||||
evaluate(contract: ToolchainContract) {
|
||||
return this.#request({ kind: "evaluate", contract });
|
||||
}
|
||||
complete(path: string, source: string, utf16Offset: number, explicit = false): Promise<import("@codemirror/autocomplete").CompletionResult | null> {
|
||||
return this.#request({ kind: "complete", path, source, utf16Offset, explicit });
|
||||
async complete(
|
||||
path: string,
|
||||
source: string,
|
||||
utf16Offset: number,
|
||||
explicit = false,
|
||||
): Promise<import("@codemirror/autocomplete").CompletionResult | null> {
|
||||
const result = await this.#request<ConfigSourceCompletionResult | null>({
|
||||
kind: "complete",
|
||||
path,
|
||||
source,
|
||||
utf16Offset,
|
||||
explicit,
|
||||
});
|
||||
return toCodeMirrorCompletion(source, result);
|
||||
}
|
||||
format(source: string): Promise<string> {
|
||||
return this.#request({ kind: "format", source });
|
||||
}
|
||||
close(): void {
|
||||
this.#worker.terminate();
|
||||
for (const pending of this.#pending.values()) pending.reject(new Error("config source toolchain was closed"));
|
||||
for (const pending of this.#pending.values()) {
|
||||
pending.reject(new Error("config source toolchain was closed"));
|
||||
}
|
||||
this.#pending.clear();
|
||||
}
|
||||
#request<T>(request: Command): Promise<T> {
|
||||
const id = this.#nextId++;
|
||||
const message = jsonWorkerMessage({ ...request, id });
|
||||
return new Promise<T>((resolve, reject) => {
|
||||
this.#pending.set(id, { resolve: (value) => resolve(value as T), reject });
|
||||
this.#worker.postMessage({ ...request, id });
|
||||
this.#pending.set(id, {
|
||||
resolve: (value) => resolve(value as T),
|
||||
reject,
|
||||
});
|
||||
this.#worker.postMessage(message);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,19 +3,30 @@ import init, {
|
||||
apply_changes,
|
||||
changes_between,
|
||||
complete_current,
|
||||
evaluate_current,
|
||||
format_source,
|
||||
set_schema_bundle,
|
||||
set_snapshot,
|
||||
} from "./generated/config_source_wasm.js";
|
||||
import type { ConfigTreeChange } from "./types.ts";
|
||||
|
||||
export type ConfigSourceWorkerRequest =
|
||||
| { id: number; kind: "set_snapshot"; snapshot: unknown }
|
||||
| {
|
||||
id: number;
|
||||
kind: "set_snapshot";
|
||||
snapshot: unknown;
|
||||
schemaBundle: unknown;
|
||||
}
|
||||
| { id: number; kind: "apply_changes"; changes: ConfigTreeChange[] }
|
||||
| { id: number; kind: "changes_between"; base: unknown; candidate: unknown }
|
||||
| { id: number; kind: "analyze"; path: string; source?: string }
|
||||
| { id: number; kind: "evaluate"; contract: unknown }
|
||||
| { id: number; kind: "complete"; path: string; source: string; utf16Offset: number; explicit: boolean }
|
||||
| {
|
||||
id: number;
|
||||
kind: "complete";
|
||||
path: string;
|
||||
source: string;
|
||||
utf16Offset: number;
|
||||
explicit: boolean;
|
||||
}
|
||||
| { id: number; kind: "format"; source: string };
|
||||
|
||||
export type ConfigSourceWorkerResponse =
|
||||
@@ -25,7 +36,9 @@ export type ConfigSourceWorkerResponse =
|
||||
const ready = init();
|
||||
let snapshot: unknown = null;
|
||||
|
||||
self.onmessage = async (event: MessageEvent<ConfigSourceWorkerRequest>): Promise<void> => {
|
||||
self.onmessage = async (
|
||||
event: MessageEvent<ConfigSourceWorkerRequest>,
|
||||
): Promise<void> => {
|
||||
const request = event.data;
|
||||
try {
|
||||
await ready;
|
||||
@@ -34,6 +47,7 @@ self.onmessage = async (event: MessageEvent<ConfigSourceWorkerRequest>): Promise
|
||||
case "set_snapshot":
|
||||
snapshot = request.snapshot;
|
||||
set_snapshot(request.snapshot);
|
||||
set_schema_bundle(request.schemaBundle);
|
||||
result = null;
|
||||
break;
|
||||
case "apply_changes":
|
||||
@@ -44,14 +58,18 @@ self.onmessage = async (event: MessageEvent<ConfigSourceWorkerRequest>): Promise
|
||||
result = changes_between(request.base, request.candidate);
|
||||
break;
|
||||
case "analyze":
|
||||
if (!snapshot) throw new Error("config source snapshot is not initialized");
|
||||
if (!snapshot) {
|
||||
throw new Error("config source snapshot is not initialized");
|
||||
}
|
||||
result = analyze_snapshot(snapshot, request.path, request.source);
|
||||
break;
|
||||
case "evaluate":
|
||||
result = evaluate_current(request.contract);
|
||||
break;
|
||||
case "complete":
|
||||
result = complete_current(request.path, request.source, request.utf16Offset, request.explicit);
|
||||
result = complete_current(
|
||||
request.path,
|
||||
request.source,
|
||||
request.utf16Offset,
|
||||
request.explicit,
|
||||
);
|
||||
break;
|
||||
case "format":
|
||||
result = format_source(request.source);
|
||||
@@ -59,6 +77,10 @@ self.onmessage = async (event: MessageEvent<ConfigSourceWorkerRequest>): Promise
|
||||
}
|
||||
self.postMessage({ id: request.id, ok: true, result });
|
||||
} catch (error) {
|
||||
self.postMessage({ id: request.id, ok: false, error: error instanceof Error ? error.message : error });
|
||||
self.postMessage({
|
||||
id: request.id,
|
||||
ok: false,
|
||||
error: error instanceof Error ? error.message : error,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
@@ -13,6 +13,4 @@ export type { EvaluationResult } from "./generated/types/EvaluationResult.ts";
|
||||
export type { ToolchainContract } from "./generated/types/ToolchainContract.ts";
|
||||
export type { VirtualPath } from "./generated/types/VirtualPath.ts";
|
||||
export type { ConfigCommitRequest } from "./generated/types/ConfigCommitRequest.ts";
|
||||
export type { ConfigPreviewRequest } from "./generated/types/ConfigPreviewRequest.ts";
|
||||
export type { EvaluatedConfigCandidate } from "./generated/types/EvaluatedConfigCandidate.ts";
|
||||
export type { WorkspaceConfigState as WorkspaceConfigTreeResponse } from "./generated/types/WorkspaceConfigState.ts";
|
||||
|
||||
@@ -0,0 +1,183 @@
|
||||
<script lang="ts">
|
||||
import { taskCounts, type ConsoleTask } from "./tasks.ts";
|
||||
|
||||
type Props = {
|
||||
tasks: ConsoleTask[];
|
||||
mode: "mini" | "pane";
|
||||
};
|
||||
|
||||
let { tasks, mode }: Props = $props();
|
||||
const counts = $derived(taskCounts(tasks));
|
||||
const activeTasks = $derived(
|
||||
tasks
|
||||
.filter((task) => task.status === "pending" || task.status === "inprogress")
|
||||
.slice(0, 3),
|
||||
);
|
||||
|
||||
function mark(status: ConsoleTask["status"]): string {
|
||||
switch (status) {
|
||||
case "pending":
|
||||
return "[ ]";
|
||||
case "inprogress":
|
||||
return "[~]";
|
||||
case "completed":
|
||||
return "[x]";
|
||||
case "deleted":
|
||||
return "[-]";
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
{#if mode === "mini" && tasks.length > 0}
|
||||
<section class="task-mini" aria-label="Worker task summary">
|
||||
{#each activeTasks as task (task.taskid)}
|
||||
<div class="task-mini-row">
|
||||
<span class:inprogress={task.status === "inprogress"} class="task-mark">
|
||||
{mark(task.status)}
|
||||
</span>
|
||||
<span class="task-subject">{task.subject.split("\n", 1)[0]}</span>
|
||||
</div>
|
||||
{/each}
|
||||
<div class="task-summary">
|
||||
{counts.total} task(s) — pending: {counts.pending}, inprogress: {counts.inprogress}, completed: {counts.completed}, deleted: {counts.deleted}
|
||||
</div>
|
||||
</section>
|
||||
{:else if mode === "pane"}
|
||||
<aside class="task-pane" aria-label="Worker tasks">
|
||||
<h3>Tasks ({counts.total})</h3>
|
||||
|
||||
{#if tasks.length === 0}
|
||||
<p class="task-empty">(no tasks)</p>
|
||||
{:else}
|
||||
<ol class="task-list">
|
||||
{#each tasks as task (task.taskid)}
|
||||
{@const subjectLines = task.subject.split("\n")}
|
||||
<li>
|
||||
<div class="task-heading">
|
||||
<span class="task-id">#{task.taskid}</span>
|
||||
<span
|
||||
class:inprogress={task.status === "inprogress"}
|
||||
class:completed={task.status === "completed"}
|
||||
class:deleted={task.status === "deleted"}
|
||||
class="task-mark"
|
||||
>{mark(task.status)}</span>
|
||||
<span>{subjectLines[0] ?? ""}</span>
|
||||
</div>
|
||||
{#each subjectLines.slice(1) as subjectLine}
|
||||
<div class="task-continuation">{subjectLine}</div>
|
||||
{/each}
|
||||
{#if task.description}
|
||||
{#each task.description.split("\n") as descriptionLine}
|
||||
<div class="task-continuation task-description">{descriptionLine}</div>
|
||||
{/each}
|
||||
{/if}
|
||||
</li>
|
||||
{/each}
|
||||
</ol>
|
||||
{/if}
|
||||
</aside>
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
.task-mini,
|
||||
.task-pane {
|
||||
font-family: var(--font-mono);
|
||||
}
|
||||
|
||||
.task-mini {
|
||||
display: grid;
|
||||
gap: 0.1rem;
|
||||
min-width: 0;
|
||||
margin-bottom: -0.75rem;
|
||||
padding-inline: 0.75rem;
|
||||
font-size: 0.8rem;
|
||||
line-height: 1.35;
|
||||
}
|
||||
|
||||
.task-mini-row,
|
||||
.task-heading {
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.task-mark,
|
||||
.task-id {
|
||||
flex: 0 0 auto;
|
||||
color: var(--text-muted);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.task-mark.inprogress {
|
||||
color: var(--warning);
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.task-mark.completed {
|
||||
color: var(--success);
|
||||
}
|
||||
|
||||
.task-mark.deleted {
|
||||
color: var(--danger);
|
||||
}
|
||||
|
||||
.task-subject,
|
||||
.task-summary {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.task-summary,
|
||||
.task-id,
|
||||
.task-empty,
|
||||
.task-description {
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.task-pane {
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
overflow: auto;
|
||||
padding-inline: 1rem;
|
||||
border-left: 1px solid var(--line);
|
||||
}
|
||||
|
||||
.task-pane h3 {
|
||||
margin: 0 0 1rem;
|
||||
color: var(--accent);
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.task-empty {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.task-list {
|
||||
display: grid;
|
||||
gap: 1rem;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
.task-heading {
|
||||
align-items: baseline;
|
||||
}
|
||||
|
||||
.task-continuation {
|
||||
padding-left: 4ch;
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
|
||||
.task-description {
|
||||
font-size: 0.8rem;
|
||||
line-height: 1.45;
|
||||
}
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.task-pane {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -1126,3 +1126,68 @@ Deno.test("projectConsole relativizes known tool path displays from snapshot cwd
|
||||
"Grep should relativize line-start cwd paths and keep outside paths absolute",
|
||||
);
|
||||
});
|
||||
|
||||
Deno.test("projectConsole mirrors live TaskCreate and TaskUpdate calls", () => {
|
||||
const projection = projectConsole([
|
||||
{
|
||||
eventId: "task-create",
|
||||
event: {
|
||||
event: "tool_call_done",
|
||||
data: {
|
||||
id: "task-create-1",
|
||||
name: "TaskCreate",
|
||||
arguments: JSON.stringify({
|
||||
subject: "Port Tasks",
|
||||
description: "Render active Worker tasks",
|
||||
}),
|
||||
},
|
||||
} satisfies Event,
|
||||
},
|
||||
{
|
||||
eventId: "task-update",
|
||||
event: {
|
||||
event: "tool_call_done",
|
||||
data: {
|
||||
id: "task-update-1",
|
||||
name: "TaskUpdate",
|
||||
arguments: JSON.stringify({ taskid: 1, status: "inprogress" }),
|
||||
},
|
||||
} satisfies Event,
|
||||
},
|
||||
]);
|
||||
|
||||
assertEquals(projection.tasks, [{
|
||||
taskid: 1,
|
||||
status: "inprogress",
|
||||
subject: "Port Tasks",
|
||||
description: "Render active Worker tasks",
|
||||
}]);
|
||||
});
|
||||
|
||||
Deno.test("snapshot restores TaskStore state from system history", () => {
|
||||
const taskSnapshot =
|
||||
`[Session TaskStore snapshot]\n\n\`\`\`json\n{\n "tasks": [{"taskid": 3, "status": "pending", "subject": "Restored", "description": "From compaction"}]\n}\n\`\`\``;
|
||||
const event = snapshotEvent("/repo");
|
||||
if (event.event !== "snapshot") throw new Error("snapshot fixture expected");
|
||||
event.data.entries = [{
|
||||
kind: "segment_start",
|
||||
ts: 1,
|
||||
session_id: "00000000-0000-0000-0000-000000000001",
|
||||
system_prompt: null,
|
||||
config: {},
|
||||
history: [{
|
||||
kind: "message",
|
||||
role: "system",
|
||||
content: [{ kind: "text", text: taskSnapshot }],
|
||||
}],
|
||||
}];
|
||||
|
||||
const projection = projectConsole([{ eventId: "task-snapshot", event }]);
|
||||
assertEquals(projection.tasks, [{
|
||||
taskid: 3,
|
||||
status: "pending",
|
||||
subject: "Restored",
|
||||
description: "From compaction",
|
||||
}]);
|
||||
assertEquals(projection.taskNextId, 4);
|
||||
});
|
||||
|
||||
@@ -6,6 +6,11 @@ import type {
|
||||
Segment,
|
||||
} from "$lib/generated/protocol";
|
||||
import { workspaceRoute } from "$lib/workspace/api/http";
|
||||
import {
|
||||
applyTaskSnapshotText,
|
||||
applyTaskToolCall,
|
||||
type ConsoleTask,
|
||||
} from "./tasks.ts";
|
||||
|
||||
export type ConsoleLineKind =
|
||||
| "user"
|
||||
@@ -60,6 +65,8 @@ export type ConsoleLine = {
|
||||
|
||||
export type ConsoleProjection = {
|
||||
lines: ConsoleLine[];
|
||||
tasks: ConsoleTask[];
|
||||
taskNextId: number;
|
||||
status: string | null;
|
||||
usage: string | null;
|
||||
cwd: string | null;
|
||||
@@ -138,6 +145,8 @@ export type ConsoleEventInput = {
|
||||
export function emptyConsoleProjection(): ConsoleProjection {
|
||||
return {
|
||||
lines: [],
|
||||
tasks: [],
|
||||
taskNextId: 1,
|
||||
status: null,
|
||||
usage: null,
|
||||
cwd: null,
|
||||
@@ -189,6 +198,8 @@ export function applyProtocolEvent(
|
||||
): ConsoleProjection {
|
||||
const next: ConsoleProjection = {
|
||||
lines: [...projection.lines],
|
||||
tasks: [...projection.tasks],
|
||||
taskNextId: projection.taskNextId,
|
||||
status: projection.status,
|
||||
usage: projection.usage,
|
||||
cwd: projection.cwd,
|
||||
@@ -209,6 +220,7 @@ export function applyProtocolEvent(
|
||||
break;
|
||||
case "system_item":
|
||||
next.lines.push(systemItemLine(envelope.eventId, event.data.item));
|
||||
applyTaskSystemItem(next, event.data.item);
|
||||
break;
|
||||
case "text_delta":
|
||||
appendStreaming(
|
||||
@@ -267,6 +279,7 @@ export function applyProtocolEvent(
|
||||
argsStream: event.data.arguments,
|
||||
state: "running",
|
||||
});
|
||||
applyTaskTool(next, event.data.name, event.data.arguments);
|
||||
break;
|
||||
case "tool_result":
|
||||
attachToolResult(next, envelope.eventId, event.data.id, {
|
||||
@@ -291,26 +304,36 @@ export function applyProtocolEvent(
|
||||
),
|
||||
);
|
||||
break;
|
||||
case "snapshot":
|
||||
case "snapshot": {
|
||||
next.status = event.data.status;
|
||||
next.cwd = event.data.greeting.cwd;
|
||||
next.lines = snapshotLinesFromEntries(
|
||||
const snapshot = snapshotProjectionFromEntries(
|
||||
envelope.eventId,
|
||||
event.data.entries,
|
||||
next.cwd,
|
||||
);
|
||||
next.lines = snapshot.lines;
|
||||
next.tasks = snapshot.tasks;
|
||||
next.taskNextId = snapshot.taskNextId;
|
||||
for (const block of event.data.in_flight?.blocks ?? []) {
|
||||
next.lines.push(inFlightLine(envelope.eventId, block, next.cwd));
|
||||
}
|
||||
break;
|
||||
}
|
||||
case "status":
|
||||
next.status = event.data.status;
|
||||
break;
|
||||
case "segment_rotated":
|
||||
next.lines = snapshotLinesFromEntries(envelope.eventId, [
|
||||
event.data.entry,
|
||||
], next.cwd);
|
||||
case "segment_rotated": {
|
||||
const segment = snapshotProjectionFromEntries(
|
||||
envelope.eventId,
|
||||
[event.data.entry],
|
||||
next.cwd,
|
||||
);
|
||||
next.lines = segment.lines;
|
||||
next.tasks = segment.tasks;
|
||||
next.taskNextId = segment.taskNextId;
|
||||
break;
|
||||
}
|
||||
case "invoke_start":
|
||||
case "turn_start":
|
||||
case "turn_end":
|
||||
@@ -1111,13 +1134,47 @@ function usageText(
|
||||
} · cache ${data.cache_read_input_tokens ?? "unknown"}`;
|
||||
}
|
||||
|
||||
function snapshotLinesFromEntries(
|
||||
function applyTaskTool(
|
||||
projection: ConsoleProjection,
|
||||
name: string,
|
||||
argumentsJson: string,
|
||||
): void {
|
||||
const state = applyTaskToolCall(
|
||||
{ tasks: projection.tasks, nextTaskId: projection.taskNextId },
|
||||
name,
|
||||
argumentsJson,
|
||||
);
|
||||
projection.tasks = state.tasks;
|
||||
projection.taskNextId = state.nextTaskId;
|
||||
}
|
||||
|
||||
function applyTaskSnapshot(projection: ConsoleProjection, text: string): void {
|
||||
const state = applyTaskSnapshotText(
|
||||
{ tasks: projection.tasks, nextTaskId: projection.taskNextId },
|
||||
text,
|
||||
);
|
||||
projection.tasks = state.tasks;
|
||||
projection.taskNextId = state.nextTaskId;
|
||||
}
|
||||
|
||||
function applyTaskSystemItem(
|
||||
projection: ConsoleProjection,
|
||||
item: unknown,
|
||||
): void {
|
||||
if (!isRecord(item)) return;
|
||||
const body = item["body"];
|
||||
if (typeof body === "string") applyTaskSnapshot(projection, body);
|
||||
}
|
||||
|
||||
function snapshotProjectionFromEntries(
|
||||
eventId: string,
|
||||
entries: unknown[],
|
||||
cwd: string | null,
|
||||
): ConsoleLine[] {
|
||||
): ConsoleProjection {
|
||||
const projection: ConsoleProjection = {
|
||||
lines: [],
|
||||
tasks: [],
|
||||
taskNextId: 1,
|
||||
status: null,
|
||||
usage: null,
|
||||
cwd,
|
||||
@@ -1126,7 +1183,7 @@ function snapshotLinesFromEntries(
|
||||
entries.forEach((entry, index) =>
|
||||
applyLogEntry(projection, `${eventId}-snapshot-${index}`, entry)
|
||||
);
|
||||
return projection.lines;
|
||||
return projection;
|
||||
}
|
||||
|
||||
function applyLogEntry(
|
||||
@@ -1153,6 +1210,7 @@ function applyLogEntry(
|
||||
break;
|
||||
case "system_item":
|
||||
projection.lines.push(systemItemLine(eventId, entry["item"]));
|
||||
applyTaskSystemItem(projection, entry["item"]);
|
||||
break;
|
||||
case "assistant_item":
|
||||
case "tool_result":
|
||||
@@ -1224,6 +1282,9 @@ function applyLoggedItem(
|
||||
case "assistant":
|
||||
projection.lines.push(line(eventId, "assistant", "assistant", body));
|
||||
break;
|
||||
case "system":
|
||||
applyTaskSnapshot(projection, body);
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
@@ -1238,19 +1299,23 @@ function applyLoggedItem(
|
||||
}
|
||||
break;
|
||||
}
|
||||
case "tool_call":
|
||||
case "tool_call": {
|
||||
const name = stringField(item, "name") ?? "Tool";
|
||||
const argumentsJson = stringField(item, "arguments") ?? "";
|
||||
upsertToolCall(
|
||||
projection,
|
||||
eventId,
|
||||
stringField(item, "call_id") ?? eventId,
|
||||
{
|
||||
name: stringField(item, "name") ?? "Tool",
|
||||
arguments: stringField(item, "arguments") ?? "",
|
||||
argsStream: stringField(item, "arguments") ?? "",
|
||||
name,
|
||||
arguments: argumentsJson,
|
||||
argsStream: argumentsJson,
|
||||
state: "running",
|
||||
},
|
||||
);
|
||||
applyTaskTool(projection, name, argumentsJson);
|
||||
break;
|
||||
}
|
||||
case "tool_result":
|
||||
attachToolResult(
|
||||
projection,
|
||||
|
||||
@@ -0,0 +1,131 @@
|
||||
declare const Deno: {
|
||||
test(name: string, fn: () => void): void;
|
||||
};
|
||||
|
||||
import {
|
||||
applyTaskSnapshotText,
|
||||
applyTaskToolCall,
|
||||
emptyConsoleTaskState,
|
||||
taskCounts,
|
||||
} from "./tasks.ts";
|
||||
|
||||
function assert(condition: unknown, message: string): asserts condition {
|
||||
if (!condition) throw new Error(message);
|
||||
}
|
||||
|
||||
Deno.test("TaskCreate and TaskUpdate mirror the active TUI task store", () => {
|
||||
let state = emptyConsoleTaskState();
|
||||
state = applyTaskToolCall(
|
||||
state,
|
||||
"TaskCreate",
|
||||
JSON.stringify({ subject: "first", description: "First detail" }),
|
||||
);
|
||||
state = applyTaskToolCall(
|
||||
state,
|
||||
"TaskCreate",
|
||||
JSON.stringify({ subject: "second", description: "Second detail" }),
|
||||
);
|
||||
state = applyTaskToolCall(
|
||||
state,
|
||||
"TaskUpdate",
|
||||
JSON.stringify({ taskid: 2, status: "inprogress", subject: "working" }),
|
||||
);
|
||||
|
||||
assert(state.tasks.length === 2, "both active tasks should remain visible");
|
||||
assert(
|
||||
state.tasks[0].taskid === 1,
|
||||
"TaskCreate should allocate sequential ids",
|
||||
);
|
||||
assert(
|
||||
state.tasks[1].status === "inprogress",
|
||||
"TaskUpdate should change status",
|
||||
);
|
||||
assert(
|
||||
state.tasks[1].subject === "working",
|
||||
"TaskUpdate should change subject",
|
||||
);
|
||||
assert(
|
||||
state.tasks[1].description === "Second detail",
|
||||
"omitted fields should keep their prior values",
|
||||
);
|
||||
const counts = taskCounts(state.tasks);
|
||||
assert(counts.pending === 1, "one task should be pending");
|
||||
assert(counts.inprogress === 1, "one task should be in progress");
|
||||
});
|
||||
|
||||
Deno.test("completed tasks remain visible until explicitly deleted", () => {
|
||||
let state = emptyConsoleTaskState();
|
||||
for (const subject of ["complete", "delete"]) {
|
||||
state = applyTaskToolCall(
|
||||
state,
|
||||
"TaskCreate",
|
||||
JSON.stringify({ subject, description: "detail" }),
|
||||
);
|
||||
}
|
||||
state = applyTaskToolCall(
|
||||
state,
|
||||
"TaskUpdate",
|
||||
JSON.stringify({ taskid: 1, status: "completed" }),
|
||||
);
|
||||
state = applyTaskToolCall(
|
||||
state,
|
||||
"TaskUpdate",
|
||||
JSON.stringify({ taskid: 2, status: "deleted" }),
|
||||
);
|
||||
assert(state.tasks.length === 1, "only deleted tasks should be forgotten");
|
||||
const counts = taskCounts(state.tasks);
|
||||
assert(counts.completed === 1, "completed tasks should remain counted");
|
||||
assert(counts.deleted === 0, "deleted tasks should be forgotten");
|
||||
assert(counts.active === 0, "completed tasks should not be shown as active");
|
||||
});
|
||||
|
||||
Deno.test("session TaskStore snapshot replaces stale state and advances ids", () => {
|
||||
let state = applyTaskToolCall(
|
||||
emptyConsoleTaskState(),
|
||||
"TaskCreate",
|
||||
JSON.stringify({ subject: "stale", description: "" }),
|
||||
);
|
||||
const snapshot = `[Session TaskStore snapshot]
|
||||
|
||||
TaskStore: 1 active task(s) (pending: 0, inprogress: 1)
|
||||
|
||||
\`\`\`json
|
||||
{
|
||||
"tasks": [
|
||||
{"taskid": 4, "status": "completed", "subject": "old", "description": ""},
|
||||
{"taskid": 7, "status": "inprogress", "subject": "restored", "description": "detail"}
|
||||
]
|
||||
}
|
||||
\`\`\`
|
||||
`;
|
||||
state = applyTaskSnapshotText(state, snapshot);
|
||||
assert(state.tasks.length === 2, "snapshot should restore retained tasks");
|
||||
assert(state.tasks[0].taskid === 4, "the completed task should be restored");
|
||||
assert(state.tasks[1].taskid === 7, "the active task should be restored");
|
||||
assert(
|
||||
state.nextTaskId === 8,
|
||||
"next id should follow the highest retained task id",
|
||||
);
|
||||
state = applyTaskToolCall(
|
||||
state,
|
||||
"TaskCreate",
|
||||
JSON.stringify({ subject: "next", description: "" }),
|
||||
);
|
||||
assert(state.tasks[2].taskid === 8, "new task should use the advanced id");
|
||||
});
|
||||
|
||||
Deno.test("malformed task events are resilient no-ops", () => {
|
||||
const state = emptyConsoleTaskState();
|
||||
assert(
|
||||
applyTaskToolCall(state, "TaskCreate", '{"subject":1}') === state,
|
||||
"invalid TaskCreate should be ignored",
|
||||
);
|
||||
assert(
|
||||
applyTaskToolCall(state, "TaskUpdate", "not json") === state,
|
||||
"invalid TaskUpdate should be ignored",
|
||||
);
|
||||
assert(
|
||||
applyTaskSnapshotText(state, "ordinary system message") === state,
|
||||
"unrelated system messages should be ignored",
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,238 @@
|
||||
export type ConsoleTaskStatus =
|
||||
| "pending"
|
||||
| "inprogress"
|
||||
| "completed"
|
||||
| "deleted";
|
||||
type SnapshotTask = ConsoleTask;
|
||||
|
||||
export type ConsoleTask = {
|
||||
taskid: number;
|
||||
status: ConsoleTaskStatus;
|
||||
subject: string;
|
||||
description: string;
|
||||
};
|
||||
|
||||
export type ConsoleTaskState = {
|
||||
tasks: ConsoleTask[];
|
||||
nextTaskId: number;
|
||||
};
|
||||
|
||||
type TaskUpdate = {
|
||||
taskid: number;
|
||||
status?: ConsoleTaskStatus;
|
||||
subject?: string;
|
||||
description?: string;
|
||||
};
|
||||
|
||||
export function emptyConsoleTaskState(): ConsoleTaskState {
|
||||
return { tasks: [], nextTaskId: 1 };
|
||||
}
|
||||
|
||||
export function applyTaskToolCall(
|
||||
state: ConsoleTaskState,
|
||||
name: string,
|
||||
argumentsJson: string,
|
||||
): ConsoleTaskState {
|
||||
const argumentsValue = parseRecord(argumentsJson);
|
||||
if (!argumentsValue) return state;
|
||||
|
||||
if (name === "TaskCreate") {
|
||||
const subject = stringField(argumentsValue, "subject");
|
||||
const description = stringField(argumentsValue, "description");
|
||||
if (subject === undefined || description === undefined) return state;
|
||||
return {
|
||||
tasks: [
|
||||
...state.tasks,
|
||||
{
|
||||
taskid: state.nextTaskId,
|
||||
status: "pending",
|
||||
subject,
|
||||
description,
|
||||
},
|
||||
],
|
||||
nextTaskId: state.nextTaskId + 1,
|
||||
};
|
||||
}
|
||||
|
||||
if (name !== "TaskUpdate") return state;
|
||||
const update = taskUpdate(argumentsValue);
|
||||
if (!update) return state;
|
||||
const index = state.tasks.findIndex((task) => task.taskid === update.taskid);
|
||||
if (index < 0) return state;
|
||||
|
||||
const current = state.tasks[index];
|
||||
const status = update.status ?? current.status;
|
||||
if (status === "deleted") {
|
||||
return {
|
||||
tasks: state.tasks.filter((_, taskIndex) => taskIndex !== index),
|
||||
nextTaskId: state.nextTaskId,
|
||||
};
|
||||
}
|
||||
|
||||
const tasks = [...state.tasks];
|
||||
tasks[index] = {
|
||||
taskid: current.taskid,
|
||||
status,
|
||||
subject: update.subject ?? current.subject,
|
||||
description: update.description ?? current.description,
|
||||
};
|
||||
return { tasks, nextTaskId: state.nextTaskId };
|
||||
}
|
||||
|
||||
export function applyTaskSnapshotText(
|
||||
state: ConsoleTaskState,
|
||||
text: string,
|
||||
): ConsoleTaskState {
|
||||
const tasks = parseTaskSnapshotText(text);
|
||||
if (!tasks) return state;
|
||||
const retained = tasks.filter((task) => task.status !== "deleted");
|
||||
return {
|
||||
tasks: retained,
|
||||
nextTaskId: Math.max(1, ...retained.map((task) => task.taskid + 1)),
|
||||
};
|
||||
}
|
||||
|
||||
export function parseTaskSnapshotText(text: string): ConsoleTask[] | null {
|
||||
if (!text.startsWith("[Session TaskStore snapshot]")) return null;
|
||||
const startMarker = "```json\n";
|
||||
const start = text.indexOf(startMarker);
|
||||
if (start < 0) return null;
|
||||
const jsonStart = start + startMarker.length;
|
||||
const end = text.indexOf("\n```", jsonStart);
|
||||
if (end < 0) return null;
|
||||
|
||||
let value: unknown;
|
||||
try {
|
||||
value = JSON.parse(text.slice(jsonStart, end));
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
if (!isRecord(value) || !Array.isArray(value.tasks)) return null;
|
||||
|
||||
const tasks: ConsoleTask[] = [];
|
||||
for (const candidate of value.tasks) {
|
||||
const task = taskEntry(candidate);
|
||||
if (!task) return null;
|
||||
if (task.status !== "deleted") {
|
||||
tasks.push(task);
|
||||
}
|
||||
}
|
||||
return tasks;
|
||||
}
|
||||
|
||||
export function taskCounts(tasks: ConsoleTask[]): {
|
||||
pending: number;
|
||||
inprogress: number;
|
||||
completed: number;
|
||||
deleted: number;
|
||||
active: number;
|
||||
total: number;
|
||||
} {
|
||||
let pending = 0;
|
||||
let inprogress = 0;
|
||||
let completed = 0;
|
||||
let deleted = 0;
|
||||
for (const task of tasks) {
|
||||
switch (task.status) {
|
||||
case "pending":
|
||||
pending += 1;
|
||||
break;
|
||||
case "inprogress":
|
||||
inprogress += 1;
|
||||
break;
|
||||
case "completed":
|
||||
completed += 1;
|
||||
break;
|
||||
case "deleted":
|
||||
deleted += 1;
|
||||
break;
|
||||
}
|
||||
}
|
||||
return {
|
||||
pending,
|
||||
inprogress,
|
||||
completed,
|
||||
deleted,
|
||||
active: pending + inprogress,
|
||||
total: tasks.length,
|
||||
};
|
||||
}
|
||||
|
||||
function taskEntry(value: unknown): SnapshotTask | null {
|
||||
if (!isRecord(value)) return null;
|
||||
const taskid = integerField(value, "taskid");
|
||||
const status = statusField(value, "status");
|
||||
const subject = stringField(value, "subject");
|
||||
const description = stringField(value, "description");
|
||||
if (
|
||||
taskid === undefined ||
|
||||
status === undefined ||
|
||||
subject === undefined ||
|
||||
description === undefined
|
||||
) return null;
|
||||
return { taskid, status, subject, description };
|
||||
}
|
||||
|
||||
function taskUpdate(value: Record<string, unknown>): TaskUpdate | null {
|
||||
const taskid = integerField(value, "taskid");
|
||||
if (taskid === undefined) return null;
|
||||
const statusValue = value.status;
|
||||
const status = statusValue === undefined
|
||||
? undefined
|
||||
: statusField(value, "status");
|
||||
if (statusValue !== undefined && status === undefined) return null;
|
||||
const subjectValue = value.subject;
|
||||
const subject = subjectValue === undefined
|
||||
? undefined
|
||||
: stringField(value, "subject");
|
||||
if (subjectValue !== undefined && subject === undefined) return null;
|
||||
const descriptionValue = value.description;
|
||||
const description = descriptionValue === undefined
|
||||
? undefined
|
||||
: stringField(value, "description");
|
||||
if (descriptionValue !== undefined && description === undefined) return null;
|
||||
return { taskid, status, subject, description };
|
||||
}
|
||||
|
||||
function parseRecord(value: string): Record<string, unknown> | null {
|
||||
try {
|
||||
const parsed: unknown = JSON.parse(value);
|
||||
return isRecord(parsed) ? parsed : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === "object" && value !== null && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function stringField(
|
||||
value: Record<string, unknown>,
|
||||
key: string,
|
||||
): string | undefined {
|
||||
return typeof value[key] === "string" ? value[key] : undefined;
|
||||
}
|
||||
|
||||
function integerField(
|
||||
value: Record<string, unknown>,
|
||||
key: string,
|
||||
): number | undefined {
|
||||
const field = value[key];
|
||||
return typeof field === "number" && Number.isSafeInteger(field) && field >= 0
|
||||
? field
|
||||
: undefined;
|
||||
}
|
||||
|
||||
function statusField(
|
||||
value: Record<string, unknown>,
|
||||
key: string,
|
||||
): ConsoleTaskStatus | undefined {
|
||||
const field = value[key];
|
||||
return field === "pending" ||
|
||||
field === "inprogress" ||
|
||||
field === "completed" ||
|
||||
field === "deleted"
|
||||
? field
|
||||
: undefined;
|
||||
}
|
||||
@@ -459,11 +459,11 @@ Deno.test("Decodal source editor keeps imperative EditorView out of reactive sta
|
||||
);
|
||||
|
||||
assert(
|
||||
editor.includes("let view: EditorView | null = null") &&
|
||||
!editor.includes("$state<EditorView") &&
|
||||
editor.includes("let view = $state.raw<EditorView | null>(null)") &&
|
||||
editor.includes("untrack(() => view)") &&
|
||||
editor.includes("untrack(() => value)") &&
|
||||
editor.includes("untrack(() => onChange)"),
|
||||
"CodeMirror EditorView must not be reactive state; otherwise mount cleanup can loop forever",
|
||||
"CodeMirror EditorView must not be deep reactive or tracked by the mount effect; otherwise cleanup can loop forever",
|
||||
);
|
||||
});
|
||||
|
||||
@@ -775,3 +775,41 @@ Deno.test("Workspace Worker list and Console share the multiplexed connection",
|
||||
"A reused Console route should subscribe immediately on the live Workspace socket and install the new route Worker",
|
||||
);
|
||||
});
|
||||
|
||||
Deno.test("Web Console renders the client-projected Worker task store", async () => {
|
||||
const consolePage = await Deno.readTextFile(
|
||||
new URL(
|
||||
"./../../../routes/w/[workspaceId]/runtimes/[runtimeId]/workers/[workerId]/console/+page.svelte",
|
||||
import.meta.url,
|
||||
),
|
||||
);
|
||||
const tasksComponent = await Deno.readTextFile(
|
||||
new URL("./ConsoleTasks.svelte", import.meta.url),
|
||||
);
|
||||
const tasksModel = await Deno.readTextFile(
|
||||
new URL("./tasks.ts", import.meta.url),
|
||||
);
|
||||
|
||||
assert(
|
||||
consolePage.includes("ConsoleTasks") &&
|
||||
consolePage.includes("consoleProjection.tasks") &&
|
||||
consolePage.includes("taskPaneOpen"),
|
||||
"Console should expose the projected task store through its existing client model",
|
||||
);
|
||||
assert(
|
||||
tasksComponent.includes("[ ]") &&
|
||||
tasksComponent.includes("[~]") &&
|
||||
tasksComponent.includes("[x]") &&
|
||||
tasksComponent.includes("[-]") &&
|
||||
tasksComponent.includes("task(s) — pending:") &&
|
||||
tasksComponent.includes("task.description"),
|
||||
"Tasks UI should mirror the TUI status marks, summary, and descriptions",
|
||||
);
|
||||
assert(
|
||||
tasksModel.includes('name === "TaskCreate"') &&
|
||||
tasksModel.includes('name !== "TaskUpdate"') &&
|
||||
tasksModel.includes("[Session TaskStore snapshot]") &&
|
||||
!consolePage.includes("fetchTasks"),
|
||||
"Task projection should replay the protocol client-side without adding a task API",
|
||||
);
|
||||
});
|
||||
|
||||
@@ -1,46 +1,79 @@
|
||||
<script lang="ts">
|
||||
import { untrack } from 'svelte';
|
||||
import { autocompletion, type CompletionContext, type CompletionResult } from '@codemirror/autocomplete';
|
||||
import { EditorState } from '@codemirror/state';
|
||||
import { HighlightStyle, syntaxHighlighting } from '@codemirror/language';
|
||||
import { Compartment, EditorState } from '@codemirror/state';
|
||||
import { EditorView, keymap, lineNumbers, highlightActiveLine, drawSelection } from '@codemirror/view';
|
||||
import { tags } from '@lezer/highlight';
|
||||
import { decodal } from 'decodal-codemirror';
|
||||
import {
|
||||
fixedSchemaWrapperExtension,
|
||||
moveSelectionIntoFixedWrapper,
|
||||
} from '$lib/workspace/config-source/fixed-schema-wrapper.ts';
|
||||
|
||||
let {
|
||||
value = '',
|
||||
readonly = false,
|
||||
ariaLabel = 'Decodal source',
|
||||
fixedSchemaWrapper = false,
|
||||
onChange = (_value: string) => {},
|
||||
onComplete = undefined,
|
||||
}: {
|
||||
value?: string;
|
||||
readonly?: boolean;
|
||||
ariaLabel?: string;
|
||||
fixedSchemaWrapper?: boolean;
|
||||
onChange?: (value: string) => void;
|
||||
onComplete?: (source: string, utf16Offset: number, explicit: boolean) => Promise<CompletionResult | null>;
|
||||
} = $props();
|
||||
|
||||
let host = $state<HTMLDivElement | null>(null);
|
||||
let view: EditorView | null = null;
|
||||
let view = $state.raw<EditorView | null>(null);
|
||||
const readonlyCompartment = new Compartment();
|
||||
const fixedSchemaWrapperCompartment = new Compartment();
|
||||
|
||||
const syntaxTheme = HighlightStyle.define([
|
||||
{ tag: tags.keyword, color: 'var(--accent)', fontWeight: '700' },
|
||||
{ tag: tags.variableName, color: 'var(--code)' },
|
||||
{ tag: [tags.bool, tags.number], color: 'var(--warning)' },
|
||||
{ tag: [tags.string, tags.regexp], color: 'var(--success)' },
|
||||
{ tag: tags.lineComment, color: 'var(--text-muted)', fontStyle: 'italic' },
|
||||
{ tag: tags.operator, color: 'var(--danger)' },
|
||||
{ tag: [tags.brace, tags.squareBracket, tags.paren, tags.punctuation], color: 'var(--accent-muted)' },
|
||||
]);
|
||||
|
||||
const theme = EditorView.theme({
|
||||
'&': {
|
||||
border: '1px solid var(--border-subtle)',
|
||||
border: '1px solid var(--line)',
|
||||
borderRadius: '0.75rem',
|
||||
minHeight: '24rem',
|
||||
background: 'var(--surface-2)',
|
||||
color: 'var(--text-primary)',
|
||||
background: 'var(--bg-raised)',
|
||||
color: 'var(--text)',
|
||||
fontSize: '0.9rem',
|
||||
},
|
||||
'&.cm-focused': { outline: '1px solid var(--accent-muted)', outlineOffset: '-1px' },
|
||||
'.cm-scroller': { fontFamily: 'var(--font-mono)', minHeight: '24rem' },
|
||||
'.cm-content': { padding: '0.75rem 0' },
|
||||
'.cm-gutters': { background: 'var(--surface-2)', color: 'var(--text-muted)' },
|
||||
'.cm-activeLine': { backgroundColor: 'rgba(125, 211, 252, 0.08)' },
|
||||
'.cm-content': { padding: '0.75rem 0', caretColor: 'var(--text-strong)' },
|
||||
'.cm-fixed-schema-wrapper': {
|
||||
color: 'var(--text-muted)',
|
||||
backgroundColor: 'var(--interactive-muted)',
|
||||
fontWeight: '600',
|
||||
},
|
||||
'.cm-cursor, .cm-dropCursor': { borderLeftColor: 'var(--text-strong)', borderLeftWidth: '2px' },
|
||||
'&.cm-focused > .cm-scroller > .cm-selectionLayer .cm-selectionBackground, .cm-selectionBackground, .cm-content ::selection': {
|
||||
backgroundColor: 'var(--interactive-selected)',
|
||||
},
|
||||
'.cm-gutters': { background: 'var(--bg-raised)', color: 'var(--text-muted)', borderColor: 'var(--line)' },
|
||||
'.cm-activeLine, .cm-activeLineGutter': { backgroundColor: 'var(--interactive-hover)' },
|
||||
'.cm-tooltip': { background: 'var(--bg-raised)', color: 'var(--text)', borderColor: 'var(--line-strong)' },
|
||||
'.cm-tooltip-autocomplete > ul > li[aria-selected]': { background: 'var(--interactive-selected)', color: 'var(--text-strong)' },
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
if (!host || view) return;
|
||||
if (!host || untrack(() => view)) return;
|
||||
const initialValue = untrack(() => value);
|
||||
const initialReadonly = untrack(() => readonly);
|
||||
const initialFixedSchemaWrapper = untrack(() => fixedSchemaWrapper);
|
||||
const handleChange = untrack(() => onChange);
|
||||
const handleComplete = untrack(() => onComplete);
|
||||
const editor = new EditorView({
|
||||
@@ -51,14 +84,20 @@
|
||||
lineNumbers(),
|
||||
drawSelection(),
|
||||
highlightActiveLine(),
|
||||
decodal(),
|
||||
decodal({ highlight: false }),
|
||||
syntaxHighlighting(syntaxTheme),
|
||||
...(handleComplete ? [autocompletion({ override: [async (context: CompletionContext) => {
|
||||
const doc = context.state.doc.toString();
|
||||
return await handleComplete(doc, context.pos, context.explicit);
|
||||
}] })] : []),
|
||||
keymap.of([]),
|
||||
EditorState.readOnly.of(initialReadonly),
|
||||
EditorView.editable.of(!initialReadonly),
|
||||
fixedSchemaWrapperCompartment.of(
|
||||
initialFixedSchemaWrapper ? fixedSchemaWrapperExtension() : [],
|
||||
),
|
||||
readonlyCompartment.of([
|
||||
EditorState.readOnly.of(initialReadonly),
|
||||
EditorView.editable.of(!initialReadonly),
|
||||
]),
|
||||
EditorView.updateListener.of((update) => {
|
||||
if (update.docChanged) handleChange(update.state.doc.toString());
|
||||
}),
|
||||
@@ -67,17 +106,46 @@
|
||||
}),
|
||||
});
|
||||
view = editor;
|
||||
if (initialFixedSchemaWrapper) moveSelectionIntoFixedWrapper(editor);
|
||||
return () => {
|
||||
editor.destroy();
|
||||
view = null;
|
||||
};
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
const editor = view;
|
||||
const nextReadonly = readonly;
|
||||
if (!editor) return;
|
||||
editor.dispatch({
|
||||
effects: readonlyCompartment.reconfigure([
|
||||
EditorState.readOnly.of(nextReadonly),
|
||||
EditorView.editable.of(!nextReadonly),
|
||||
]),
|
||||
});
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
const editor = view;
|
||||
const enabled = fixedSchemaWrapper;
|
||||
if (!editor) return;
|
||||
editor.dispatch({
|
||||
effects: fixedSchemaWrapperCompartment.reconfigure(
|
||||
enabled ? fixedSchemaWrapperExtension() : [],
|
||||
),
|
||||
});
|
||||
if (enabled) moveSelectionIntoFixedWrapper(editor);
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
if (!view) return;
|
||||
const current = view.state.doc.toString();
|
||||
if (current !== value) {
|
||||
view.dispatch({ changes: { from: 0, to: current.length, insert: value } });
|
||||
view.dispatch({
|
||||
changes: { from: 0, to: current.length, insert: value },
|
||||
filter: false,
|
||||
});
|
||||
if (fixedSchemaWrapper) moveSelectionIntoFixedWrapper(view);
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
@@ -6,10 +6,6 @@ import {
|
||||
SETTINGS_SECTIONS,
|
||||
settingsSectionHref,
|
||||
} from "./model.ts";
|
||||
import {
|
||||
profileSourceTreeSettingsHref,
|
||||
virtualProfilePathForCreate,
|
||||
} from "./profile-routes.ts";
|
||||
|
||||
declare const Deno: {
|
||||
test(name: string, fn: () => void): void;
|
||||
@@ -24,6 +20,11 @@ function assert(condition: unknown, message: string): asserts condition {
|
||||
Deno.test("settings section navigation stays under the settings route", () => {
|
||||
assert(SETTINGS_ROUTE === "/settings", "settings route should be stable");
|
||||
|
||||
assert(
|
||||
settingsSectionHref("configuration-sources") === "/settings/configuration",
|
||||
"shared configuration editor route should stay canonical",
|
||||
);
|
||||
|
||||
for (const section of SETTINGS_SECTIONS) {
|
||||
const href = settingsSectionHref(section.id);
|
||||
assert(
|
||||
@@ -106,29 +107,3 @@ Deno.test("diagnostic labels preserve severity and code", () => {
|
||||
"diagnostic label should be bounded and stable",
|
||||
);
|
||||
});
|
||||
|
||||
Deno.test("profile source tree routes use encoded scoped ids", () => {
|
||||
const href = profileSourceTreeSettingsHref("workspace a", "project/tree");
|
||||
assert(
|
||||
href === "/w/workspace%20a/settings/profiles/trees/project%2Ftree",
|
||||
`unexpected href ${href}`,
|
||||
);
|
||||
assert(!href.includes("/home/"), "route must not contain host paths");
|
||||
});
|
||||
|
||||
Deno.test("profile source create paths are normalized to virtual profile paths", () => {
|
||||
assert(
|
||||
virtualProfilePathForCreate("alpha.dcdl") === "profiles/alpha.dcdl",
|
||||
"bare file names are scoped",
|
||||
);
|
||||
assert(
|
||||
virtualProfilePathForCreate("profiles/alpha.dcdl") ===
|
||||
"profiles/alpha.dcdl",
|
||||
"virtual paths are preserved",
|
||||
);
|
||||
assert(
|
||||
virtualProfilePathForCreate("project:profiles/alpha.dcdl") ===
|
||||
"project:profiles/alpha.dcdl",
|
||||
"safe virtual namespaces are preserved",
|
||||
);
|
||||
});
|
||||
|
||||
@@ -108,7 +108,7 @@ export const SETTINGS_SECTIONS: readonly SettingsSection[] = [
|
||||
bullets: [
|
||||
"Virtual paths and imports resolve inside the committed Workspace tree, never from browser or Server host paths.",
|
||||
"Browser analysis is advisory; Server evaluation is required before an atomic revision commit.",
|
||||
"Profile, Skill, Prompt, and Plugin consumers remain on their existing authorities until their follow-up cutovers.",
|
||||
"Profile launch data is projected from this active revision; remaining Skill, Prompt, and Plugin consumers migrate in their follow-up cutovers.",
|
||||
],
|
||||
},
|
||||
{
|
||||
@@ -116,11 +116,11 @@ export const SETTINGS_SECTIONS: readonly SettingsSection[] = [
|
||||
label: "Profile Sources",
|
||||
status: "editable",
|
||||
summary:
|
||||
"Manage the workspace-scoped Decodal Profile registry and source files used by Backend-published launch profile discovery.",
|
||||
"Inspect the Profile launch projection derived from the active Workspace configuration revision.",
|
||||
bullets: [
|
||||
"Selectors are source-qualified (builtin:* or project:*); raw profile source paths, archive content, archive digests, resource handles, and runtime tokens are not exposed.",
|
||||
"Profile source edits are validated through the Backend ProfileSourceArchive/Decodal boundary before they are persisted.",
|
||||
"Launch profile candidates refresh from the same Backend projection after registry or source updates.",
|
||||
"Profile declarations and sources are edited only through the shared Workspace configuration editor and evaluate-before-commit contract.",
|
||||
"Launch candidates and Profile archives carry the same active config revision, tree digest, and projection digest.",
|
||||
],
|
||||
},
|
||||
{
|
||||
|
||||
@@ -1,166 +1,72 @@
|
||||
import { workspaceApiJson, workspaceApiJsonWithBody } from "../api/http";
|
||||
import type {
|
||||
ProfileSettingsMutationResponse,
|
||||
ProfileSettingsResponse,
|
||||
WorkspaceMetadataMutationResponse,
|
||||
WorkspaceMetadataSettingsResponse,
|
||||
WorkspaceProfileSourceDetailResponse,
|
||||
WorkspaceProfileSourceTreeFileResponse,
|
||||
WorkspaceProfileSourceTreeResponse,
|
||||
} from "./profile-types";
|
||||
|
||||
export function fetchWorkspaceMetadataSettings(
|
||||
export type WorkspaceProfileApi = {
|
||||
getMetadata(workspaceId: string): Promise<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,
|
||||
): Promise<WorkspaceMetadataSettingsResponse> {
|
||||
return workspaceApiJson(
|
||||
return await requestJson<WorkspaceMetadataSettingsResponse>(
|
||||
`/api/w/${encodeURIComponent(workspaceId)}/settings/workspace`,
|
||||
);
|
||||
}
|
||||
|
||||
export function updateWorkspaceMetadataSettings(
|
||||
export async function updateWorkspaceMetadataSettings(
|
||||
workspaceId: string,
|
||||
request: { display_name: string; revision: string },
|
||||
): Promise<WorkspaceMetadataMutationResponse> {
|
||||
return workspaceApiJsonWithBody(
|
||||
return await requestJson<WorkspaceMetadataMutationResponse>(
|
||||
`/api/w/${encodeURIComponent(workspaceId)}/settings/workspace`,
|
||||
{
|
||||
method: "PUT",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify(request),
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
export function fetchProfileSettings(
|
||||
workspaceId: string,
|
||||
): Promise<ProfileSettingsResponse> {
|
||||
return workspaceApiJson(
|
||||
export async function fetchProfileSettings(workspaceId: string): Promise<ProfileSettingsResponse> {
|
||||
return await requestJson<ProfileSettingsResponse>(
|
||||
`/api/w/${encodeURIComponent(workspaceId)}/settings/profiles`,
|
||||
);
|
||||
}
|
||||
|
||||
export function createProfileSource(
|
||||
workspaceId: string,
|
||||
request: {
|
||||
name: string;
|
||||
description?: string;
|
||||
content: string;
|
||||
registry_revision: string;
|
||||
},
|
||||
): Promise<ProfileSettingsMutationResponse> {
|
||||
return workspaceApiJsonWithBody(
|
||||
`/api/w/${encodeURIComponent(workspaceId)}/settings/profiles`,
|
||||
{
|
||||
method: "POST",
|
||||
body: JSON.stringify(request),
|
||||
export function createWorkspaceProfileApi(): WorkspaceProfileApi {
|
||||
return {
|
||||
async getMetadata(workspaceId) {
|
||||
return await requestJson<WorkspaceMetadataSettingsResponse>(
|
||||
`/api/w/${encodeURIComponent(workspaceId)}/settings/metadata`,
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
export function updateProfileRegistry(
|
||||
workspaceId: string,
|
||||
request: {
|
||||
registry_revision: string;
|
||||
default_profile?: string | null;
|
||||
profiles: Array<
|
||||
{
|
||||
name: string;
|
||||
description?: string | null;
|
||||
profile_source_id?: string | null;
|
||||
}
|
||||
>;
|
||||
},
|
||||
): Promise<ProfileSettingsMutationResponse> {
|
||||
return workspaceApiJsonWithBody(
|
||||
`/api/w/${encodeURIComponent(workspaceId)}/settings/profiles/registry`,
|
||||
{
|
||||
method: "PUT",
|
||||
body: JSON.stringify(request),
|
||||
async updateMetadata(workspaceId, displayName, expectedRevision) {
|
||||
return await requestJson<WorkspaceMetadataMutationResponse>(
|
||||
`/api/w/${encodeURIComponent(workspaceId)}/settings/metadata`,
|
||||
{
|
||||
method: "PUT",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({ display_name: displayName, expected_revision: expectedRevision }),
|
||||
},
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
export function fetchProfileSource(
|
||||
workspaceId: string,
|
||||
sourceId: string,
|
||||
): Promise<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) },
|
||||
);
|
||||
getProfiles: fetchProfileSettings,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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}`;
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user