refactor: rename pod crate to worker
This commit is contained in:
@@ -17,13 +17,13 @@ Owns:
|
||||
Does not own:
|
||||
|
||||
- provider HTTP clients or secret lookup implementation (`provider`, `secrets`)
|
||||
- Pod lifecycle (`pod`)
|
||||
- Worker lifecycle (`worker`)
|
||||
- product CLI parsing (`yoi`)
|
||||
- generated memory records (`memory`)
|
||||
|
||||
## Design notes
|
||||
|
||||
Profiles are reusable recipes; resolved Manifests are runtime contracts. Keep runtime-bound fields such as Pod name, concrete delegated scope, sockets, session pointers, and raw secrets out of reusable Profiles.
|
||||
Profiles are reusable recipes; resolved Manifests are runtime contracts. Keep runtime-bound fields such as Worker name, concrete delegated scope, sockets, session pointers, and raw secrets out of reusable Profiles.
|
||||
|
||||
## See also
|
||||
|
||||
|
||||
+181
-175
@@ -1,10 +1,10 @@
|
||||
//! Partial-form of [`crate::PodManifest`] used as cascade layers.
|
||||
//! Partial-form of [`crate::WorkerManifest`] used as cascade layers.
|
||||
//!
|
||||
//! `PodManifestConfig` mirrors `PodManifest` but every field is optional
|
||||
//! `WorkerManifestConfig` mirrors `WorkerManifest` but every field is optional
|
||||
//! so individual layers (builtin defaults, user manifest, project
|
||||
//! manifest, programmatic overlay) can be partial. Layers are combined
|
||||
//! via [`PodManifestConfig::merge`] and the final config is converted to
|
||||
//! a validated [`PodManifest`] via `TryFrom`.
|
||||
//! via [`WorkerManifestConfig::merge`] and the final config is converted to
|
||||
//! a validated [`WorkerManifest`] via `TryFrom`.
|
||||
|
||||
use std::collections::{BTreeSet, HashMap};
|
||||
use std::num::NonZeroU32;
|
||||
@@ -17,19 +17,19 @@ use crate::defaults;
|
||||
use crate::model::{AuthRef, ModelManifest, ReasoningControl};
|
||||
use crate::plugin::PluginConfig;
|
||||
use crate::{
|
||||
CompactionConfig, FeatureConfig, FeatureFlagConfig, FileUploadLimits, McpConfig, McpEnvValue,
|
||||
McpStdioCwdPolicy, MemoryConfig, PodManifest, PodMeta, ScopeConfig, SessionConfig,
|
||||
CompactionConfig, EngineManifest, FeatureConfig, FeatureFlagConfig, FileUploadLimits,
|
||||
McpConfig, McpEnvValue, McpStdioCwdPolicy, MemoryConfig, ScopeConfig, SessionConfig,
|
||||
SkillsConfig, TicketFeatureAccessConfig, TicketFeatureConfig, ToolOutputLimits,
|
||||
ToolPermissionConfig, ToolPermissionRule, WebConfig, WorkerManifest,
|
||||
ToolPermissionConfig, ToolPermissionRule, WebConfig, WorkerManifest, WorkerMeta,
|
||||
};
|
||||
|
||||
/// Partial-form Pod manifest. Every field is optional; one or more
|
||||
/// instances merge via [`PodManifestConfig::merge`] before being
|
||||
/// converted to a validated [`PodManifest`] via `TryFrom`.
|
||||
/// Partial-form Worker manifest. Every field is optional; one or more
|
||||
/// instances merge via [`WorkerManifestConfig::merge`] before being
|
||||
/// converted to a validated [`WorkerManifest`] via `TryFrom`.
|
||||
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
||||
pub struct PodManifestConfig {
|
||||
pub struct WorkerManifestConfig {
|
||||
#[serde(default)]
|
||||
pub pod: PodMetaConfig,
|
||||
pub worker: WorkerMetaConfig,
|
||||
/// `[model]` セクションは partial でも完成形でも同じ
|
||||
/// [`ModelManifest`] を使う。ref / inline の両形を受け入れるための
|
||||
/// 全 Optional 構造なので、カスケード層と最終マニフェストで型を
|
||||
@@ -37,7 +37,7 @@ pub struct PodManifestConfig {
|
||||
#[serde(default)]
|
||||
pub model: ModelManifest,
|
||||
#[serde(default)]
|
||||
pub worker: WorkerManifestConfig,
|
||||
pub engine: EngineManifestConfig,
|
||||
#[serde(default)]
|
||||
pub scope: ScopeConfig,
|
||||
/// Scope that may be subdelegated to spawned child Pods. Defaults empty.
|
||||
@@ -83,7 +83,7 @@ pub struct FeatureConfigPartial {
|
||||
#[serde(default)]
|
||||
pub web: Option<FeatureFlagConfigPartial>,
|
||||
#[serde(default)]
|
||||
pub pods: Option<FeatureFlagConfigPartial>,
|
||||
pub workers: Option<FeatureFlagConfigPartial>,
|
||||
#[serde(default)]
|
||||
pub ticket: Option<TicketFeatureConfigPartial>,
|
||||
#[serde(default)]
|
||||
@@ -98,7 +98,7 @@ impl FeatureConfigPartial {
|
||||
task: merge_option(self.task, other.task, FeatureFlagConfigPartial::merge),
|
||||
memory: merge_option(self.memory, other.memory, FeatureFlagConfigPartial::merge),
|
||||
web: merge_option(self.web, other.web, FeatureFlagConfigPartial::merge),
|
||||
pods: merge_option(self.pods, other.pods, FeatureFlagConfigPartial::merge),
|
||||
workers: merge_option(self.workers, other.workers, FeatureFlagConfigPartial::merge),
|
||||
ticket: merge_option(self.ticket, other.ticket, TicketFeatureConfigPartial::merge),
|
||||
ticket_orchestration: merge_option(
|
||||
self.ticket_orchestration,
|
||||
@@ -150,7 +150,10 @@ impl From<FeatureConfigPartial> for FeatureConfig {
|
||||
.map(FeatureFlagConfig::from)
|
||||
.unwrap_or_default(),
|
||||
web: value.web.map(FeatureFlagConfig::from).unwrap_or_default(),
|
||||
pods: value.pods.map(FeatureFlagConfig::from).unwrap_or_default(),
|
||||
workers: value
|
||||
.workers
|
||||
.map(FeatureFlagConfig::from)
|
||||
.unwrap_or_default(),
|
||||
ticket: value
|
||||
.ticket
|
||||
.map(TicketFeatureConfig::from)
|
||||
@@ -207,7 +210,7 @@ impl From<FeatureConfig> for FeatureConfigPartial {
|
||||
task: Some(value.task.into()),
|
||||
memory: Some(value.memory.into()),
|
||||
web: Some(value.web.into()),
|
||||
pods: Some(value.pods.into()),
|
||||
workers: Some(value.workers.into()),
|
||||
ticket: Some(value.ticket.into()),
|
||||
ticket_orchestration: Some(value.ticket_orchestration.into()),
|
||||
plugins: Some(value.plugins.into()),
|
||||
@@ -216,18 +219,18 @@ impl From<FeatureConfig> for FeatureConfigPartial {
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
||||
pub struct PodMetaConfig {
|
||||
pub struct WorkerMetaConfig {
|
||||
#[serde(default)]
|
||||
pub name: Option<String>,
|
||||
/// Optional `PromptCatalog` manifest pack override. See
|
||||
/// [`crate::PodMeta::prompt_pack`] for semantics. Relative paths
|
||||
/// are resolved through [`PodManifestConfig::resolve_paths`].
|
||||
/// [`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)]
|
||||
pub struct WorkerManifestConfig {
|
||||
pub struct EngineManifestConfig {
|
||||
#[serde(default)]
|
||||
pub instruction: Option<String>,
|
||||
#[serde(default)]
|
||||
@@ -318,8 +321,8 @@ pub struct CompactionConfigPartial {
|
||||
pub model: Option<ModelManifest>,
|
||||
}
|
||||
|
||||
/// Errors raised when converting a [`PodManifestConfig`] to a validated
|
||||
/// [`PodManifest`] via `TryFrom`.
|
||||
/// Errors raised when converting a [`WorkerManifestConfig`] to a validated
|
||||
/// [`WorkerManifest`] via `TryFrom`.
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum ResolveError {
|
||||
#[error("missing required field: {0}")]
|
||||
@@ -359,7 +362,7 @@ pub(crate) fn reject_removed_manifest_fields(s: &str) -> Result<(), toml::de::Er
|
||||
Ok(())
|
||||
}
|
||||
|
||||
impl PodManifestConfig {
|
||||
impl WorkerManifestConfig {
|
||||
/// Parse a partial manifest from a TOML string. Unknown top-level or
|
||||
/// nested fields emit a `tracing::warn!` and are ignored; use
|
||||
/// `tracing_subscriber` with `WARN` enabled to surface them to the
|
||||
@@ -378,12 +381,12 @@ impl PodManifestConfig {
|
||||
/// from this layer so every per-field default lives at exactly one
|
||||
/// call site (the `defaults` module).
|
||||
///
|
||||
/// `TryFrom<PodManifestConfig>` also reads the same constants as a
|
||||
/// `TryFrom<WorkerManifestConfig>` also reads the same constants as a
|
||||
/// belt-and-suspenders fallback, so a manually-constructed config
|
||||
/// that skips this layer still resolves to the same values.
|
||||
pub fn builtin_defaults() -> Self {
|
||||
Self {
|
||||
worker: WorkerManifestConfig {
|
||||
engine: EngineManifestConfig {
|
||||
tool_output: ToolOutputLimitsPartial {
|
||||
default_max_bytes: Some(defaults::TOOL_OUTPUT_MAX_BYTES),
|
||||
per_tool: HashMap::new(),
|
||||
@@ -415,7 +418,7 @@ impl PodManifestConfig {
|
||||
base.display()
|
||||
);
|
||||
resolve_auth_file(&mut self.model.auth, base);
|
||||
if let Some(ref mut pack) = self.pod.prompt_pack {
|
||||
if let Some(ref mut pack) = self.worker.prompt_pack {
|
||||
*pack = join_if_relative(base, pack);
|
||||
}
|
||||
for rule in &mut self.scope.allow {
|
||||
@@ -457,11 +460,11 @@ impl PodManifestConfig {
|
||||
/// fields from `self`. Map entries merge key-wise with `upper`
|
||||
/// winning on conflict. Scope rules from both layers accumulate
|
||||
/// (see [`ScopeConfig`] semantics).
|
||||
pub fn merge(self, upper: PodManifestConfig) -> Self {
|
||||
pub fn merge(self, upper: WorkerManifestConfig) -> Self {
|
||||
Self {
|
||||
pod: self.pod.merge(upper.pod),
|
||||
model: self.model.merge(upper.model),
|
||||
worker: self.worker.merge(upper.worker),
|
||||
model: self.model.merge(upper.model),
|
||||
engine: self.engine.merge(upper.engine),
|
||||
scope: merge_scope(self.scope, upper.scope),
|
||||
delegation_scope: merge_scope(self.delegation_scope, upper.delegation_scope),
|
||||
session: merge_option(self.session, upper.session, SessionConfigPartial::merge),
|
||||
@@ -575,7 +578,7 @@ impl MemoryConfig {
|
||||
}
|
||||
}
|
||||
|
||||
impl PodMetaConfig {
|
||||
impl WorkerMetaConfig {
|
||||
fn merge(self, upper: Self) -> Self {
|
||||
Self {
|
||||
name: upper.name.or(self.name),
|
||||
@@ -584,7 +587,7 @@ impl PodMetaConfig {
|
||||
}
|
||||
}
|
||||
|
||||
impl WorkerManifestConfig {
|
||||
impl EngineManifestConfig {
|
||||
fn merge(self, upper: Self) -> Self {
|
||||
Self {
|
||||
instruction: upper.instruction.or(self.instruction),
|
||||
@@ -696,9 +699,9 @@ fn join_if_relative(base: &Path, p: &Path) -> PathBuf {
|
||||
}
|
||||
}
|
||||
|
||||
/// Invariant check: every path in a fully-resolved [`PodManifestConfig`]
|
||||
/// Invariant check: every path in a fully-resolved [`WorkerManifestConfig`]
|
||||
/// must be absolute. Relative paths are resolved per-layer via
|
||||
/// [`PodManifestConfig::resolve_paths`]; if one reaches `TryFrom` it
|
||||
/// [`WorkerManifestConfig::resolve_paths`]; if one reaches `TryFrom` it
|
||||
/// indicates a caller skipped the per-layer resolve step.
|
||||
fn ensure_absolute(field: &'static str, path: &Path) -> Result<(), ResolveError> {
|
||||
if path.is_absolute() {
|
||||
@@ -871,45 +874,48 @@ fn bounded_label(value: &str) -> String {
|
||||
out
|
||||
}
|
||||
|
||||
impl TryFrom<PodManifestConfig> for PodManifest {
|
||||
impl TryFrom<WorkerManifestConfig> for WorkerManifest {
|
||||
type Error = ResolveError;
|
||||
|
||||
fn try_from(cfg: PodManifestConfig) -> Result<Self, Self::Error> {
|
||||
let name = cfg.pod.name.ok_or(ResolveError::MissingField("pod.name"))?;
|
||||
let prompt_pack = cfg.pod.prompt_pack;
|
||||
fn try_from(cfg: WorkerManifestConfig) -> Result<Self, Self::Error> {
|
||||
let name = cfg
|
||||
.worker
|
||||
.name
|
||||
.ok_or(ResolveError::MissingField("worker.name"))?;
|
||||
let prompt_pack = cfg.worker.prompt_pack;
|
||||
if let Some(ref p) = prompt_pack {
|
||||
ensure_absolute("pod.prompt_pack", p)?;
|
||||
ensure_absolute("worker.prompt_pack", p)?;
|
||||
}
|
||||
|
||||
validate_model_paths(&cfg.model, "model.auth.file")?;
|
||||
|
||||
let worker = WorkerManifest {
|
||||
let engine = EngineManifest {
|
||||
instruction: cfg
|
||||
.worker
|
||||
.engine
|
||||
.instruction
|
||||
.unwrap_or_else(|| defaults::DEFAULT_INSTRUCTION.to_string()),
|
||||
language: cfg
|
||||
.worker
|
||||
.engine
|
||||
.language
|
||||
.unwrap_or_else(|| defaults::WORKER_LANGUAGE.to_string()),
|
||||
max_tokens: cfg.worker.max_tokens,
|
||||
max_turns: cfg.worker.max_turns,
|
||||
temperature: cfg.worker.temperature,
|
||||
top_p: cfg.worker.top_p,
|
||||
top_k: cfg.worker.top_k,
|
||||
stop_sequences: cfg.worker.stop_sequences.unwrap_or_default(),
|
||||
reasoning: cfg.worker.reasoning,
|
||||
max_tokens: cfg.engine.max_tokens,
|
||||
max_turns: cfg.engine.max_turns,
|
||||
temperature: cfg.engine.temperature,
|
||||
top_p: cfg.engine.top_p,
|
||||
top_k: cfg.engine.top_k,
|
||||
stop_sequences: cfg.engine.stop_sequences.unwrap_or_default(),
|
||||
reasoning: cfg.engine.reasoning,
|
||||
tool_output: ToolOutputLimits {
|
||||
default_max_bytes: cfg
|
||||
.worker
|
||||
.engine
|
||||
.tool_output
|
||||
.default_max_bytes
|
||||
.unwrap_or(defaults::TOOL_OUTPUT_MAX_BYTES),
|
||||
per_tool: cfg.worker.tool_output.per_tool,
|
||||
per_tool: cfg.engine.tool_output.per_tool,
|
||||
},
|
||||
file_upload: FileUploadLimits {
|
||||
max_bytes: cfg
|
||||
.worker
|
||||
.engine
|
||||
.file_upload
|
||||
.max_bytes
|
||||
.unwrap_or(defaults::FILE_UPLOAD_MAX_BYTES),
|
||||
@@ -1007,10 +1013,10 @@ impl TryFrom<PodManifestConfig> for PodManifest {
|
||||
|
||||
validate_mcp_config(&cfg.mcp)?;
|
||||
|
||||
Ok(PodManifest {
|
||||
pod: PodMeta { name, prompt_pack },
|
||||
Ok(WorkerManifest {
|
||||
worker: WorkerMeta { name, prompt_pack },
|
||||
model: cfg.model,
|
||||
worker,
|
||||
engine,
|
||||
scope: cfg.scope,
|
||||
delegation_scope: cfg.delegation_scope,
|
||||
session,
|
||||
@@ -1041,9 +1047,9 @@ mod tests {
|
||||
AuthRef::ApiKey { file: Some(path) }
|
||||
}
|
||||
|
||||
fn minimal_valid() -> PodManifestConfig {
|
||||
PodManifestConfig {
|
||||
pod: PodMetaConfig {
|
||||
fn minimal_valid() -> WorkerManifestConfig {
|
||||
WorkerManifestConfig {
|
||||
worker: WorkerMetaConfig {
|
||||
name: Some("test".into()),
|
||||
prompt_pack: None,
|
||||
},
|
||||
@@ -1052,10 +1058,10 @@ mod tests {
|
||||
model_id: Some("claude-sonnet-4-20250514".into()),
|
||||
..Default::default()
|
||||
},
|
||||
worker: WorkerManifestConfig::default(),
|
||||
engine: EngineManifestConfig::default(),
|
||||
scope: ScopeConfig {
|
||||
allow: vec![ScopeRule {
|
||||
target: abs("/pod"),
|
||||
target: abs("/worker"),
|
||||
permission: Permission::Write,
|
||||
recursive: true,
|
||||
}],
|
||||
@@ -1076,8 +1082,8 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn resolve_minimal_succeeds() {
|
||||
let manifest: PodManifest = minimal_valid().try_into().unwrap();
|
||||
assert_eq!(manifest.pod.name, "test");
|
||||
let manifest: WorkerManifest = minimal_valid().try_into().unwrap();
|
||||
assert_eq!(manifest.worker.name, "test");
|
||||
assert_eq!(manifest.model.scheme, Some(SchemeKind::Anthropic));
|
||||
assert!(manifest.permissions.is_none());
|
||||
}
|
||||
@@ -1113,7 +1119,7 @@ mod tests {
|
||||
},
|
||||
});
|
||||
|
||||
let manifest: PodManifest = cfg.try_into().unwrap();
|
||||
let manifest: WorkerManifest = cfg.try_into().unwrap();
|
||||
|
||||
assert_eq!(manifest.mcp.stdio_servers.len(), 1);
|
||||
let server = &manifest.mcp.stdio_servers[0];
|
||||
@@ -1137,7 +1143,7 @@ mod tests {
|
||||
env: crate::McpEnvConfig::default(),
|
||||
});
|
||||
|
||||
let err = PodManifest::try_from(cfg).unwrap_err();
|
||||
let err = WorkerManifest::try_from(cfg).unwrap_err();
|
||||
assert!(matches!(
|
||||
err,
|
||||
ResolveError::InvalidMcpConfig {
|
||||
@@ -1157,7 +1163,7 @@ mod tests {
|
||||
});
|
||||
}
|
||||
|
||||
let err = PodManifest::try_from(cfg).unwrap_err();
|
||||
let err = WorkerManifest::try_from(cfg).unwrap_err();
|
||||
assert!(matches!(
|
||||
err,
|
||||
ResolveError::InvalidMcpConfig {
|
||||
@@ -1186,7 +1192,7 @@ mod tests {
|
||||
},
|
||||
});
|
||||
|
||||
let err = PodManifest::try_from(cfg).unwrap_err();
|
||||
let err = WorkerManifest::try_from(cfg).unwrap_err();
|
||||
let rendered = err.to_string();
|
||||
assert!(rendered.contains("secret_ref"));
|
||||
assert!(!rendered.contains("bad secret id with spaces"));
|
||||
@@ -1208,7 +1214,7 @@ mod tests {
|
||||
env: crate::McpEnvConfig::default(),
|
||||
});
|
||||
|
||||
let manifest: PodManifest = cfg.try_into().unwrap();
|
||||
let manifest: WorkerManifest = cfg.try_into().unwrap();
|
||||
assert_eq!(
|
||||
manifest.mcp.stdio_servers[0].command,
|
||||
"definitely-not-a-command-yoi-must-spawn"
|
||||
@@ -1222,7 +1228,7 @@ mod tests {
|
||||
record_event_trace: Some(true),
|
||||
});
|
||||
|
||||
let manifest: PodManifest = cfg.try_into().unwrap();
|
||||
let manifest: WorkerManifest = cfg.try_into().unwrap();
|
||||
assert!(manifest.session.record_event_trace);
|
||||
}
|
||||
|
||||
@@ -1234,7 +1240,7 @@ mod tests {
|
||||
rules: Vec::new(),
|
||||
});
|
||||
|
||||
let err = PodManifest::try_from(cfg).unwrap_err();
|
||||
let err = WorkerManifest::try_from(cfg).unwrap_err();
|
||||
|
||||
assert!(matches!(
|
||||
err,
|
||||
@@ -1261,7 +1267,7 @@ mod tests {
|
||||
],
|
||||
});
|
||||
|
||||
let manifest: PodManifest = cfg.try_into().unwrap();
|
||||
let manifest: WorkerManifest = cfg.try_into().unwrap();
|
||||
let permissions = manifest.permissions.unwrap();
|
||||
|
||||
assert_eq!(permissions.default_action, crate::ToolPermissionAction::Ask);
|
||||
@@ -1318,7 +1324,7 @@ mod tests {
|
||||
fn try_from_invariant_rejects_lingering_relative_auth_file() {
|
||||
let mut cfg = minimal_valid();
|
||||
cfg.model.auth = Some(api_key_file_auth(PathBuf::from("keys/relative")));
|
||||
let err = PodManifest::try_from(cfg).unwrap_err();
|
||||
let err = WorkerManifest::try_from(cfg).unwrap_err();
|
||||
assert!(matches!(
|
||||
err,
|
||||
ResolveError::RelativePath {
|
||||
@@ -1332,7 +1338,7 @@ mod tests {
|
||||
fn try_from_invariant_rejects_lingering_relative_scope_target() {
|
||||
let mut cfg = minimal_valid();
|
||||
cfg.scope.allow[0].target = PathBuf::from("docs");
|
||||
let err = PodManifest::try_from(cfg).unwrap_err();
|
||||
let err = WorkerManifest::try_from(cfg).unwrap_err();
|
||||
assert!(matches!(
|
||||
err,
|
||||
ResolveError::RelativePath {
|
||||
@@ -1343,25 +1349,25 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolve_rejects_missing_pod_name() {
|
||||
fn resolve_rejects_missing_worker_name() {
|
||||
let mut cfg = minimal_valid();
|
||||
cfg.pod.name = None;
|
||||
let err = PodManifest::try_from(cfg).unwrap_err();
|
||||
assert!(matches!(err, ResolveError::MissingField("pod.name")));
|
||||
cfg.worker.name = None;
|
||||
let err = WorkerManifest::try_from(cfg).unwrap_err();
|
||||
assert!(matches!(err, ResolveError::MissingField("worker.name")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolve_accepts_empty_scope_for_profile_launch_policy() {
|
||||
let mut cfg = minimal_valid();
|
||||
cfg.scope.allow.clear();
|
||||
let manifest = PodManifest::try_from(cfg).unwrap();
|
||||
let manifest = WorkerManifest::try_from(cfg).unwrap();
|
||||
assert!(manifest.scope.allow.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn merge_scalar_upper_wins() {
|
||||
let lower = PodManifestConfig {
|
||||
pod: PodMetaConfig {
|
||||
let lower = WorkerManifestConfig {
|
||||
worker: WorkerMetaConfig {
|
||||
name: Some("lower".into()),
|
||||
prompt_pack: None,
|
||||
},
|
||||
@@ -1371,30 +1377,30 @@ mod tests {
|
||||
},
|
||||
..Default::default()
|
||||
};
|
||||
let upper = PodManifestConfig {
|
||||
pod: PodMetaConfig {
|
||||
let upper = WorkerManifestConfig {
|
||||
worker: WorkerMetaConfig {
|
||||
name: Some("upper".into()),
|
||||
prompt_pack: None,
|
||||
},
|
||||
..Default::default()
|
||||
};
|
||||
let merged = lower.merge(upper);
|
||||
assert_eq!(merged.pod.name.as_deref(), Some("upper"));
|
||||
assert_eq!(merged.worker.name.as_deref(), Some("upper"));
|
||||
// model_id not present in upper — retain lower
|
||||
assert_eq!(merged.model.model_id.as_deref(), Some("lower-model"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn merge_worker_reasoning_upper_wins() {
|
||||
let lower = PodManifestConfig {
|
||||
worker: WorkerManifestConfig {
|
||||
let lower = WorkerManifestConfig {
|
||||
engine: EngineManifestConfig {
|
||||
reasoning: Some(ReasoningControl::Effort(ReasoningEffort::Low)),
|
||||
..Default::default()
|
||||
},
|
||||
..Default::default()
|
||||
};
|
||||
let upper = PodManifestConfig {
|
||||
worker: WorkerManifestConfig {
|
||||
let upper = WorkerManifestConfig {
|
||||
engine: EngineManifestConfig {
|
||||
reasoning: Some(ReasoningControl::BudgetTokens(4096)),
|
||||
..Default::default()
|
||||
},
|
||||
@@ -1404,15 +1410,15 @@ mod tests {
|
||||
let merged = lower.merge(upper);
|
||||
|
||||
assert_eq!(
|
||||
merged.worker.reasoning,
|
||||
merged.engine.reasoning,
|
||||
Some(ReasoningControl::BudgetTokens(4096))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn merge_worker_generation_settings_upper_wins() {
|
||||
let lower = PodManifestConfig {
|
||||
worker: WorkerManifestConfig {
|
||||
let lower = WorkerManifestConfig {
|
||||
engine: EngineManifestConfig {
|
||||
top_p: Some(0.8),
|
||||
top_k: Some(20),
|
||||
stop_sequences: Some(vec!["lower".into()]),
|
||||
@@ -1420,8 +1426,8 @@ mod tests {
|
||||
},
|
||||
..Default::default()
|
||||
};
|
||||
let upper = PodManifestConfig {
|
||||
worker: WorkerManifestConfig {
|
||||
let upper = WorkerManifestConfig {
|
||||
engine: EngineManifestConfig {
|
||||
top_p: Some(0.9),
|
||||
stop_sequences: Some(vec!["upper".into()]),
|
||||
..Default::default()
|
||||
@@ -1431,14 +1437,14 @@ mod tests {
|
||||
|
||||
let merged = lower.merge(upper);
|
||||
|
||||
assert_eq!(merged.worker.top_p, Some(0.9));
|
||||
assert_eq!(merged.worker.top_k, Some(20));
|
||||
assert_eq!(merged.worker.stop_sequences, Some(vec!["upper".into()]));
|
||||
assert_eq!(merged.engine.top_p, Some(0.9));
|
||||
assert_eq!(merged.engine.top_k, Some(20));
|
||||
assert_eq!(merged.engine.stop_sequences, Some(vec!["upper".into()]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn merge_scope_accumulates_allow_and_deny() {
|
||||
let lower = PodManifestConfig {
|
||||
let lower = WorkerManifestConfig {
|
||||
scope: ScopeConfig {
|
||||
allow: vec![ScopeRule {
|
||||
target: abs("/a"),
|
||||
@@ -1449,7 +1455,7 @@ mod tests {
|
||||
},
|
||||
..Default::default()
|
||||
};
|
||||
let upper = PodManifestConfig {
|
||||
let upper = WorkerManifestConfig {
|
||||
scope: ScopeConfig {
|
||||
allow: vec![ScopeRule {
|
||||
target: abs("/b"),
|
||||
@@ -1471,7 +1477,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn merge_permissions_accumulates_rules_and_upper_default_wins() {
|
||||
let lower = PodManifestConfig {
|
||||
let lower = WorkerManifestConfig {
|
||||
permissions: Some(PermissionConfigPartial {
|
||||
default_action: Some(crate::ToolPermissionAction::Allow),
|
||||
rules: vec![ToolPermissionRule {
|
||||
@@ -1482,7 +1488,7 @@ mod tests {
|
||||
}),
|
||||
..Default::default()
|
||||
};
|
||||
let upper = PodManifestConfig {
|
||||
let upper = WorkerManifestConfig {
|
||||
permissions: Some(PermissionConfigPartial {
|
||||
default_action: Some(crate::ToolPermissionAction::Deny),
|
||||
rules: vec![ToolPermissionRule {
|
||||
@@ -1507,8 +1513,8 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn merge_tool_output_per_tool_keywise() {
|
||||
let lower = PodManifestConfig {
|
||||
worker: WorkerManifestConfig {
|
||||
let lower = WorkerManifestConfig {
|
||||
engine: EngineManifestConfig {
|
||||
tool_output: ToolOutputLimitsPartial {
|
||||
default_max_bytes: Some(8192),
|
||||
per_tool: [("Read".to_string(), 1024)].into_iter().collect(),
|
||||
@@ -1517,8 +1523,8 @@ mod tests {
|
||||
},
|
||||
..Default::default()
|
||||
};
|
||||
let upper = PodManifestConfig {
|
||||
worker: WorkerManifestConfig {
|
||||
let upper = WorkerManifestConfig {
|
||||
engine: EngineManifestConfig {
|
||||
tool_output: ToolOutputLimitsPartial {
|
||||
default_max_bytes: None,
|
||||
per_tool: [("Read".to_string(), 2048), ("Grep".to_string(), 512)]
|
||||
@@ -1530,7 +1536,7 @@ mod tests {
|
||||
..Default::default()
|
||||
};
|
||||
let merged = lower.merge(upper);
|
||||
let to = &merged.worker.tool_output;
|
||||
let to = &merged.engine.tool_output;
|
||||
assert_eq!(to.default_max_bytes, Some(8192));
|
||||
assert_eq!(to.per_tool.get("Read"), Some(&2048));
|
||||
assert_eq!(to.per_tool.get("Grep"), Some(&512));
|
||||
@@ -1538,8 +1544,8 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn merge_file_upload_max_bytes_upper_wins() {
|
||||
let lower = PodManifestConfig {
|
||||
worker: WorkerManifestConfig {
|
||||
let lower = WorkerManifestConfig {
|
||||
engine: EngineManifestConfig {
|
||||
file_upload: FileUploadLimitsPartial {
|
||||
max_bytes: Some(8192),
|
||||
},
|
||||
@@ -1547,8 +1553,8 @@ mod tests {
|
||||
},
|
||||
..Default::default()
|
||||
};
|
||||
let upper = PodManifestConfig {
|
||||
worker: WorkerManifestConfig {
|
||||
let upper = WorkerManifestConfig {
|
||||
engine: EngineManifestConfig {
|
||||
file_upload: FileUploadLimitsPartial {
|
||||
max_bytes: Some(54_321),
|
||||
},
|
||||
@@ -1557,11 +1563,11 @@ mod tests {
|
||||
..Default::default()
|
||||
};
|
||||
let merged = lower.merge(upper);
|
||||
assert_eq!(merged.worker.file_upload.max_bytes, Some(54_321));
|
||||
assert_eq!(merged.engine.file_upload.max_bytes, Some(54_321));
|
||||
}
|
||||
#[test]
|
||||
fn merge_option_struct_field_wise() {
|
||||
let lower = PodManifestConfig {
|
||||
let lower = WorkerManifestConfig {
|
||||
compaction: Some(CompactionConfigPartial {
|
||||
threshold: Some(50_000),
|
||||
prune_protected_tokens: Some(5_000),
|
||||
@@ -1577,7 +1583,7 @@ mod tests {
|
||||
}),
|
||||
..Default::default()
|
||||
};
|
||||
let upper = PodManifestConfig {
|
||||
let upper = WorkerManifestConfig {
|
||||
compaction: Some(CompactionConfigPartial {
|
||||
threshold: Some(80_000),
|
||||
..Default::default()
|
||||
@@ -1604,32 +1610,32 @@ mod tests {
|
||||
#[test]
|
||||
fn from_toml_type_mismatch_is_hard_error() {
|
||||
let bad = r#"
|
||||
[pod]
|
||||
[worker]
|
||||
name = "x"
|
||||
|
||||
[worker]
|
||||
[engine]
|
||||
max_tokens = "not-a-number"
|
||||
"#;
|
||||
assert!(PodManifestConfig::from_toml(bad).is_err());
|
||||
assert!(WorkerManifestConfig::from_toml(bad).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn from_toml_accepts_unknown_field() {
|
||||
// Unknown keys are warn-and-ignored, not hard errors.
|
||||
// `pod.pwd` specifically is silently dropped after the
|
||||
// `worker.pwd` specifically is silently dropped after the
|
||||
// path-resolution ticket — keep it in the fixture to exercise
|
||||
// that code path.
|
||||
let ok = r#"
|
||||
[pod]
|
||||
[worker]
|
||||
name = "x"
|
||||
pwd = "/obsolete"
|
||||
|
||||
[worker]
|
||||
[engine]
|
||||
max_tokens = 1000
|
||||
unknown_future_field = "tolerated"
|
||||
"#;
|
||||
let cfg = PodManifestConfig::from_toml(ok).unwrap();
|
||||
assert_eq!(cfg.worker.max_tokens, Some(1000));
|
||||
let cfg = WorkerManifestConfig::from_toml(ok).unwrap();
|
||||
assert_eq!(cfg.engine.max_tokens, Some(1000));
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -1638,7 +1644,7 @@ unknown_future_field = "tolerated"
|
||||
[compaction]
|
||||
prune_protected_turns = 3
|
||||
"#;
|
||||
let err = PodManifestConfig::from_toml(bad).unwrap_err();
|
||||
let err = WorkerManifestConfig::from_toml(bad).unwrap_err();
|
||||
assert!(
|
||||
err.to_string().contains("compaction.prune_protected_turns"),
|
||||
"unexpected error: {err}"
|
||||
@@ -1651,7 +1657,7 @@ prune_protected_turns = 3
|
||||
[memory]
|
||||
extract_worker_max_input_tokens = 30000
|
||||
"#;
|
||||
let err = PodManifestConfig::from_toml(bad).unwrap_err();
|
||||
let err = WorkerManifestConfig::from_toml(bad).unwrap_err();
|
||||
assert!(
|
||||
err.to_string()
|
||||
.contains("memory.extract_worker_max_input_tokens"),
|
||||
@@ -1661,7 +1667,7 @@ extract_worker_max_input_tokens = 30000
|
||||
|
||||
#[test]
|
||||
fn from_toml_accepts_extract_worker_max_turns() {
|
||||
let cfg = PodManifestConfig::from_toml(
|
||||
let cfg = WorkerManifestConfig::from_toml(
|
||||
r#"
|
||||
[memory]
|
||||
extract_worker_max_turns = 2
|
||||
@@ -1673,36 +1679,36 @@ extract_worker_max_turns = 2
|
||||
|
||||
#[test]
|
||||
fn from_toml_accepts_worker_reasoning_string_or_integer() {
|
||||
let effort = PodManifestConfig::from_toml(
|
||||
let effort = WorkerManifestConfig::from_toml(
|
||||
r#"
|
||||
[worker]
|
||||
[engine]
|
||||
reasoning = "xhigh"
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
effort.worker.reasoning,
|
||||
effort.engine.reasoning,
|
||||
Some(ReasoningControl::Effort(ReasoningEffort::XHigh))
|
||||
);
|
||||
|
||||
let budget = PodManifestConfig::from_toml(
|
||||
let budget = WorkerManifestConfig::from_toml(
|
||||
r#"
|
||||
[worker]
|
||||
[engine]
|
||||
reasoning = -1
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
budget.worker.reasoning,
|
||||
budget.engine.reasoning,
|
||||
Some(ReasoningControl::BudgetTokens(-1))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn from_toml_accepts_worker_generation_settings() {
|
||||
let cfg = PodManifestConfig::from_toml(
|
||||
let cfg = WorkerManifestConfig::from_toml(
|
||||
r#"
|
||||
[worker]
|
||||
[engine]
|
||||
top_p = 0.9
|
||||
top_k = 40
|
||||
stop_sequences = ["\n\n", "</stop>"]
|
||||
@@ -1710,17 +1716,17 @@ stop_sequences = ["\n\n", "</stop>"]
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(cfg.worker.top_p, Some(0.9));
|
||||
assert_eq!(cfg.worker.top_k, Some(40));
|
||||
assert_eq!(cfg.engine.top_p, Some(0.9));
|
||||
assert_eq!(cfg.engine.top_k, Some(40));
|
||||
assert_eq!(
|
||||
cfg.worker.stop_sequences,
|
||||
cfg.engine.stop_sequences,
|
||||
Some(vec!["\n\n".into(), "</stop>".into()])
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn from_toml_accepts_worker_max_turns() {
|
||||
let cfg = PodManifestConfig::from_toml(
|
||||
let cfg = WorkerManifestConfig::from_toml(
|
||||
r#"
|
||||
[compaction]
|
||||
worker_max_turns = 7
|
||||
@@ -1736,7 +1742,7 @@ worker_max_turns = 7
|
||||
let mut cfg = minimal_valid();
|
||||
cfg.compaction = Some(CompactionConfigPartial::default());
|
||||
|
||||
let manifest = PodManifest::try_from(cfg).unwrap();
|
||||
let manifest = WorkerManifest::try_from(cfg).unwrap();
|
||||
|
||||
assert_eq!(
|
||||
manifest.compaction.unwrap().worker_max_turns,
|
||||
@@ -1746,18 +1752,18 @@ worker_max_turns = 7
|
||||
|
||||
#[test]
|
||||
fn feature_flags_default_disabled_in_resolved_manifest() {
|
||||
let manifest: PodManifest = minimal_valid().try_into().unwrap();
|
||||
let manifest: WorkerManifest = minimal_valid().try_into().unwrap();
|
||||
assert!(!manifest.feature.task.enabled);
|
||||
assert!(!manifest.feature.memory.enabled);
|
||||
assert!(!manifest.feature.web.enabled);
|
||||
assert!(!manifest.feature.pods.enabled);
|
||||
assert!(!manifest.feature.workers.enabled);
|
||||
assert!(!manifest.feature.ticket.enabled);
|
||||
assert!(!manifest.feature.ticket_orchestration.enabled);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn from_toml_parses_explicit_feature_flags() {
|
||||
let cfg = PodManifestConfig::from_toml(
|
||||
let cfg = WorkerManifestConfig::from_toml(
|
||||
r#"
|
||||
[feature.task]
|
||||
enabled = true
|
||||
@@ -1771,10 +1777,10 @@ enabled = true
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
let manifest: PodManifest = PodManifestConfig::builtin_defaults()
|
||||
let manifest: WorkerManifest = WorkerManifestConfig::builtin_defaults()
|
||||
.merge(cfg)
|
||||
.merge(PodManifestConfig {
|
||||
pod: PodMetaConfig {
|
||||
.merge(WorkerManifestConfig {
|
||||
worker: WorkerMetaConfig {
|
||||
name: Some("feature-test".into()),
|
||||
prompt_pack: None,
|
||||
},
|
||||
@@ -1785,7 +1791,7 @@ enabled = true
|
||||
},
|
||||
scope: ScopeConfig {
|
||||
allow: vec![ScopeRule {
|
||||
target: abs("/pod"),
|
||||
target: abs("/worker"),
|
||||
permission: Permission::Read,
|
||||
recursive: true,
|
||||
}],
|
||||
@@ -1807,7 +1813,7 @@ enabled = true
|
||||
|
||||
#[test]
|
||||
fn feature_flags_merge_as_partial_profile_layers() {
|
||||
let base = PodManifestConfig::from_toml(
|
||||
let base = WorkerManifestConfig::from_toml(
|
||||
r#"
|
||||
[feature.memory]
|
||||
enabled = true
|
||||
@@ -1818,7 +1824,7 @@ access = "read_only"
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
let upper = PodManifestConfig::from_toml(
|
||||
let upper = WorkerManifestConfig::from_toml(
|
||||
r#"
|
||||
[feature.ticket]
|
||||
access = "lifecycle"
|
||||
@@ -1828,11 +1834,11 @@ enabled = true
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
let manifest: PodManifest = PodManifestConfig::builtin_defaults()
|
||||
let manifest: WorkerManifest = WorkerManifestConfig::builtin_defaults()
|
||||
.merge(base)
|
||||
.merge(upper)
|
||||
.merge(PodManifestConfig {
|
||||
pod: PodMetaConfig {
|
||||
.merge(WorkerManifestConfig {
|
||||
worker: WorkerMetaConfig {
|
||||
name: Some("feature-merge-test".into()),
|
||||
prompt_pack: None,
|
||||
},
|
||||
@@ -1843,7 +1849,7 @@ enabled = true
|
||||
},
|
||||
scope: ScopeConfig {
|
||||
allow: vec![ScopeRule {
|
||||
target: abs("/pod"),
|
||||
target: abs("/worker"),
|
||||
permission: Permission::Read,
|
||||
recursive: true,
|
||||
}],
|
||||
@@ -1860,7 +1866,7 @@ enabled = true
|
||||
TicketFeatureAccessConfig::Lifecycle
|
||||
);
|
||||
assert!(manifest.feature.web.enabled);
|
||||
assert!(!manifest.feature.pods.enabled);
|
||||
assert!(!manifest.feature.workers.enabled);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -1871,20 +1877,20 @@ enabled = true
|
||||
target = "/abs/project"
|
||||
permission = "write"
|
||||
"#;
|
||||
let cfg = PodManifestConfig::from_toml(toml).unwrap();
|
||||
assert!(cfg.pod.name.is_none());
|
||||
let cfg = WorkerManifestConfig::from_toml(toml).unwrap();
|
||||
assert!(cfg.worker.name.is_none());
|
||||
assert_eq!(cfg.scope.allow.len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn builtin_defaults_populates_worker_limit_defaults() {
|
||||
let cfg = PodManifestConfig::builtin_defaults();
|
||||
let cfg = WorkerManifestConfig::builtin_defaults();
|
||||
assert_eq!(
|
||||
cfg.worker.tool_output.default_max_bytes,
|
||||
cfg.engine.tool_output.default_max_bytes,
|
||||
Some(defaults::TOOL_OUTPUT_MAX_BYTES)
|
||||
);
|
||||
assert_eq!(
|
||||
cfg.worker.file_upload.max_bytes,
|
||||
cfg.engine.file_upload.max_bytes,
|
||||
Some(defaults::FILE_UPLOAD_MAX_BYTES)
|
||||
);
|
||||
}
|
||||
@@ -1892,10 +1898,10 @@ permission = "write"
|
||||
#[test]
|
||||
fn builtin_defaults_merged_into_minimal_resolves_with_defaults() {
|
||||
// Starting from builtin_defaults and overlaying only the
|
||||
// required fields must resolve to a PodManifest carrying the
|
||||
// required fields must resolve to a WorkerManifest carrying the
|
||||
// centralised default values.
|
||||
let overlay = PodManifestConfig {
|
||||
pod: PodMetaConfig {
|
||||
let overlay = WorkerManifestConfig {
|
||||
worker: WorkerMetaConfig {
|
||||
name: Some("x".into()),
|
||||
prompt_pack: None,
|
||||
},
|
||||
@@ -1906,7 +1912,7 @@ permission = "write"
|
||||
},
|
||||
scope: ScopeConfig {
|
||||
allow: vec![ScopeRule {
|
||||
target: abs("/pod"),
|
||||
target: abs("/worker"),
|
||||
permission: Permission::Write,
|
||||
recursive: true,
|
||||
}],
|
||||
@@ -1914,22 +1920,22 @@ permission = "write"
|
||||
},
|
||||
..Default::default()
|
||||
};
|
||||
let merged = PodManifestConfig::builtin_defaults().merge(overlay);
|
||||
let manifest: PodManifest = merged.try_into().unwrap();
|
||||
let merged = WorkerManifestConfig::builtin_defaults().merge(overlay);
|
||||
let manifest: WorkerManifest = merged.try_into().unwrap();
|
||||
assert_eq!(
|
||||
manifest.worker.tool_output.default_max_bytes,
|
||||
manifest.engine.tool_output.default_max_bytes,
|
||||
defaults::TOOL_OUTPUT_MAX_BYTES
|
||||
);
|
||||
assert_eq!(
|
||||
manifest.worker.file_upload.max_bytes,
|
||||
manifest.engine.file_upload.max_bytes,
|
||||
defaults::FILE_UPLOAD_MAX_BYTES
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn end_to_end_cascade() {
|
||||
let builtin = PodManifestConfig::default();
|
||||
let user = PodManifestConfig::from_toml(
|
||||
let builtin = WorkerManifestConfig::default();
|
||||
let user = WorkerManifestConfig::from_toml(
|
||||
r#"
|
||||
[model]
|
||||
scheme = "anthropic"
|
||||
@@ -1937,7 +1943,7 @@ model_id = "claude-sonnet-4-20250514"
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
let project = PodManifestConfig::from_toml(
|
||||
let project = WorkerManifestConfig::from_toml(
|
||||
r#"
|
||||
[[scope.allow]]
|
||||
target = "/abs/project"
|
||||
@@ -1945,17 +1951,17 @@ permission = "write"
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
let overlay = PodManifestConfig::from_toml(
|
||||
let overlay = WorkerManifestConfig::from_toml(
|
||||
r#"
|
||||
[pod]
|
||||
[worker]
|
||||
name = "dbg"
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let merged = builtin.merge(user).merge(project).merge(overlay);
|
||||
let manifest: PodManifest = merged.try_into().unwrap();
|
||||
assert_eq!(manifest.pod.name, "dbg");
|
||||
let manifest: WorkerManifest = merged.try_into().unwrap();
|
||||
assert_eq!(manifest.worker.name, "dbg");
|
||||
assert_eq!(manifest.model.scheme, Some(SchemeKind::Anthropic));
|
||||
assert_eq!(manifest.scope.allow.len(), 1);
|
||||
}
|
||||
@@ -1981,7 +1987,7 @@ name = "dbg"
|
||||
cfg.skills = Some(SkillsConfig {
|
||||
directories: vec![PathBuf::from("relative/skills")],
|
||||
});
|
||||
let err = PodManifest::try_from(cfg).unwrap_err();
|
||||
let err = WorkerManifest::try_from(cfg).unwrap_err();
|
||||
assert!(matches!(
|
||||
err,
|
||||
ResolveError::RelativePath {
|
||||
@@ -1993,13 +1999,13 @@ name = "dbg"
|
||||
|
||||
#[test]
|
||||
fn skills_merge_extends_directories() {
|
||||
let lower = PodManifestConfig {
|
||||
let lower = WorkerManifestConfig {
|
||||
skills: Some(SkillsConfig {
|
||||
directories: vec![PathBuf::from("/a")],
|
||||
}),
|
||||
..Default::default()
|
||||
};
|
||||
let upper = PodManifestConfig {
|
||||
let upper = WorkerManifestConfig {
|
||||
skills: Some(SkillsConfig {
|
||||
directories: vec![PathBuf::from("/b")],
|
||||
}),
|
||||
@@ -2013,13 +2019,13 @@ name = "dbg"
|
||||
#[test]
|
||||
fn from_toml_parses_skills_section() {
|
||||
let toml = r#"
|
||||
[pod]
|
||||
[worker]
|
||||
name = "x"
|
||||
|
||||
[skills]
|
||||
directories = [".claude/skills", ".cursor/skills"]
|
||||
"#;
|
||||
let cfg = PodManifestConfig::from_toml(toml).unwrap();
|
||||
let cfg = WorkerManifestConfig::from_toml(toml).unwrap();
|
||||
let dirs = cfg.skills.unwrap().directories;
|
||||
assert_eq!(
|
||||
dirs,
|
||||
@@ -2032,14 +2038,14 @@ directories = [".claude/skills", ".cursor/skills"]
|
||||
|
||||
#[test]
|
||||
fn merge_preserves_ref() {
|
||||
let lower = PodManifestConfig {
|
||||
let lower = WorkerManifestConfig {
|
||||
model: ModelManifest {
|
||||
ref_: Some("anthropic/claude-sonnet-4-6".into()),
|
||||
..Default::default()
|
||||
},
|
||||
..Default::default()
|
||||
};
|
||||
let upper = PodManifestConfig {
|
||||
let upper = WorkerManifestConfig {
|
||||
model: ModelManifest {
|
||||
// only override auth
|
||||
auth: Some(AuthRef::None),
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
//! Single source of truth for manifest default values.
|
||||
//!
|
||||
//! Every default that would otherwise be duplicated between serde
|
||||
//! `#[serde(default = "...")]` attributes (on [`crate::PodManifest`])
|
||||
//! `#[serde(default = "...")]` attributes (on [`crate::WorkerManifest`])
|
||||
//! and the cascade resolution in [`crate::config`] lives here as a
|
||||
//! `pub const`. Both paths read from this module, so changing a
|
||||
//! default requires editing exactly one line.
|
||||
@@ -48,7 +48,7 @@ pub const COMPACT_OVERVIEW_DEADLINE_TOKENS: u64 = 40_000;
|
||||
pub const DEFAULT_INSTRUCTION: &str = "$yoi/default";
|
||||
|
||||
/// Default language policy used by the main worker for normal prose
|
||||
/// responses. See [`crate::WorkerManifest::language`].
|
||||
/// responses. See [`crate::EngineManifest::language`].
|
||||
pub const WORKER_LANGUAGE: &str =
|
||||
"match the user's language unless they explicitly request another language";
|
||||
|
||||
|
||||
+93
-93
@@ -7,9 +7,9 @@ mod profile;
|
||||
mod scope;
|
||||
|
||||
pub use config::{
|
||||
CompactionConfigPartial, FileUploadLimitsPartial, PermissionConfigPartial, PodManifestConfig,
|
||||
PodMetaConfig, ResolveError, SessionConfigPartial, ToolOutputLimitsPartial,
|
||||
WorkerManifestConfig,
|
||||
CompactionConfigPartial, EngineManifestConfig, FileUploadLimitsPartial,
|
||||
PermissionConfigPartial, ResolveError, SessionConfigPartial, ToolOutputLimitsPartial,
|
||||
WorkerManifestConfig, WorkerMetaConfig,
|
||||
};
|
||||
pub use model::{
|
||||
AuthRef, ModelCapability, ModelManifest, ReasoningControl, ReasoningEffort, SchemeKind,
|
||||
@@ -32,20 +32,20 @@ use std::path::PathBuf;
|
||||
use serde::de::Error as _;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// Declarative configuration for a Pod.
|
||||
/// Declarative configuration for a Worker.
|
||||
///
|
||||
/// Parsed from a TOML manifest file. Describes the model, system prompt,
|
||||
/// and directory scope (required). The Pod's working directory is **not**
|
||||
/// and directory scope (required). The Worker's working directory is **not**
|
||||
/// part of the manifest — it is the process's `std::env::current_dir()`
|
||||
/// at construction time.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct PodManifest {
|
||||
pub pod: PodMeta,
|
||||
pub struct WorkerManifest {
|
||||
pub worker: WorkerMeta,
|
||||
pub model: ModelManifest,
|
||||
pub worker: WorkerManifest,
|
||||
/// Direct filesystem authority for this Pod's own tools.
|
||||
pub engine: EngineManifest,
|
||||
/// Direct filesystem authority for this Worker's own tools.
|
||||
pub scope: ScopeConfig,
|
||||
/// Filesystem authority this Pod may pass to spawned children. Missing
|
||||
/// Filesystem authority this Worker may pass to spawned children. Missing
|
||||
/// metadata/config defaults to no delegation authority.
|
||||
#[serde(default)]
|
||||
pub delegation_scope: ScopeConfig,
|
||||
@@ -91,14 +91,14 @@ pub struct PodManifest {
|
||||
#[serde(default)]
|
||||
pub skills: Option<SkillsConfig>,
|
||||
/// Optional profile provenance for manifests produced by profile resolution.
|
||||
/// Stored only after profile resolution so Pod restore can prefer the
|
||||
/// Stored only after profile resolution so Worker restore can prefer the
|
||||
/// validated snapshot over current profile files or one-file Manifest input.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub profile: Option<profile::ProfileManifestSnapshot>,
|
||||
}
|
||||
|
||||
/// Explicit built-in feature/tool-surface enablement. These flags are
|
||||
/// profile/config data only: they do not carry runtime Pod names, sockets,
|
||||
/// profile/config data only: they do not carry runtime Worker names, sockets,
|
||||
/// sessions, secrets, or resolved host state. Tool registration still applies
|
||||
/// the normal scope, host-authority, backend, memory, and network checks.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
@@ -110,7 +110,7 @@ pub struct FeatureConfig {
|
||||
#[serde(default)]
|
||||
pub web: FeatureFlagConfig,
|
||||
#[serde(default)]
|
||||
pub pods: FeatureFlagConfig,
|
||||
pub workers: FeatureFlagConfig,
|
||||
#[serde(default)]
|
||||
pub ticket: TicketFeatureConfig,
|
||||
#[serde(default)]
|
||||
@@ -125,7 +125,7 @@ impl Default for FeatureConfig {
|
||||
task: FeatureFlagConfig::disabled(),
|
||||
memory: FeatureFlagConfig::disabled(),
|
||||
web: FeatureFlagConfig::disabled(),
|
||||
pods: FeatureFlagConfig::disabled(),
|
||||
workers: FeatureFlagConfig::disabled(),
|
||||
ticket: TicketFeatureConfig::default(),
|
||||
ticket_orchestration: FeatureFlagConfig::disabled(),
|
||||
plugins: FeatureFlagConfig::disabled(),
|
||||
@@ -197,7 +197,7 @@ pub struct SkillsConfig {
|
||||
/// Skills *roots*. Children of each root must be individual
|
||||
/// `<name>/SKILL.md` bundles; the directory itself is not a skill.
|
||||
/// Resolved against the manifest base directory before
|
||||
/// [`PodManifest`] is materialised.
|
||||
/// [`WorkerManifest`] is materialised.
|
||||
#[serde(default)]
|
||||
pub directories: Vec<PathBuf>,
|
||||
}
|
||||
@@ -206,7 +206,7 @@ pub struct SkillsConfig {
|
||||
///
|
||||
/// The manifest layer records local stdio MCP server declarations but never
|
||||
/// starts them. Future lifecycle code must opt in to spawning and must keep MCP
|
||||
/// process authority separate from Plugin permissions and `pod::feature` flags.
|
||||
/// process authority separate from Plugin permissions and `worker::feature` flags.
|
||||
#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct McpConfig {
|
||||
@@ -363,7 +363,7 @@ pub struct WebFetchConfig {
|
||||
|
||||
/// Memory subsystem configuration. Presence in the manifest enables
|
||||
/// memory; `workspace_root` pins the memory workspace explicitly. When it
|
||||
/// is absent, memory resolution searches upward from the Pod's pwd for a
|
||||
/// is absent, memory resolution searches upward from the Worker's pwd for a
|
||||
/// `.yoi/memory` marker rather than treating `.yoi` project records alone
|
||||
/// as a memory root.
|
||||
///
|
||||
@@ -396,7 +396,7 @@ pub struct MemoryConfig {
|
||||
#[serde(default)]
|
||||
pub language: Option<String>,
|
||||
/// Optional model for the extract worker. When `None`,
|
||||
/// the main pod model is cloned via `clone_boxed()`. Lightweight
|
||||
/// the main engine model is cloned via `clone_boxed()`. Lightweight
|
||||
/// reasoning-capable models (Haiku / 4o-mini / Flash class) are
|
||||
/// recommended.
|
||||
#[serde(default)]
|
||||
@@ -414,7 +414,7 @@ pub struct MemoryConfig {
|
||||
#[serde(default)]
|
||||
pub extract_worker_max_turns: Option<u32>,
|
||||
/// Optional model for the consolidation worker. When
|
||||
/// `None`, the main pod model is cloned via `clone_boxed()`.
|
||||
/// `None`, the main engine model is cloned via `clone_boxed()`.
|
||||
/// Reasoning-class models are recommended.
|
||||
#[serde(default)]
|
||||
pub consolidation_model: Option<ModelManifest>,
|
||||
@@ -432,12 +432,12 @@ pub struct MemoryConfig {
|
||||
pub consolidation_threshold_bytes: Option<u64>,
|
||||
}
|
||||
|
||||
/// Pod metadata.
|
||||
/// Worker metadata.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct PodMeta {
|
||||
pub struct WorkerMeta {
|
||||
pub name: String,
|
||||
/// Optional path to a TOML override file read as the top layer of
|
||||
/// `pod::PromptCatalog`. Subject to the same relative-path
|
||||
/// `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.
|
||||
@@ -453,7 +453,7 @@ pub struct PodMeta {
|
||||
|
||||
/// Worker-level configuration embedded in the manifest.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct WorkerManifest {
|
||||
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/...`,
|
||||
@@ -572,7 +572,7 @@ impl ToolOutputLimits {
|
||||
|
||||
/// Declarative scope configuration.
|
||||
///
|
||||
/// A Pod may only touch paths whose effective permission (computed from
|
||||
/// A Worker may only touch paths whose effective permission (computed from
|
||||
/// allow/deny rules below) is at least `Read` / `Write`. See
|
||||
/// [`Scope`] for the resolved runtime form.
|
||||
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
||||
@@ -653,7 +653,7 @@ pub struct CompactionConfig {
|
||||
|
||||
/// Safety-net (between-requests) compaction threshold.
|
||||
///
|
||||
/// Checked by `PodInterceptor::pre_llm_request` inside a turn. When
|
||||
/// Checked by `WorkerInterceptor::pre_llm_request` inside a turn. When
|
||||
/// current occupancy exceeds this value, the run yields so that the
|
||||
/// Controller can compact before the next LLM request. `None`
|
||||
/// disables the between-requests check.
|
||||
@@ -802,7 +802,7 @@ impl Default for CompactionConfig {
|
||||
}
|
||||
}
|
||||
|
||||
impl PodManifest {
|
||||
impl WorkerManifest {
|
||||
/// Parse a manifest from a TOML string.
|
||||
pub fn from_toml(s: &str) -> Result<Self, toml::de::Error> {
|
||||
config::reject_removed_manifest_fields(s)?;
|
||||
@@ -818,14 +818,14 @@ mod tests {
|
||||
use super::*;
|
||||
|
||||
const MINIMAL_REQUIRED: &str = r#"
|
||||
[pod]
|
||||
[worker]
|
||||
name = "test-agent"
|
||||
|
||||
[model]
|
||||
scheme = "anthropic"
|
||||
model_id = "claude-sonnet-4-20250514"
|
||||
|
||||
[worker]
|
||||
[engine]
|
||||
|
||||
[[scope.allow]]
|
||||
target = "/abs/scope"
|
||||
@@ -834,8 +834,8 @@ permission = "write"
|
||||
|
||||
#[test]
|
||||
fn parse_minimal_manifest() {
|
||||
let manifest = PodManifest::from_toml(MINIMAL_REQUIRED).unwrap();
|
||||
assert_eq!(manifest.pod.name, "test-agent");
|
||||
let manifest = WorkerManifest::from_toml(MINIMAL_REQUIRED).unwrap();
|
||||
assert_eq!(manifest.worker.name, "test-agent");
|
||||
assert_eq!(manifest.model.scheme, Some(SchemeKind::Anthropic));
|
||||
assert_eq!(
|
||||
manifest.model.model_id.as_deref(),
|
||||
@@ -846,19 +846,19 @@ permission = "write"
|
||||
assert!(manifest.scope.deny.is_empty());
|
||||
assert!(manifest.delegation_scope.allow.is_empty());
|
||||
assert!(manifest.delegation_scope.deny.is_empty());
|
||||
assert_eq!(manifest.worker.instruction, defaults::DEFAULT_INSTRUCTION);
|
||||
assert!(manifest.worker.top_p.is_none());
|
||||
assert!(manifest.worker.top_k.is_none());
|
||||
assert!(manifest.worker.stop_sequences.is_empty());
|
||||
assert_eq!(manifest.engine.instruction, defaults::DEFAULT_INSTRUCTION);
|
||||
assert!(manifest.engine.top_p.is_none());
|
||||
assert!(manifest.engine.top_k.is_none());
|
||||
assert!(manifest.engine.stop_sequences.is_empty());
|
||||
assert!(manifest.web.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deserialize_old_manifest_snapshot_defaults_to_no_delegation() {
|
||||
let manifest = PodManifest::from_toml(MINIMAL_REQUIRED).unwrap();
|
||||
let manifest = WorkerManifest::from_toml(MINIMAL_REQUIRED).unwrap();
|
||||
let mut snapshot = serde_json::to_value(&manifest).unwrap();
|
||||
snapshot.as_object_mut().unwrap().remove("delegation_scope");
|
||||
let restored: PodManifest = serde_json::from_value(snapshot).unwrap();
|
||||
let restored: WorkerManifest = serde_json::from_value(snapshot).unwrap();
|
||||
assert_eq!(restored.scope.allow.len(), 1);
|
||||
assert!(restored.delegation_scope.allow.is_empty());
|
||||
assert!(restored.delegation_scope.deny.is_empty());
|
||||
@@ -870,7 +870,7 @@ permission = "write"
|
||||
"{}\n[web]\nenabled = true\n\n[web.search]\nprovider = \"brave\"\napi_key_secret = \"web/brave/default\"\ntimeout_secs = 12\n\n[web.fetch]\ntimeout_secs = 7\nredirect_limit = 3\nmax_response_bytes = 12345\nmax_output_bytes = 2048\n",
|
||||
MINIMAL_REQUIRED
|
||||
);
|
||||
let manifest = PodManifest::from_toml(&toml).unwrap();
|
||||
let manifest = WorkerManifest::from_toml(&toml).unwrap();
|
||||
let web = manifest.web.unwrap();
|
||||
assert_eq!(web.enabled, Some(true));
|
||||
let search = web.search.unwrap();
|
||||
@@ -886,7 +886,7 @@ permission = "write"
|
||||
#[test]
|
||||
fn parse_full_manifest() {
|
||||
let toml = r#"
|
||||
[pod]
|
||||
[worker]
|
||||
name = "code-reviewer"
|
||||
|
||||
[model]
|
||||
@@ -894,7 +894,7 @@ scheme = "anthropic"
|
||||
model_id = "claude-sonnet-4-20250514"
|
||||
auth = { kind = "api_key", file = "/abs/keys/anthropic" }
|
||||
|
||||
[worker]
|
||||
[engine]
|
||||
instruction = "$user/reviewer"
|
||||
max_tokens = 4096
|
||||
temperature = 0.3
|
||||
@@ -924,21 +924,21 @@ permission = "write"
|
||||
target = "/abs/project/tasks/private"
|
||||
permission = "write"
|
||||
"#;
|
||||
let manifest = PodManifest::from_toml(toml).unwrap();
|
||||
assert_eq!(manifest.pod.name, "code-reviewer");
|
||||
let manifest = WorkerManifest::from_toml(toml).unwrap();
|
||||
assert_eq!(manifest.worker.name, "code-reviewer");
|
||||
let file = match manifest.model.auth.as_ref() {
|
||||
Some(AuthRef::ApiKey { file, .. }) => file.as_deref(),
|
||||
_ => panic!("expected ApiKey"),
|
||||
};
|
||||
assert_eq!(file, Some(std::path::Path::new("/abs/keys/anthropic")));
|
||||
assert_eq!(manifest.worker.instruction, "$user/reviewer");
|
||||
assert_eq!(manifest.worker.max_tokens, Some(4096));
|
||||
assert_eq!(manifest.worker.temperature, Some(0.3));
|
||||
assert_eq!(manifest.worker.top_p, Some(0.9));
|
||||
assert_eq!(manifest.worker.top_k, Some(40));
|
||||
assert_eq!(manifest.worker.stop_sequences, vec!["\n\n", "</stop>"]);
|
||||
assert_eq!(manifest.engine.instruction, "$user/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));
|
||||
assert_eq!(manifest.engine.top_k, Some(40));
|
||||
assert_eq!(manifest.engine.stop_sequences, vec!["\n\n", "</stop>"]);
|
||||
assert_eq!(
|
||||
manifest.worker.reasoning,
|
||||
manifest.engine.reasoning,
|
||||
Some(ReasoningControl::Effort(ReasoningEffort::Medium))
|
||||
);
|
||||
let allow = &manifest.scope.allow;
|
||||
@@ -960,16 +960,16 @@ permission = "write"
|
||||
#[test]
|
||||
fn reject_missing_scope() {
|
||||
let toml = r#"
|
||||
[pod]
|
||||
[worker]
|
||||
name = "missing-scope"
|
||||
|
||||
[model]
|
||||
scheme = "anthropic"
|
||||
model_id = "claude-sonnet-4-20250514"
|
||||
|
||||
[worker]
|
||||
[engine]
|
||||
"#;
|
||||
assert!(PodManifest::from_toml(toml).is_err());
|
||||
assert!(WorkerManifest::from_toml(toml).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -984,7 +984,7 @@ model_id = "claude-sonnet-4-20250514"
|
||||
[plugins.enabled.config]\n\
|
||||
greeting = \"hello\"\n"
|
||||
);
|
||||
let manifest = PodManifest::from_toml(&toml).unwrap();
|
||||
let manifest = WorkerManifest::from_toml(&toml).unwrap();
|
||||
assert_eq!(manifest.plugins.enabled.len(), 1);
|
||||
let enabled = &manifest.plugins.enabled[0];
|
||||
assert_eq!(enabled.id, "project:example");
|
||||
@@ -1005,37 +1005,37 @@ model_id = "claude-sonnet-4-20250514"
|
||||
|
||||
#[test]
|
||||
fn parse_max_turns() {
|
||||
let toml = MINIMAL_REQUIRED.replace("[worker]\n", "[worker]\nmax_turns = 50\n");
|
||||
let manifest = PodManifest::from_toml(&toml).unwrap();
|
||||
assert_eq!(manifest.worker.max_turns.unwrap().get(), 50);
|
||||
let toml = MINIMAL_REQUIRED.replace("[engine]\n", "[engine]\nmax_turns = 50\n");
|
||||
let manifest = WorkerManifest::from_toml(&toml).unwrap();
|
||||
assert_eq!(manifest.engine.max_turns.unwrap().get(), 50);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_reasoning_budget() {
|
||||
let toml = MINIMAL_REQUIRED.replace("[worker]\n", "[worker]\nreasoning = -1\n");
|
||||
let manifest = PodManifest::from_toml(&toml).unwrap();
|
||||
let toml = MINIMAL_REQUIRED.replace("[engine]\n", "[engine]\nreasoning = -1\n");
|
||||
let manifest = WorkerManifest::from_toml(&toml).unwrap();
|
||||
assert_eq!(
|
||||
manifest.worker.reasoning,
|
||||
manifest.engine.reasoning,
|
||||
Some(ReasoningControl::BudgetTokens(-1))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn omitted_max_turns_is_none() {
|
||||
let manifest = PodManifest::from_toml(MINIMAL_REQUIRED).unwrap();
|
||||
assert!(manifest.worker.max_turns.is_none());
|
||||
let manifest = WorkerManifest::from_toml(MINIMAL_REQUIRED).unwrap();
|
||||
assert!(manifest.engine.max_turns.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reject_max_turns_zero() {
|
||||
let toml = MINIMAL_REQUIRED.replace("[worker]\n", "[worker]\nmax_turns = 0\n");
|
||||
assert!(PodManifest::from_toml(&toml).is_err());
|
||||
let toml = MINIMAL_REQUIRED.replace("[engine]\n", "[engine]\nmax_turns = 0\n");
|
||||
assert!(WorkerManifest::from_toml(&toml).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_compaction_config() {
|
||||
let toml = format!("{MINIMAL_REQUIRED}\n[compaction]\nthreshold = 80000\n");
|
||||
let manifest = PodManifest::from_toml(&toml).unwrap();
|
||||
let manifest = WorkerManifest::from_toml(&toml).unwrap();
|
||||
let c = manifest.compaction.unwrap();
|
||||
assert_eq!(c.prune_protected_tokens, 8000);
|
||||
assert_eq!(c.prune_min_savings, 4096);
|
||||
@@ -1048,7 +1048,7 @@ model_id = "claude-sonnet-4-20250514"
|
||||
#[test]
|
||||
fn reject_removed_prune_protected_turns_field() {
|
||||
let toml = format!("{MINIMAL_REQUIRED}\n[compaction]\nprune_protected_turns = 3\n");
|
||||
let err = PodManifest::from_toml(&toml).unwrap_err();
|
||||
let err = WorkerManifest::from_toml(&toml).unwrap_err();
|
||||
assert!(
|
||||
err.to_string().contains("compaction.prune_protected_turns"),
|
||||
"unexpected error: {err}"
|
||||
@@ -1062,7 +1062,7 @@ model_id = "claude-sonnet-4-20250514"
|
||||
[compaction]\n\
|
||||
worker_max_turns = 7\n"
|
||||
);
|
||||
let manifest = PodManifest::from_toml(&toml).unwrap();
|
||||
let manifest = WorkerManifest::from_toml(&toml).unwrap();
|
||||
let c = manifest.compaction.unwrap();
|
||||
assert_eq!(c.worker_max_turns, Some(7));
|
||||
}
|
||||
@@ -1075,7 +1075,7 @@ model_id = "claude-sonnet-4-20250514"
|
||||
threshold = 80000\n\
|
||||
request_threshold = 90000\n"
|
||||
);
|
||||
let manifest = PodManifest::from_toml(&toml).unwrap();
|
||||
let manifest = WorkerManifest::from_toml(&toml).unwrap();
|
||||
let c = manifest.compaction.unwrap();
|
||||
assert_eq!(c.threshold, Some(80000));
|
||||
assert_eq!(c.request_threshold, Some(90000));
|
||||
@@ -1088,7 +1088,7 @@ model_id = "claude-sonnet-4-20250514"
|
||||
[compaction]\n\
|
||||
request_threshold = 90000\n"
|
||||
);
|
||||
let manifest = PodManifest::from_toml(&toml).unwrap();
|
||||
let manifest = WorkerManifest::from_toml(&toml).unwrap();
|
||||
let c = manifest.compaction.unwrap();
|
||||
assert_eq!(c.threshold, None);
|
||||
assert_eq!(c.request_threshold, Some(90000));
|
||||
@@ -1104,7 +1104,7 @@ model_id = "claude-sonnet-4-20250514"
|
||||
scheme = \"gemini\"\n\
|
||||
model_id = \"gemini-2.0-flash\"\n"
|
||||
);
|
||||
let manifest = PodManifest::from_toml(&toml).unwrap();
|
||||
let manifest = WorkerManifest::from_toml(&toml).unwrap();
|
||||
let c = manifest.compaction.unwrap();
|
||||
let p = c.model.unwrap();
|
||||
assert_eq!(p.scheme, Some(SchemeKind::Gemini));
|
||||
@@ -1113,20 +1113,20 @@ model_id = "claude-sonnet-4-20250514"
|
||||
|
||||
#[test]
|
||||
fn omitted_compaction_is_none() {
|
||||
let manifest = PodManifest::from_toml(MINIMAL_REQUIRED).unwrap();
|
||||
let manifest = WorkerManifest::from_toml(MINIMAL_REQUIRED).unwrap();
|
||||
assert!(manifest.compaction.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn omitted_memory_is_none() {
|
||||
let manifest = PodManifest::from_toml(MINIMAL_REQUIRED).unwrap();
|
||||
let manifest = WorkerManifest::from_toml(MINIMAL_REQUIRED).unwrap();
|
||||
assert!(manifest.memory.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_memory_section_enables_with_default_root() {
|
||||
let toml = format!("{MINIMAL_REQUIRED}\n[memory]\n");
|
||||
let manifest = PodManifest::from_toml(&toml).unwrap();
|
||||
let manifest = WorkerManifest::from_toml(&toml).unwrap();
|
||||
let mem = manifest.memory.expect("memory section parsed");
|
||||
assert!(mem.workspace_root.is_none());
|
||||
assert_eq!(mem.inject_summary, None);
|
||||
@@ -1135,7 +1135,7 @@ model_id = "claude-sonnet-4-20250514"
|
||||
#[test]
|
||||
fn memory_section_with_inject_summary_false() {
|
||||
let toml = format!("{MINIMAL_REQUIRED}\n[memory]\ninject_summary = false\n");
|
||||
let manifest = PodManifest::from_toml(&toml).unwrap();
|
||||
let manifest = WorkerManifest::from_toml(&toml).unwrap();
|
||||
let mem = manifest.memory.unwrap();
|
||||
assert_eq!(mem.inject_summary, Some(false));
|
||||
}
|
||||
@@ -1143,7 +1143,7 @@ model_id = "claude-sonnet-4-20250514"
|
||||
#[test]
|
||||
fn memory_section_with_explicit_root() {
|
||||
let toml = format!("{MINIMAL_REQUIRED}\n[memory]\nworkspace_root = \"/some/where\"\n");
|
||||
let manifest = PodManifest::from_toml(&toml).unwrap();
|
||||
let manifest = WorkerManifest::from_toml(&toml).unwrap();
|
||||
let mem = manifest.memory.unwrap();
|
||||
assert_eq!(
|
||||
mem.workspace_root.unwrap(),
|
||||
@@ -1154,7 +1154,7 @@ model_id = "claude-sonnet-4-20250514"
|
||||
#[test]
|
||||
fn memory_section_with_language() {
|
||||
let toml = format!("{MINIMAL_REQUIRED}\n[memory]\nlanguage = \"Japanese\"\n");
|
||||
let manifest = PodManifest::from_toml(&toml).unwrap();
|
||||
let manifest = WorkerManifest::from_toml(&toml).unwrap();
|
||||
let mem = manifest.memory.unwrap();
|
||||
assert_eq!(mem.language.as_deref(), Some("Japanese"));
|
||||
}
|
||||
@@ -1163,62 +1163,62 @@ model_id = "claude-sonnet-4-20250514"
|
||||
fn reject_unknown_scheme() {
|
||||
let toml =
|
||||
MINIMAL_REQUIRED.replace("scheme = \"anthropic\"", "scheme = \"unknown_scheme\"");
|
||||
assert!(PodManifest::from_toml(&toml).is_err());
|
||||
assert!(WorkerManifest::from_toml(&toml).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn omitted_limits_fall_back_to_defaults() {
|
||||
let manifest = PodManifest::from_toml(MINIMAL_REQUIRED).unwrap();
|
||||
let limits = &manifest.worker.tool_output;
|
||||
let manifest = WorkerManifest::from_toml(MINIMAL_REQUIRED).unwrap();
|
||||
let limits = &manifest.engine.tool_output;
|
||||
assert_eq!(limits.default_max_bytes, defaults::TOOL_OUTPUT_MAX_BYTES);
|
||||
assert!(limits.per_tool.is_empty());
|
||||
assert_eq!(
|
||||
manifest.worker.file_upload.max_bytes,
|
||||
manifest.engine.file_upload.max_bytes,
|
||||
defaults::FILE_UPLOAD_MAX_BYTES
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn worker_language_defaults_and_parses() {
|
||||
let manifest = PodManifest::from_toml(MINIMAL_REQUIRED).unwrap();
|
||||
assert_eq!(manifest.worker.language, defaults::WORKER_LANGUAGE);
|
||||
let manifest = WorkerManifest::from_toml(MINIMAL_REQUIRED).unwrap();
|
||||
assert_eq!(manifest.engine.language, defaults::WORKER_LANGUAGE);
|
||||
|
||||
let toml = MINIMAL_REQUIRED.replace("[worker]\n", "[worker]\nlanguage = \"Japanese\"\n");
|
||||
let manifest = PodManifest::from_toml(&toml).unwrap();
|
||||
assert_eq!(manifest.worker.language, "Japanese");
|
||||
let toml = MINIMAL_REQUIRED.replace("[engine]\n", "[engine]\nlanguage = \"Japanese\"\n");
|
||||
let manifest = WorkerManifest::from_toml(&toml).unwrap();
|
||||
assert_eq!(manifest.engine.language, "Japanese");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_worker_output_limits() {
|
||||
let toml = MINIMAL_REQUIRED.replace(
|
||||
"[worker]\n",
|
||||
"[worker]\n\
|
||||
"[engine]\n",
|
||||
"[engine]\n\
|
||||
[worker.tool_output]\n\
|
||||
default_max_bytes = 8192\n\n\
|
||||
[worker.tool_output.per_tool]\n\
|
||||
Read = 32768\n\
|
||||
Grep = 4096\n\n\
|
||||
[worker.file_upload]\n\
|
||||
[engine.file_upload]\n\
|
||||
max_bytes = 12345\n",
|
||||
);
|
||||
let manifest = PodManifest::from_toml(&toml).unwrap();
|
||||
let limits = &manifest.worker.tool_output;
|
||||
let manifest = WorkerManifest::from_toml(&toml).unwrap();
|
||||
let limits = &manifest.engine.tool_output;
|
||||
assert_eq!(limits.default_max_bytes, 8192);
|
||||
assert_eq!(limits.limit_for("Read"), 32768);
|
||||
assert_eq!(limits.limit_for("Grep"), 4096);
|
||||
assert_eq!(limits.limit_for("Unknown"), 8192);
|
||||
assert_eq!(manifest.worker.file_upload.max_bytes, 12345);
|
||||
assert_eq!(manifest.engine.file_upload.max_bytes, 12345);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_tool_output_section_uses_default_max_bytes() {
|
||||
let toml = MINIMAL_REQUIRED.replace(
|
||||
"[worker]\n",
|
||||
"[worker]\n\
|
||||
"[engine]\n",
|
||||
"[engine]\n\
|
||||
[worker.tool_output]\n",
|
||||
);
|
||||
let manifest = PodManifest::from_toml(&toml).unwrap();
|
||||
let limits = &manifest.worker.tool_output;
|
||||
let manifest = WorkerManifest::from_toml(&toml).unwrap();
|
||||
let limits = &manifest.engine.tool_output;
|
||||
assert_eq!(limits.default_max_bytes, defaults::TOOL_OUTPUT_MAX_BYTES);
|
||||
assert!(limits.per_tool.is_empty());
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
//! LLM モデル宣言型
|
||||
//!
|
||||
//! Pod マニフェストの `[model]` セクションで記述する型。`ref`(プロバイダ
|
||||
//! Worker マニフェストの `[model]` セクションで記述する型。`ref`(プロバイダ
|
||||
//! とモデルを両方指し示す短縮形)と inline 指定(`scheme` / `model_id`
|
||||
//! 直書き)の両方を受け入れるため、すべてのフィールドを `Option` として
|
||||
//! 持つ 1 つの型 [`ModelManifest`] に統合している。実解決(ref をプロバイダ
|
||||
@@ -18,7 +18,7 @@ use serde::{Deserialize, Serialize};
|
||||
// マニフェストで任意に override できるよう型だけ再エクスポートする。
|
||||
pub use llm_engine::llm_client::capability::{ModelCapability, ReasoningControl, ReasoningEffort};
|
||||
|
||||
/// Pod マニフェストの `[model]` セクション。
|
||||
/// Worker マニフェストの `[model]` セクション。
|
||||
///
|
||||
/// - ref だけ書く: `[model] ref = "anthropic/claude-sonnet-4-6"`
|
||||
/// - ref + 一部 override: ref で基底を引き、`auth` 等だけ書き換え
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
//! `providers.toml`, `models.toml`, `prompts/`, `prompts.toml` 等
|
||||
//! - **`data_dir`** — プログラムが書く永続データ。`sessions/` 等
|
||||
//! - **`runtime_dir`** — 再起動で消えてよいランタイム状態。socket,
|
||||
//! `pods.json`, `pid` ファイル等
|
||||
//! `workers.json`, `pid` ファイル等
|
||||
//!
|
||||
//! ## 解決順 (優先順位高 → 低)
|
||||
//!
|
||||
@@ -46,7 +46,7 @@ pub fn data_dir() -> Option<PathBuf> {
|
||||
)
|
||||
}
|
||||
|
||||
/// ランタイムディレクトリ。socket, `pods.json`, Pod ごとの `pid` /
|
||||
/// ランタイムディレクトリ。socket, `workers.json`, Worker ごとの `pid` /
|
||||
/// `status.json` 等が置かれる。再起動で消えて構わない。
|
||||
pub fn runtime_dir() -> Option<PathBuf> {
|
||||
resolve_runtime_dir_from_parts(
|
||||
@@ -61,7 +61,7 @@ pub fn runtime_dir() -> Option<PathBuf> {
|
||||
|
||||
/// `<config_dir>/profiles.toml` — user profile registry/default configuration.
|
||||
///
|
||||
/// This is application/profile selection configuration, not a Pod manifest
|
||||
/// This is application/profile selection configuration, not a Worker manifest
|
||||
/// layer.
|
||||
pub fn user_profiles_path() -> Option<PathBuf> {
|
||||
user_profiles_path_from_config_dir(config_dir())
|
||||
@@ -88,24 +88,24 @@ pub fn sessions_dir() -> Option<PathBuf> {
|
||||
sessions_dir_from_data_dir(data_dir())
|
||||
}
|
||||
|
||||
/// `<runtime_dir>/pods.json` — machine-wide Pod allocation registry。
|
||||
/// `<runtime_dir>/workers.json` — machine-wide Worker allocation registry。
|
||||
pub fn pod_registry_path() -> Option<PathBuf> {
|
||||
pod_registry_path_from_runtime_dir(runtime_dir())
|
||||
}
|
||||
|
||||
/// `<runtime_dir>/<pod_name>/` — Pod ごとのランタイムディレクトリ。
|
||||
pub fn pod_runtime_dir(pod_name: &str) -> Option<PathBuf> {
|
||||
pod_runtime_dir_from_runtime_dir(runtime_dir(), pod_name)
|
||||
/// `<runtime_dir>/<worker_name>/` — Worker ごとのランタイムディレクトリ。
|
||||
pub fn worker_runtime_dir(worker_name: &str) -> Option<PathBuf> {
|
||||
worker_runtime_dir_from_runtime_dir(runtime_dir(), worker_name)
|
||||
}
|
||||
|
||||
/// `<runtime_dir>/<pod_name>/sock` — Pod の Unix socket パス。
|
||||
/// `<runtime_dir>/<worker_name>/sock` — Worker の Unix socket パス。
|
||||
///
|
||||
/// Pod プロセス内で実際に socket を作成するのは `pod` crate の
|
||||
/// `RuntimeDir::socket_path()` で、Pod 名が分かっている外部 (TUI の
|
||||
/// Worker プロセス内で実際に socket を作成するのは `worker` crate の
|
||||
/// `RuntimeDir::socket_path()` で、Worker 名が分かっている外部 (TUI の
|
||||
/// attach フロー等) からの**予測**はこの関数で行う。両者は同じパス
|
||||
/// を返すことが期待される。
|
||||
pub fn pod_socket_path(pod_name: &str) -> Option<PathBuf> {
|
||||
pod_socket_path_from_runtime_dir(runtime_dir(), pod_name)
|
||||
pub fn pod_socket_path(worker_name: &str) -> Option<PathBuf> {
|
||||
pod_socket_path_from_runtime_dir(runtime_dir(), worker_name)
|
||||
}
|
||||
|
||||
// ---- internals --------------------------------------------------------------
|
||||
@@ -184,21 +184,21 @@ fn sessions_dir_from_data_dir(data_dir: Option<PathBuf>) -> Option<PathBuf> {
|
||||
}
|
||||
|
||||
fn pod_registry_path_from_runtime_dir(runtime_dir: Option<PathBuf>) -> Option<PathBuf> {
|
||||
Some(runtime_dir?.join("pods.json"))
|
||||
Some(runtime_dir?.join("workers.json"))
|
||||
}
|
||||
|
||||
fn pod_runtime_dir_from_runtime_dir(
|
||||
fn worker_runtime_dir_from_runtime_dir(
|
||||
runtime_dir: Option<PathBuf>,
|
||||
pod_name: &str,
|
||||
worker_name: &str,
|
||||
) -> Option<PathBuf> {
|
||||
Some(runtime_dir?.join(pod_name))
|
||||
Some(runtime_dir?.join(worker_name))
|
||||
}
|
||||
|
||||
fn pod_socket_path_from_runtime_dir(
|
||||
runtime_dir: Option<PathBuf>,
|
||||
pod_name: &str,
|
||||
worker_name: &str,
|
||||
) -> Option<PathBuf> {
|
||||
Some(pod_runtime_dir_from_runtime_dir(runtime_dir, pod_name)?.join("sock"))
|
||||
Some(worker_runtime_dir_from_runtime_dir(runtime_dir, worker_name)?.join("sock"))
|
||||
}
|
||||
|
||||
/// 空文字列の env は未設定として扱う。`std::env::var` は `Ok("")` と
|
||||
@@ -397,10 +397,10 @@ mod tests {
|
||||
);
|
||||
assert_eq!(
|
||||
pod_registry_path_from_runtime_dir(runtime_dir.clone()).unwrap(),
|
||||
PathBuf::from("/sand/run/pods.json")
|
||||
PathBuf::from("/sand/run/workers.json")
|
||||
);
|
||||
assert_eq!(
|
||||
pod_runtime_dir_from_runtime_dir(runtime_dir.clone(), "foo").unwrap(),
|
||||
worker_runtime_dir_from_runtime_dir(runtime_dir.clone(), "foo").unwrap(),
|
||||
PathBuf::from("/sand/run/foo")
|
||||
);
|
||||
assert_eq!(
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
//!
|
||||
//! Profiles are reusable, human-authored recipes. They are intentionally not
|
||||
//! complete runtime manifests: runtime-bound and authority-bearing fields such
|
||||
//! as `pod.name` and concrete `scope.allow` rules are supplied by the resolver
|
||||
//! as `worker.name` and concrete `scope.allow` rules are supplied by the resolver
|
||||
//! from launch context.
|
||||
|
||||
use std::cell::RefCell;
|
||||
@@ -19,9 +19,9 @@ use crate::config::{
|
||||
use crate::model::{AuthRef, ModelManifest};
|
||||
use crate::plugin::PluginConfig;
|
||||
use crate::{
|
||||
McpConfig, McpStdioCwdPolicy, MemoryConfig, Permission, PodManifest, PodManifestConfig,
|
||||
PodMetaConfig, ResolveError, ScopeConfig, ScopeRule, SkillsConfig, WebConfig,
|
||||
WorkerManifestConfig, paths,
|
||||
EngineManifestConfig, McpConfig, McpStdioCwdPolicy, MemoryConfig, Permission, ResolveError,
|
||||
ScopeConfig, ScopeRule, SkillsConfig, WebConfig, WorkerManifest, WorkerManifestConfig,
|
||||
WorkerMetaConfig, paths,
|
||||
};
|
||||
|
||||
const PROFILE_FORMAT_V1: &str = "yoi.lua-profile.v1";
|
||||
@@ -408,26 +408,26 @@ pub struct WorkspaceOverrideSnapshot {
|
||||
#[derive(Debug)]
|
||||
struct WorkspaceOverrideLayer {
|
||||
path: PathBuf,
|
||||
config: PodManifestConfig,
|
||||
config: WorkerManifestConfig,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ResolvedProfile {
|
||||
pub source: ProfileSource,
|
||||
pub profile: Option<ProfileMetadata>,
|
||||
pub manifest: PodManifest,
|
||||
pub manifest: WorkerManifest,
|
||||
pub manifest_snapshot: serde_json::Value,
|
||||
pub raw_artifact: serde_json::Value,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct ProfileResolveOptions {
|
||||
pub pod_name: Option<String>,
|
||||
pub worker_name: Option<String>,
|
||||
}
|
||||
impl ProfileResolveOptions {
|
||||
pub fn with_pod_name(name: impl Into<String>) -> Self {
|
||||
pub fn with_worker_name(name: impl Into<String>) -> Self {
|
||||
Self {
|
||||
pod_name: Some(name.into()),
|
||||
worker_name: Some(name.into()),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -469,8 +469,8 @@ impl ProfileResolver {
|
||||
}
|
||||
}
|
||||
/// Resolve a registry/default selector against an already-discovered
|
||||
/// registry. Callers such as SpawnPod use this to bind discovery to the
|
||||
/// Pod's cwd instead of the process current directory.
|
||||
/// registry. Callers such as SpawnWorker use this to bind discovery to the
|
||||
/// Worker's cwd instead of the process current directory.
|
||||
pub fn resolve_from_registry(
|
||||
&self,
|
||||
selector: &ProfileSelector,
|
||||
@@ -604,22 +604,22 @@ fn resolve_lua_profile_value(
|
||||
let profile: ProfileConfig = serde_json::from_value(value.clone())
|
||||
.map_err(|source| ProfileError::ProfileDeserialize { source })?;
|
||||
validate_profile_paths(&profile)?;
|
||||
let pod_name = options
|
||||
.pod_name
|
||||
.ok_or(ProfileError::MissingRuntimePodName)?;
|
||||
let worker_name = options
|
||||
.worker_name
|
||||
.ok_or(ProfileError::MissingRuntimeWorkerName)?;
|
||||
let profile_meta = Some(ProfileMetadata {
|
||||
name: profile.slug.clone().or_else(|| source_name(&source)),
|
||||
description: profile.description.clone(),
|
||||
format: Some(PROFILE_FORMAT_V1.to_string()),
|
||||
});
|
||||
let compaction = profile_compaction_to_partial(profile.compaction, &profile.model)?;
|
||||
let config = PodManifestConfig {
|
||||
pod: PodMetaConfig {
|
||||
name: Some(pod_name),
|
||||
let config = WorkerManifestConfig {
|
||||
worker: WorkerMetaConfig {
|
||||
name: Some(worker_name),
|
||||
prompt_pack: None,
|
||||
},
|
||||
model: profile.model.unwrap_or_default(),
|
||||
worker: profile.worker.unwrap_or_default(),
|
||||
engine: profile.engine.unwrap_or_default(),
|
||||
scope: profile_scope_to_config(profile.scope, workspace_base)?,
|
||||
delegation_scope: profile_delegation_scope_to_config(
|
||||
profile.delegation_scope,
|
||||
@@ -635,7 +635,8 @@ fn resolve_lua_profile_value(
|
||||
memory: profile.memory,
|
||||
skills: profile.skills,
|
||||
};
|
||||
let mut config = PodManifestConfig::builtin_defaults().merge(config.resolve_paths(profile_dir));
|
||||
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
|
||||
@@ -652,7 +653,7 @@ fn resolve_lua_profile_value(
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let mut manifest = PodManifest::try_from(config).map_err(ProfileError::ManifestResolve)?;
|
||||
let mut manifest = WorkerManifest::try_from(config).map_err(ProfileError::ManifestResolve)?;
|
||||
manifest.profile = Some(ProfileManifestSnapshot {
|
||||
source: source.clone(),
|
||||
profile: profile_meta.clone(),
|
||||
@@ -679,7 +680,7 @@ struct ProfileConfig {
|
||||
#[serde(default)]
|
||||
model: Option<ModelManifest>,
|
||||
#[serde(default)]
|
||||
worker: Option<WorkerManifestConfig>,
|
||||
engine: Option<EngineManifestConfig>,
|
||||
#[serde(default)]
|
||||
scope: Option<ProfileScopeConfig>,
|
||||
#[serde(default)]
|
||||
@@ -814,16 +815,16 @@ fn load_workspace_override_file(path: &Path) -> Result<WorkspaceOverrideLayer, P
|
||||
path: path.to_path_buf(),
|
||||
source,
|
||||
})?;
|
||||
let config = PodManifestConfig::from_toml(&content).map_err(|source| {
|
||||
let config = WorkerManifestConfig::from_toml(&content).map_err(|source| {
|
||||
ProfileError::WorkspaceOverrideParse {
|
||||
path: path.to_path_buf(),
|
||||
source,
|
||||
}
|
||||
})?;
|
||||
if config.pod.name.is_some() {
|
||||
if config.worker.name.is_some() {
|
||||
return Err(ProfileError::InvalidWorkspaceOverride {
|
||||
path: path.to_path_buf(),
|
||||
message: "workspace-local manifest overrides cannot set pod.name; Pod identity is a runtime input".into(),
|
||||
message: "workspace-local manifest overrides cannot set worker.name; Worker identity is a runtime input".into(),
|
||||
});
|
||||
}
|
||||
Ok(WorkspaceOverrideLayer {
|
||||
@@ -1209,8 +1210,8 @@ fn reject_manifest_shaped_profile(value: &serde_json::Value) -> Result<(), Profi
|
||||
)));
|
||||
}
|
||||
}
|
||||
if map.contains_key("pod") {
|
||||
return Err(ProfileError::InvalidProfile("field `pod` is runtime-bound and is not allowed in reusable Profiles; pass the Pod name via CLI/TUI runtime inputs".into()));
|
||||
if map.contains_key("worker") {
|
||||
return Err(ProfileError::InvalidProfile("field `worker` is runtime-bound and is not allowed in reusable Profiles; pass the Worker name via CLI/TUI runtime inputs".into()));
|
||||
}
|
||||
if let Some(scope) = map.get("scope").and_then(|v| v.as_object()) {
|
||||
for key in ["allow", "deny"] {
|
||||
@@ -1442,7 +1443,7 @@ pub fn resolve_profile_artifact(
|
||||
source,
|
||||
base_dir,
|
||||
base_dir,
|
||||
ProfileResolveOptions::with_pod_name("artifact-pod"),
|
||||
ProfileResolveOptions::with_worker_name("artifact-worker"),
|
||||
raw_artifact.clone(),
|
||||
raw_artifact,
|
||||
None,
|
||||
@@ -1489,8 +1490,8 @@ pub enum ProfileError {
|
||||
InvalidWorkspaceOverride { path: PathBuf, message: String },
|
||||
#[error("no default profile is configured")]
|
||||
NoDefaultProfile,
|
||||
#[error("profile resolution requires an explicit runtime Pod name")]
|
||||
MissingRuntimePodName,
|
||||
#[error("profile resolution requires an explicit runtime Worker name")]
|
||||
MissingRuntimeWorkerName,
|
||||
#[error("profile not found: {selector}")]
|
||||
ProfileNotFound { selector: String },
|
||||
#[error("ambiguous profile name `{name}`; use a source-qualified selector such as {matches:?}")]
|
||||
@@ -1574,17 +1575,17 @@ mod tests {
|
||||
.with_workspace_base(tmp.path())
|
||||
.resolve(
|
||||
&ProfileSelector::source_named(ProfileRegistrySource::Builtin, expected),
|
||||
ProfileResolveOptions::with_pod_name("role-pod"),
|
||||
ProfileResolveOptions::with_worker_name("role-worker"),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
resolved.profile.as_ref().unwrap().name.as_deref(),
|
||||
Some(expected)
|
||||
);
|
||||
assert_eq!(resolved.manifest.pod.name, "role-pod");
|
||||
assert_eq!(resolved.manifest.worker.name, "role-worker");
|
||||
if matches!(expected, "intake" | "orchestrator" | "coder" | "reviewer") {
|
||||
let expected_instruction = format!("$yoi/role/{expected}");
|
||||
assert_eq!(resolved.manifest.worker.instruction, expected_instruction);
|
||||
assert_eq!(resolved.manifest.engine.instruction, expected_instruction);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1597,7 +1598,7 @@ mod tests {
|
||||
.with_workspace_base(tmp.path())
|
||||
.resolve(
|
||||
&ProfileSelector::source_named(ProfileRegistrySource::Builtin, role),
|
||||
ProfileResolveOptions::with_pod_name("role-pod"),
|
||||
ProfileResolveOptions::with_worker_name("role-worker"),
|
||||
)
|
||||
.unwrap()
|
||||
.manifest
|
||||
@@ -1605,7 +1606,7 @@ mod tests {
|
||||
|
||||
let companion = resolve("companion");
|
||||
assert!(companion.feature.task.enabled);
|
||||
assert!(companion.feature.pods.enabled);
|
||||
assert!(companion.feature.workers.enabled);
|
||||
assert!(companion.feature.ticket.enabled);
|
||||
assert!(companion.scope.allow.is_empty());
|
||||
assert!(companion.scope.deny.is_empty());
|
||||
@@ -1615,7 +1616,7 @@ mod tests {
|
||||
|
||||
let intake = resolve("intake");
|
||||
assert!(!intake.feature.task.enabled);
|
||||
assert!(!intake.feature.pods.enabled);
|
||||
assert!(!intake.feature.workers.enabled);
|
||||
assert!(intake.feature.ticket.enabled);
|
||||
assert!(intake.scope.allow.is_empty());
|
||||
assert!(intake.delegation_scope.allow.is_empty());
|
||||
@@ -1625,7 +1626,7 @@ mod tests {
|
||||
|
||||
let orchestrator = resolve("orchestrator");
|
||||
assert!(!orchestrator.feature.task.enabled);
|
||||
assert!(orchestrator.feature.pods.enabled);
|
||||
assert!(orchestrator.feature.workers.enabled);
|
||||
assert!(orchestrator.feature.ticket.enabled);
|
||||
assert!(orchestrator.feature.ticket_orchestration.enabled);
|
||||
assert!(orchestrator.scope.allow.is_empty());
|
||||
@@ -1638,7 +1639,7 @@ mod tests {
|
||||
|
||||
let coder = resolve("coder");
|
||||
assert!(coder.feature.task.enabled);
|
||||
assert!(!coder.feature.pods.enabled);
|
||||
assert!(!coder.feature.workers.enabled);
|
||||
assert!(coder.scope.allow.is_empty());
|
||||
assert!(coder.delegation_scope.allow.is_empty());
|
||||
assert_eq!(coder.model.ref_.as_deref(), Some("codex-oauth/gpt-5.5"));
|
||||
@@ -1646,7 +1647,7 @@ mod tests {
|
||||
|
||||
let reviewer = resolve("reviewer");
|
||||
assert!(!reviewer.feature.task.enabled);
|
||||
assert!(!reviewer.feature.pods.enabled);
|
||||
assert!(!reviewer.feature.workers.enabled);
|
||||
assert!(!reviewer.feature.ticket.enabled);
|
||||
assert!(reviewer.scope.allow.is_empty());
|
||||
assert!(reviewer.delegation_scope.allow.is_empty());
|
||||
@@ -1655,17 +1656,17 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn profile_resolution_requires_runtime_pod_name() {
|
||||
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())
|
||||
.unwrap_err();
|
||||
assert!(matches!(err, ProfileError::MissingRuntimePodName));
|
||||
assert!(matches!(err, ProfileError::MissingRuntimeWorkerName));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolves_plain_lua_profile_with_runtime_pod_name_and_scope_intent() {
|
||||
fn resolves_plain_lua_profile_with_runtime_worker_name_and_scope_intent() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let profile = write_profile(
|
||||
tmp.path(),
|
||||
@@ -1676,7 +1677,7 @@ local scope = require("yoi.scope")
|
||||
return profile {
|
||||
slug = "coder",
|
||||
model = { scheme = "anthropic", model_id = "claude-sonnet-4-20250514" },
|
||||
worker = { reasoning = "high" },
|
||||
engine = { reasoning = "high" },
|
||||
scope = scope.workspace_read(),
|
||||
}
|
||||
"#,
|
||||
@@ -1687,13 +1688,13 @@ return profile {
|
||||
.with_workspace_base(&workspace)
|
||||
.resolve(
|
||||
&ProfileSelector::path(&profile),
|
||||
ProfileResolveOptions::with_pod_name("runtime-pod"),
|
||||
ProfileResolveOptions::with_worker_name("runtime-worker"),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(resolved.manifest.pod.name, "runtime-pod");
|
||||
assert_eq!(resolved.manifest.worker.name, "runtime-worker");
|
||||
assert_eq!(resolved.manifest.model.scheme, Some(SchemeKind::Anthropic));
|
||||
assert_eq!(
|
||||
resolved.manifest.worker.reasoning,
|
||||
resolved.manifest.engine.reasoning,
|
||||
Some(ReasoningControl::Effort(ReasoningEffort::High))
|
||||
);
|
||||
assert_eq!(resolved.manifest.scope.allow[0].target, workspace);
|
||||
@@ -1748,7 +1749,7 @@ return profile {
|
||||
.with_workspace_base(&workspace)
|
||||
.resolve(
|
||||
&ProfileSelector::path(&profile),
|
||||
ProfileResolveOptions::with_pod_name("runtime-pod"),
|
||||
ProfileResolveOptions::with_worker_name("runtime-worker"),
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
@@ -1785,7 +1786,7 @@ return profile {
|
||||
task = { enabled = true },
|
||||
memory = { enabled = false },
|
||||
web = { enabled = true },
|
||||
pods = { enabled = true },
|
||||
workers = { enabled = true },
|
||||
ticket = { enabled = true, access = "read_only" },
|
||||
ticket_orchestration = { enabled = false },
|
||||
},
|
||||
@@ -1798,14 +1799,14 @@ return profile {
|
||||
.with_workspace_base(&workspace)
|
||||
.resolve(
|
||||
&ProfileSelector::path(&profile),
|
||||
ProfileResolveOptions::with_pod_name("runtime-pod"),
|
||||
ProfileResolveOptions::with_worker_name("runtime-worker"),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(resolved.manifest.pod.name, "runtime-pod");
|
||||
assert_eq!(resolved.manifest.worker.name, "runtime-worker");
|
||||
assert!(resolved.manifest.feature.task.enabled);
|
||||
assert!(!resolved.manifest.feature.memory.enabled);
|
||||
assert!(resolved.manifest.feature.web.enabled);
|
||||
assert!(resolved.manifest.feature.pods.enabled);
|
||||
assert!(resolved.manifest.feature.workers.enabled);
|
||||
assert!(resolved.manifest.feature.ticket.enabled);
|
||||
assert_eq!(
|
||||
resolved.manifest.feature.ticket.access,
|
||||
@@ -1844,7 +1845,7 @@ return yoi.profile {
|
||||
.with_workspace_base(tmp.path())
|
||||
.resolve(
|
||||
&ProfileSelector::path(profile),
|
||||
ProfileResolveOptions::with_pod_name("p"),
|
||||
ProfileResolveOptions::with_worker_name("p"),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
@@ -1877,7 +1878,7 @@ p.slug = "assigned"
|
||||
p.model = yoi.models.catalog("anthropic/claude-sonnet-4-6")
|
||||
p.feature = {
|
||||
task = { enabled = false },
|
||||
pods = { enabled = true },
|
||||
workers = { enabled = true },
|
||||
}
|
||||
p.web = { enabled = false }
|
||||
p.compaction = yoi.compact.tokens { threshold = 123, request_threshold = 456 }
|
||||
@@ -1888,7 +1889,7 @@ return p
|
||||
.with_workspace_base(tmp.path())
|
||||
.resolve(
|
||||
&ProfileSelector::path(profile),
|
||||
ProfileResolveOptions::with_pod_name("p"),
|
||||
ProfileResolveOptions::with_worker_name("p"),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
@@ -1896,7 +1897,7 @@ return p
|
||||
Some("anthropic/claude-sonnet-4-6")
|
||||
);
|
||||
assert!(!resolved.manifest.feature.task.enabled);
|
||||
assert!(resolved.manifest.feature.pods.enabled);
|
||||
assert!(resolved.manifest.feature.workers.enabled);
|
||||
assert_eq!(resolved.manifest.web.as_ref().unwrap().enabled, Some(false));
|
||||
assert!(resolved.manifest.web.as_ref().unwrap().search.is_none());
|
||||
assert_eq!(
|
||||
@@ -1925,7 +1926,7 @@ return yoi.profile.extend("builtin:default", {
|
||||
.with_workspace_base(tmp.path())
|
||||
.resolve(
|
||||
&ProfileSelector::path(profile),
|
||||
ProfileResolveOptions::with_pod_name("p"),
|
||||
ProfileResolveOptions::with_worker_name("p"),
|
||||
)
|
||||
.unwrap_err();
|
||||
let message = err.to_string();
|
||||
@@ -1948,7 +1949,7 @@ return yoi.profile.extend("builtin:default", {
|
||||
.with_workspace_base(tmp.path())
|
||||
.resolve(
|
||||
&ProfileSelector::path(path),
|
||||
ProfileResolveOptions::with_pod_name("p"),
|
||||
ProfileResolveOptions::with_worker_name("p"),
|
||||
)
|
||||
.unwrap_err();
|
||||
assert!(matches!(
|
||||
@@ -1962,7 +1963,7 @@ return yoi.profile.extend("builtin:default", {
|
||||
for (value, needle) in [
|
||||
(serde_json::json!({"manifest": {}}), "manifest"),
|
||||
(serde_json::json!({"config": {}}), "config"),
|
||||
(serde_json::json!({"pod": {"name": "bad"}}), "pod"),
|
||||
(serde_json::json!({"worker": {"name": "bad"}}), "worker"),
|
||||
(
|
||||
serde_json::json!({"model": {"ref": "codex-oauth/gpt-5.5"}, "scope": {"allow": []}}),
|
||||
"scope.allow",
|
||||
@@ -2008,7 +2009,7 @@ return profile {
|
||||
.with_workspace_base(tmp.path())
|
||||
.resolve(
|
||||
&ProfileSelector::path(profile),
|
||||
ProfileResolveOptions::with_pod_name("p"),
|
||||
ProfileResolveOptions::with_worker_name("p"),
|
||||
)
|
||||
.unwrap();
|
||||
let c = resolved.manifest.compaction.unwrap();
|
||||
@@ -2023,10 +2024,10 @@ return profile {
|
||||
.with_workspace_base(tmp.path())
|
||||
.resolve(
|
||||
&ProfileSelector::source_named(ProfileRegistrySource::Builtin, "default"),
|
||||
ProfileResolveOptions::with_pod_name("runtime-workspace"),
|
||||
ProfileResolveOptions::with_worker_name("runtime-workspace"),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(resolved.manifest.pod.name, "runtime-workspace");
|
||||
assert_eq!(resolved.manifest.worker.name, "runtime-workspace");
|
||||
assert_eq!(
|
||||
resolved.manifest.model.ref_.as_deref(),
|
||||
Some("codex-oauth/gpt-5.5")
|
||||
@@ -2060,9 +2061,9 @@ return profile {
|
||||
std::fs::write(
|
||||
&override_path,
|
||||
r#"
|
||||
[pod]
|
||||
prompt_pack = "prompts.toml"
|
||||
[worker]
|
||||
prompt_pack = "prompts.toml"
|
||||
[engine]
|
||||
language = "ja"
|
||||
[session]
|
||||
record_event_trace = false
|
||||
@@ -2074,15 +2075,15 @@ record_event_trace = false
|
||||
.with_workspace_base(&nested)
|
||||
.resolve(
|
||||
&ProfileSelector::Default,
|
||||
ProfileResolveOptions::with_pod_name("runtime-pod"),
|
||||
ProfileResolveOptions::with_worker_name("runtime-worker"),
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(resolved.manifest.pod.name, "runtime-pod");
|
||||
assert_eq!(resolved.manifest.worker.language, "ja");
|
||||
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.pod.prompt_pack.as_deref(),
|
||||
resolved.manifest.worker.prompt_pack.as_deref(),
|
||||
Some(yoi_dir.join("prompts.toml").as_path())
|
||||
);
|
||||
assert!(resolved.manifest.scope.allow.is_empty());
|
||||
@@ -2111,9 +2112,9 @@ record_event_trace = false
|
||||
std::fs::write(
|
||||
parent_yoi.join(WORKSPACE_OVERRIDE_LOCAL_FILENAME),
|
||||
r#"
|
||||
[pod]
|
||||
prompt_pack = "parent-prompts.toml"
|
||||
[worker]
|
||||
prompt_pack = "parent-prompts.toml"
|
||||
[engine]
|
||||
language = "parent"
|
||||
"#,
|
||||
)
|
||||
@@ -2122,9 +2123,9 @@ language = "parent"
|
||||
std::fs::write(
|
||||
&nested_override_path,
|
||||
r#"
|
||||
[pod]
|
||||
prompt_pack = "nested-prompts.toml"
|
||||
[worker]
|
||||
prompt_pack = "nested-prompts.toml"
|
||||
[engine]
|
||||
language = "nested"
|
||||
"#,
|
||||
)
|
||||
@@ -2134,13 +2135,13 @@ language = "nested"
|
||||
.with_workspace_base(&child)
|
||||
.resolve(
|
||||
&ProfileSelector::Default,
|
||||
ProfileResolveOptions::with_pod_name("runtime-pod"),
|
||||
ProfileResolveOptions::with_worker_name("runtime-worker"),
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(resolved.manifest.worker.language, "nested");
|
||||
assert_eq!(resolved.manifest.engine.language, "nested");
|
||||
assert_eq!(
|
||||
resolved.manifest.pod.prompt_pack.as_deref(),
|
||||
resolved.manifest.worker.prompt_pack.as_deref(),
|
||||
Some(nested_yoi.join("nested-prompts.toml").as_path())
|
||||
);
|
||||
assert_eq!(
|
||||
@@ -2155,13 +2156,13 @@ language = "nested"
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn workspace_local_override_rejects_runtime_pod_name() {
|
||||
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),
|
||||
"[pod]\nname = \"not-local\"\n",
|
||||
"[worker]\nname = \"not-local\"\n",
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
@@ -2169,11 +2170,11 @@ language = "nested"
|
||||
.with_workspace_base(tmp.path())
|
||||
.resolve(
|
||||
&ProfileSelector::Default,
|
||||
ProfileResolveOptions::with_pod_name("runtime-pod"),
|
||||
ProfileResolveOptions::with_worker_name("runtime-worker"),
|
||||
)
|
||||
.unwrap_err();
|
||||
assert!(matches!(err, ProfileError::InvalidWorkspaceOverride { .. }));
|
||||
assert!(err.to_string().contains("pod.name"));
|
||||
assert!(err.to_string().contains("worker.name"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
//! Runtime representation of a Pod's access scope.
|
||||
//! Runtime representation of a Worker's access scope.
|
||||
//!
|
||||
//! Built from [`crate::ScopeConfig`] via [`Scope::from_config`]. Every
|
||||
//! rule `target` must already be an absolute path — per-layer path
|
||||
//! resolution runs earlier, inside [`crate::PodManifestConfig::resolve_paths`].
|
||||
//! resolution runs earlier, inside [`crate::WorkerManifestConfig::resolve_paths`].
|
||||
//! All rule `target` paths inside the [`Scope`] are canonicalised (where
|
||||
//! possible) so access checks are pure path comparisons.
|
||||
|
||||
@@ -14,7 +14,7 @@ use arc_swap::{ArcSwap, Guard};
|
||||
|
||||
use crate::{Permission, ScopeConfig, ScopeRule};
|
||||
|
||||
/// Parsed, pwd-resolved set of allow/deny rules for a Pod.
|
||||
/// Parsed, pwd-resolved set of allow/deny rules for a Worker.
|
||||
///
|
||||
/// Read/write access decisions are pure functions of the path being
|
||||
/// queried and these rules — see [`Scope::permission_at`].
|
||||
@@ -32,7 +32,7 @@ struct ResolvedRule {
|
||||
recursive: bool,
|
||||
}
|
||||
|
||||
/// Parsed filesystem authority this Pod may pass to spawned children.
|
||||
/// Parsed filesystem authority this Worker may pass to spawned children.
|
||||
///
|
||||
/// Unlike [`Scope`], an empty allow list is valid and means no delegation
|
||||
/// authority. Direct tools never consult this type.
|
||||
@@ -173,7 +173,7 @@ impl Scope {
|
||||
///
|
||||
/// Every `target` in `config` must already be absolute — per-layer
|
||||
/// resolution happens upstream in
|
||||
/// [`crate::PodManifestConfig::resolve_paths`] so that cascade merge
|
||||
/// [`crate::WorkerManifestConfig::resolve_paths`] so that cascade merge
|
||||
/// operates on fully-qualified paths. A lingering relative target
|
||||
/// here signals an upstream bug and is rejected.
|
||||
pub fn from_config(config: &ScopeConfig) -> Result<Self, ScopeError> {
|
||||
@@ -266,7 +266,7 @@ impl Scope {
|
||||
|
||||
/// Allow rules with their targets resolved to absolute paths.
|
||||
///
|
||||
/// Used by the pod-registry, where every Pod's allocation
|
||||
/// Used by the pod-registry, where every Worker's allocation
|
||||
/// must be expressed in absolute terms so prefix comparisons are
|
||||
/// meaningful across processes.
|
||||
pub fn allow_rules(&self) -> Vec<ScopeRule> {
|
||||
@@ -324,7 +324,7 @@ impl Scope {
|
||||
|
||||
/// Build a new [`Scope`] equal to `self` with `extra_deny` appended
|
||||
/// to the deny set. Used by dynamic-scope shrink paths
|
||||
/// (e.g. SpawnPod-style delegation that strips Write from the
|
||||
/// (e.g. SpawnWorker-style delegation that strips Write from the
|
||||
/// spawner without touching its allow rules).
|
||||
pub fn with_added_deny_rules(
|
||||
&self,
|
||||
@@ -420,7 +420,7 @@ impl Scope {
|
||||
/// not lose each other's contributions.
|
||||
///
|
||||
/// All clones share the same underlying state — a `SharedScope` cloned
|
||||
/// out to multiple consumers (Pod, ScopedFs, future grant/revoke
|
||||
/// out to multiple consumers (Worker, ScopedFs, future grant/revoke
|
||||
/// callers) sees every update.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct SharedScope {
|
||||
|
||||
Reference in New Issue
Block a user