use std::path::PathBuf;
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(tag = "kind", rename_all = "kebab-case")]
pub enum PreCheckStrategy {
Always,
GitDiff { watch_paths: Vec<String> },
GiteaIssue { issue_number: u64 },
Shell {
script: String,
#[serde(default = "default_pre_check_timeout")]
timeout_secs: u64,
},
}
fn default_pre_check_timeout() -> u64 {
60
}
const GRACE_PERIOD_MIN_SECS: u64 = 5;
const GRACE_PERIOD_MAX_SECS: u64 = 300;
const MAX_CPU_MIN_SECS: u64 = 60;
const MAX_CPU_MAX_SECS: u64 = 7200;
const PROBE_TTL_MIN_SECS: u64 = 60;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Project {
pub id: String,
pub working_dir: PathBuf,
#[serde(default)]
pub schedule_offset_minutes: u8,
#[serde(default)]
pub gitea: Option<GiteaOutputConfig>,
#[serde(default)]
pub mentions: Option<MentionConfig>,
#[serde(default)]
pub workflow: Option<WorkflowConfig>,
#[cfg(feature = "quickwit")]
#[serde(default)]
pub quickwit: Option<QuickwitConfig>,
#[serde(default)]
pub max_concurrent_agents: Option<usize>,
#[serde(default)]
pub max_concurrent_mention_agents: Option<usize>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct OrchestratorConfig {
pub working_dir: PathBuf,
pub nightwatch: NightwatchConfig,
pub compound_review: CompoundReviewConfig,
#[serde(default)]
pub workflow: Option<WorkflowConfig>,
#[serde(default)]
pub agents: Vec<AgentDefinition>,
#[serde(default = "default_restart_cooldown")]
pub restart_cooldown_secs: u64,
#[serde(default = "default_max_restart_count")]
pub max_restart_count: u32,
#[serde(default = "default_restart_budget_window")]
pub restart_budget_window_secs: u64,
#[serde(default = "default_disk_usage_threshold")]
pub disk_usage_threshold: u8,
#[serde(default = "default_tick_interval")]
pub tick_interval_secs: u64,
#[serde(default = "default_gate_reconcile_interval_ticks")]
pub gate_reconcile_interval_ticks: u32,
#[serde(default)]
pub handoff_buffer_ttl_secs: Option<u64>,
#[serde(default)]
pub persona_data_dir: Option<PathBuf>,
#[serde(default)]
pub skill_data_dir: Option<PathBuf>,
#[serde(default)]
pub flows: Vec<crate::flow::config::FlowDefinition>,
#[serde(default)]
pub flow_state_dir: Option<PathBuf>,
#[serde(default)]
pub gitea: Option<GiteaOutputConfig>,
#[serde(default)]
pub mentions: Option<MentionConfig>,
#[serde(default)]
pub webhook: Option<WebhookConfig>,
#[serde(default)]
pub role_config_path: Option<PathBuf>,
#[serde(default)]
pub routing: Option<RoutingConfig>,
#[cfg(feature = "quickwit")]
#[serde(default)]
pub quickwit: Option<QuickwitConfig>,
#[serde(default)]
pub projects: Vec<Project>,
#[serde(default)]
pub include: Vec<String>,
#[serde(default)]
pub providers: Vec<crate::provider_budget::ProviderBudgetConfig>,
#[serde(default)]
pub provider_budget_state_file: Option<PathBuf>,
#[serde(default)]
pub pause_dir: Option<PathBuf>,
#[serde(default = "default_project_circuit_breaker_threshold")]
pub project_circuit_breaker_threshold: u32,
#[serde(default)]
pub fleet_escalation_owner: Option<String>,
#[serde(default)]
pub fleet_escalation_repo: Option<String>,
#[serde(default)]
pub post_merge_gate: Option<PostMergeGateConfig>,
#[serde(default)]
pub learning: LearningConfig,
#[serde(default)]
pub evolution: EvolutionConfig,
#[serde(default)]
pub pr_dispatch: Option<PrDispatchConfig>,
#[serde(default, skip_deserializing)]
pub pr_dispatch_per_project: std::collections::HashMap<String, PrDispatchConfig>,
#[serde(default)]
pub gitea_skill_repo: Option<GiteaSkillRepoConfig>,
}
#[derive(Clone, Serialize, Deserialize)]
pub struct GiteaSkillRepoConfig {
pub url: String,
pub owner: String,
pub repo: String,
#[serde(default = "default_git_ref")]
pub git_ref: String,
#[serde(default = "default_cache_dir")]
pub cache_dir: PathBuf,
#[serde(default)]
pub token: Option<String>,
#[serde(default = "default_fetch_timeout")]
pub fetch_timeout_secs: u64,
#[serde(default)]
pub skills: Vec<String>,
}
impl std::fmt::Debug for GiteaSkillRepoConfig {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("GiteaSkillRepoConfig")
.field("url", &self.url)
.field("owner", &self.owner)
.field("repo", &self.repo)
.field("git_ref", &self.git_ref)
.field("cache_dir", &self.cache_dir)
.field("token", &self.token.as_ref().map(|_| "***REDACTED***"))
.field("fetch_timeout_secs", &self.fetch_timeout_secs)
.field("skills", &self.skills)
.finish()
}
}
fn default_git_ref() -> String {
"main".to_string()
}
fn default_fetch_timeout() -> u64 {
30
}
fn default_cache_dir() -> PathBuf {
if let Ok(xdg) = std::env::var("XDG_CACHE_HOME") {
if !xdg.is_empty() {
return PathBuf::from(xdg).join("terraphim/skills");
}
}
if let Ok(home) = std::env::var("HOME") {
if !home.is_empty() {
return PathBuf::from(home).join(".cache/terraphim/skills");
}
}
std::env::temp_dir().join("terraphim/skills")
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct PrDispatchConfig {
#[serde(default)]
pub agents_on_pr_open: Vec<PrDispatchEntry>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PrDispatchEntry {
pub name: String,
pub context: String,
}
impl PrDispatchConfig {
pub fn legacy_default() -> Self {
Self {
agents_on_pr_open: vec![PrDispatchEntry {
name: "pr-reviewer".to_string(),
context: "adf/pr-reviewer".to_string(),
}],
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct LearningConfig {
#[serde(default)]
pub enabled: bool,
#[serde(default = "default_learning_min_trust")]
pub min_trust: String,
#[serde(default = "default_learning_max_tokens")]
pub max_tokens: usize,
#[serde(default = "default_learning_max_entries")]
pub max_entries: usize,
#[serde(default = "default_learning_archive_days")]
pub archive_days: u32,
#[serde(default = "default_learning_consolidation_ticks")]
pub consolidation_ticks: u64,
}
fn default_learning_min_trust() -> String {
"L1".to_string()
}
fn default_learning_max_tokens() -> usize {
1500
}
fn default_learning_max_entries() -> usize {
10
}
fn default_learning_archive_days() -> u32 {
30
}
fn default_learning_consolidation_ticks() -> u64 {
100
}
impl Default for LearningConfig {
fn default() -> Self {
Self {
enabled: false,
min_trust: default_learning_min_trust(),
max_tokens: default_learning_max_tokens(),
max_entries: default_learning_max_entries(),
archive_days: default_learning_archive_days(),
consolidation_ticks: default_learning_consolidation_ticks(),
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct EvolutionConfig {
#[serde(default)]
pub enabled: bool,
#[serde(default = "default_evolution_max_memory_tokens")]
pub max_memory_tokens: usize,
#[serde(default = "default_evolution_max_snapshots")]
pub max_snapshots_per_agent: usize,
#[serde(default = "default_evolution_consolidation_ticks")]
pub consolidation_interval_ticks: u64,
}
fn default_evolution_max_memory_tokens() -> usize {
1500
}
fn default_evolution_max_snapshots() -> usize {
100
}
fn default_evolution_consolidation_ticks() -> u64 {
200
}
impl Default for EvolutionConfig {
fn default() -> Self {
Self {
enabled: false,
max_memory_tokens: default_evolution_max_memory_tokens(),
max_snapshots_per_agent: default_evolution_max_snapshots(),
consolidation_interval_ticks: default_evolution_consolidation_ticks(),
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PostMergeGateConfig {
#[serde(default = "default_post_merge_gate_max_test_duration_secs")]
pub max_test_duration_secs: u64,
#[serde(default = "default_post_merge_gate_remote")]
pub revert_push_remote: Option<String>,
#[serde(default = "default_post_merge_gate_branch")]
pub revert_push_branch: String,
}
fn default_post_merge_gate_max_test_duration_secs() -> u64 {
crate::post_merge_gate::DEFAULT_MAX_TEST_DURATION_SECS
}
fn default_post_merge_gate_remote() -> Option<String> {
Some("origin".to_string())
}
fn default_post_merge_gate_branch() -> String {
"main".to_string()
}
impl Default for PostMergeGateConfig {
fn default() -> Self {
Self {
max_test_duration_secs: default_post_merge_gate_max_test_duration_secs(),
revert_push_remote: default_post_merge_gate_remote(),
revert_push_branch: default_post_merge_gate_branch(),
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct RoutingConfig {
pub taxonomy_path: PathBuf,
#[serde(default = "default_probe_ttl")]
pub probe_ttl_secs: u64,
#[serde(default)]
pub probe_results_dir: Option<PathBuf>,
#[serde(default = "default_true_routing")]
pub probe_on_startup: bool,
#[serde(default)]
pub use_routing_engine: bool,
#[serde(default)]
pub route_selection_strategy: crate::control_plane::RouteSelectionStrategy,
}
fn default_probe_ttl() -> u64 {
1800
}
fn default_true_routing() -> bool {
true
}
#[derive(Clone, Serialize, Deserialize)]
pub struct GiteaOutputConfig {
pub base_url: String,
pub token: String,
pub owner: String,
pub repo: String,
#[serde(default)]
pub agent_tokens_path: Option<PathBuf>,
}
impl std::fmt::Debug for GiteaOutputConfig {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("GiteaOutputConfig")
.field("base_url", &self.base_url)
.field("token", &"***REDACTED***")
.field("owner", &self.owner)
.field("repo", &self.repo)
.field("agent_tokens_path", &self.agent_tokens_path)
.finish()
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct MentionConfig {
#[serde(default = "default_poll_modulo")]
pub poll_modulo: u64,
#[serde(default = "default_max_dispatches_per_tick")]
pub max_dispatches_per_tick: u32,
#[serde(default = "default_max_concurrent_mention_agents")]
pub max_concurrent_mention_agents: u32,
#[serde(default = "default_max_mention_depth")]
pub max_mention_depth: u32,
}
fn default_poll_modulo() -> u64 {
2
}
fn default_max_dispatches_per_tick() -> u32 {
3
}
fn default_max_concurrent_mention_agents() -> u32 {
5
}
fn default_max_mention_depth() -> u32 {
crate::mention_chain::DEFAULT_MAX_MENTION_DEPTH
}
impl Default for MentionConfig {
fn default() -> Self {
Self {
poll_modulo: default_poll_modulo(),
max_dispatches_per_tick: default_max_dispatches_per_tick(),
max_concurrent_mention_agents: default_max_concurrent_mention_agents(),
max_mention_depth: default_max_mention_depth(),
}
}
}
#[derive(Clone, Serialize, Deserialize)]
pub struct WebhookConfig {
#[serde(default = "default_webhook_bind")]
pub bind: String,
pub secret: Option<String>,
}
impl std::fmt::Debug for WebhookConfig {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("WebhookConfig")
.field("bind", &self.bind)
.field("secret", &self.secret.as_ref().map(|_| "***REDACTED***"))
.finish()
}
}
fn default_webhook_bind() -> String {
"127.0.0.1:9090".to_string()
}
#[cfg(feature = "quickwit")]
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct QuickwitConfig {
#[serde(default = "default_quickwit_enabled")]
pub enabled: bool,
#[serde(default = "default_quickwit_endpoint")]
pub endpoint: String,
#[serde(default = "default_quickwit_index_id")]
pub index_id: String,
#[serde(default = "default_quickwit_batch_size")]
pub batch_size: usize,
#[serde(default = "default_quickwit_flush_interval_secs")]
pub flush_interval_secs: u64,
#[serde(default = "default_quickwit_use_es_bulk")]
pub use_es_bulk: bool,
}
#[cfg(feature = "quickwit")]
impl Default for QuickwitConfig {
fn default() -> Self {
Self {
enabled: default_quickwit_enabled(),
endpoint: default_quickwit_endpoint(),
index_id: default_quickwit_index_id(),
batch_size: default_quickwit_batch_size(),
flush_interval_secs: default_quickwit_flush_interval_secs(),
use_es_bulk: default_quickwit_use_es_bulk(),
}
}
}
#[cfg(feature = "quickwit")]
fn default_quickwit_enabled() -> bool {
false
}
#[cfg(feature = "quickwit")]
fn default_quickwit_endpoint() -> String {
"http://127.0.0.1:7280".to_string()
}
#[cfg(feature = "quickwit")]
fn default_quickwit_index_id() -> String {
"adf-logs".to_string()
}
#[cfg(feature = "quickwit")]
fn default_quickwit_batch_size() -> usize {
100
}
#[cfg(feature = "quickwit")]
fn default_quickwit_flush_interval_secs() -> u64 {
5
}
#[cfg(feature = "quickwit")]
fn default_quickwit_use_es_bulk() -> bool {
false
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct SfiaSkillRef {
pub code: String,
pub level: u8,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AgentDefinition {
pub name: String,
pub layer: AgentLayer,
pub cli_tool: String,
pub task: String,
pub schedule: Option<String>,
pub model: Option<String>,
#[serde(default)]
pub capabilities: Vec<String>,
pub max_memory_bytes: Option<u64>,
#[serde(default)]
pub budget_monthly_cents: Option<u64>,
#[serde(default)]
pub provider: Option<String>,
#[serde(default)]
pub persona: Option<String>,
#[serde(default)]
pub terraphim_role: Option<String>,
#[serde(default)]
pub skill_chain: Vec<String>,
#[serde(default)]
pub sfia_skills: Vec<SfiaSkillRef>,
#[serde(default)]
pub fallback_provider: Option<String>,
#[serde(default)]
pub fallback_model: Option<String>,
#[serde(default)]
pub grace_period_secs: Option<u64>,
#[serde(default)]
pub max_cpu_seconds: Option<u64>,
#[serde(default)]
pub pre_check: Option<PreCheckStrategy>,
#[serde(default)]
pub gitea_issue: Option<u64>,
#[serde(default)]
pub event_only: bool,
#[serde(default)]
pub project: Option<String>,
#[serde(default)]
pub evolution_enabled: bool,
#[serde(default)]
pub rlm_enabled: Option<bool>,
#[serde(default)]
pub bypass_kg_routing: bool,
#[serde(default = "default_agent_enabled")]
pub enabled: bool,
}
fn default_agent_enabled() -> bool {
true
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum AgentLayer {
Safety,
Core,
Growth,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct NightwatchConfig {
#[serde(default = "default_eval_interval")]
pub eval_interval_secs: u64,
#[serde(default = "default_minor_threshold")]
pub minor_threshold: f64,
#[serde(default = "default_moderate_threshold")]
pub moderate_threshold: f64,
#[serde(default = "default_severe_threshold")]
pub severe_threshold: f64,
#[serde(default = "default_critical_threshold")]
pub critical_threshold: f64,
#[serde(default)]
pub active_start_hour: u8,
#[serde(default = "default_active_end_hour")]
pub active_end_hour: u8,
#[serde(default = "default_error_weight")]
pub error_weight: f64,
#[serde(default = "default_success_weight")]
pub success_weight: f64,
#[serde(default = "default_health_weight")]
pub health_weight: f64,
#[serde(default = "default_budget_weight")]
pub budget_weight: f64,
}
impl Default for NightwatchConfig {
fn default() -> Self {
Self {
eval_interval_secs: default_eval_interval(),
minor_threshold: default_minor_threshold(),
moderate_threshold: default_moderate_threshold(),
severe_threshold: default_severe_threshold(),
critical_threshold: default_critical_threshold(),
active_start_hour: 0,
active_end_hour: default_active_end_hour(),
error_weight: default_error_weight(),
success_weight: default_success_weight(),
health_weight: default_health_weight(),
budget_weight: default_budget_weight(),
}
}
}
fn default_eval_interval() -> u64 {
300
}
fn default_minor_threshold() -> f64 {
0.10
}
fn default_moderate_threshold() -> f64 {
0.20
}
fn default_severe_threshold() -> f64 {
0.40
}
fn default_critical_threshold() -> f64 {
0.70
}
fn default_active_end_hour() -> u8 {
24
}
fn default_error_weight() -> f64 {
0.35
}
fn default_success_weight() -> f64 {
0.25
}
fn default_health_weight() -> f64 {
0.20
}
fn default_budget_weight() -> f64 {
0.20
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CompoundReviewConfig {
pub schedule: String,
#[serde(default = "default_max_duration")]
pub max_duration_secs: u64,
pub repo_path: PathBuf,
#[serde(default)]
pub create_prs: bool,
#[serde(default = "default_worktree_root")]
pub worktree_root: PathBuf,
#[serde(default = "default_base_branch")]
pub base_branch: String,
#[serde(default = "default_max_concurrent_agents")]
pub max_concurrent_agents: usize,
#[serde(default)]
pub cli_tool: Option<String>,
#[serde(default)]
pub provider: Option<String>,
#[serde(default)]
pub model: Option<String>,
#[serde(default)]
pub gitea_issue: Option<u64>,
#[serde(default)]
pub auto_file_issues: bool,
#[serde(default)]
pub auto_remediate: bool,
#[serde(default)]
pub remediation_agents: std::collections::HashMap<String, String>,
}
fn default_max_duration() -> u64 {
1800
}
fn default_worktree_root() -> PathBuf {
PathBuf::from(".worktrees")
}
fn default_base_branch() -> String {
"main".to_string()
}
fn default_max_concurrent_agents() -> usize {
3
}
impl Default for CompoundReviewConfig {
fn default() -> Self {
Self {
schedule: "0 2 * * *".to_string(),
max_duration_secs: default_max_duration(),
repo_path: PathBuf::from("."),
create_prs: false,
worktree_root: default_worktree_root(),
base_branch: default_base_branch(),
max_concurrent_agents: default_max_concurrent_agents(),
cli_tool: None,
provider: None,
model: None,
gitea_issue: None,
auto_file_issues: false,
auto_remediate: false,
remediation_agents: std::collections::HashMap::new(),
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct WorkflowConfig {
pub enabled: bool,
#[serde(default = "default_poll_interval")]
pub poll_interval_secs: u64,
pub workflow_file: PathBuf,
pub tracker: TrackerConfig,
#[serde(default)]
pub concurrency: ConcurrencyConfig,
}
#[derive(Clone, Serialize, Deserialize)]
pub struct TrackerConfig {
pub kind: String,
pub endpoint: String,
pub api_key: String,
pub owner: String,
pub repo: String,
#[serde(default)]
pub project_slug: Option<String>,
#[serde(default)]
pub use_robot_api: bool,
#[serde(default)]
pub states: TrackerStates,
}
impl std::fmt::Debug for TrackerConfig {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("TrackerConfig")
.field("kind", &self.kind)
.field("endpoint", &self.endpoint)
.field("api_key", &"***REDACTED***")
.field("owner", &self.owner)
.field("repo", &self.repo)
.field("project_slug", &self.project_slug)
.field("use_robot_api", &self.use_robot_api)
.field("states", &self.states)
.finish()
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct TrackerStates {
#[serde(default = "default_active_states")]
pub active: Vec<String>,
#[serde(default = "default_terminal_states")]
pub terminal: Vec<String>,
}
impl Default for TrackerStates {
fn default() -> Self {
Self {
active: default_active_states(),
terminal: default_terminal_states(),
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ConcurrencyConfig {
#[serde(default = "default_global_max")]
pub global_max: usize,
#[serde(default = "default_issue_max")]
pub issue_max: usize,
#[serde(default = "default_fairness")]
pub fairness: String,
}
impl Default for ConcurrencyConfig {
fn default() -> Self {
Self {
global_max: default_global_max(),
issue_max: default_issue_max(),
fairness: default_fairness(),
}
}
}
fn default_poll_interval() -> u64 {
120 }
fn default_active_states() -> Vec<String> {
vec!["Todo".into(), "In Progress".into()]
}
fn default_terminal_states() -> Vec<String> {
vec!["Done".into(), "Closed".into(), "Cancelled".into()]
}
fn default_global_max() -> usize {
5
}
fn default_issue_max() -> usize {
3
}
fn default_fairness() -> String {
"round_robin".into()
}
fn default_restart_cooldown() -> u64 {
60
}
fn default_max_restart_count() -> u32 {
10
}
fn default_restart_budget_window() -> u64 {
43_200
}
fn default_disk_usage_threshold() -> u8 {
90
}
fn default_tick_interval() -> u64 {
30
}
fn default_gate_reconcile_interval_ticks() -> u32 {
20
}
fn default_project_circuit_breaker_threshold() -> u32 {
crate::project_control::DEFAULT_PROJECT_CIRCUIT_BREAKER_THRESHOLD
}
#[derive(Debug, Clone, Default, Deserialize)]
#[serde(deny_unknown_fields)]
struct IncludeFragment {
#[serde(default)]
projects: Vec<Project>,
#[serde(default)]
agents: Vec<AgentDefinition>,
#[serde(default)]
flows: Vec<crate::flow::config::FlowDefinition>,
#[serde(default)]
pr_dispatch: Option<PrDispatchConfig>,
}
pub const ALLOWED_PROVIDER_PREFIXES: &[&str] = &[
"claude-code",
"opencode-go",
"kimi-for-coding",
"minimax-coding-plan",
"zai-coding-plan",
"openai",
];
pub const BANNED_PROVIDER_PREFIXES: &[&str] = &[
"opencode",
"github-copilot",
"google",
"huggingface",
"minimax",
];
pub const CLAUDE_CLI_BARE_MODELS: &[&str] = &["sonnet", "opus", "haiku"];
pub const ANTHROPIC_BARE_PROVIDERS: &[&str] = &["anthropic"];
pub fn is_allowed_provider(provider_or_model: &str) -> bool {
if let Some((prefix, _)) = provider_or_model.split_once('/') {
if ANTHROPIC_BARE_PROVIDERS.contains(&prefix) {
return true;
}
if BANNED_PROVIDER_PREFIXES.contains(&prefix) {
return false;
}
return ALLOWED_PROVIDER_PREFIXES.contains(&prefix);
}
CLAUDE_CLI_BARE_MODELS.contains(&provider_or_model)
|| ANTHROPIC_BARE_PROVIDERS.contains(&provider_or_model)
|| ALLOWED_PROVIDER_PREFIXES.contains(&provider_or_model)
}
pub(crate) fn validate_model_provider(
agent_name: &str,
field: &str,
model: &str,
) -> Result<(), crate::error::OrchestratorError> {
if !model.contains('/') {
if CLAUDE_CLI_BARE_MODELS.contains(&model)
|| ANTHROPIC_BARE_PROVIDERS.contains(&model)
|| ALLOWED_PROVIDER_PREFIXES.contains(&model)
{
return Ok(());
}
return Err(crate::error::OrchestratorError::BannedProvider {
agent: agent_name.to_string(),
provider: model.to_string(),
field: field.to_string(),
});
}
let prefix = model.split('/').next().unwrap_or("");
if ANTHROPIC_BARE_PROVIDERS.contains(&prefix) {
return Ok(());
}
for banned in BANNED_PROVIDER_PREFIXES {
if prefix == *banned {
return Err(crate::error::OrchestratorError::BannedProvider {
agent: agent_name.to_string(),
provider: model.to_string(),
field: field.to_string(),
});
}
}
if ALLOWED_PROVIDER_PREFIXES.contains(&prefix) {
return Ok(());
}
Err(crate::error::OrchestratorError::BannedProvider {
agent: agent_name.to_string(),
provider: model.to_string(),
field: field.to_string(),
})
}
pub fn warn_if_world_readable(path: &std::path::Path) {
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
match std::fs::metadata(path) {
Ok(meta) => {
let mode = meta.permissions().mode();
if mode & 0o004 != 0 {
tracing::error!(
path = %path.display(),
mode = format!("{:04o}", mode & 0o777),
"SECURITY: sensitive file is world-readable. \
Fix immediately: chmod 600 {}",
path.display()
);
} else if mode & 0o040 != 0 {
tracing::warn!(
path = %path.display(),
mode = format!("{:04o}", mode & 0o777),
"SECURITY: sensitive file is group-readable. \
Consider: chmod 600 {}",
path.display()
);
}
}
Err(e) => {
tracing::debug!(path = %path.display(), error = %e, "could not stat file for permission check");
}
}
}
}
pub(crate) fn expand_env_vars(s: &str) -> String {
use std::sync::OnceLock;
static RE: OnceLock<regex::Regex> = OnceLock::new();
let re = RE.get_or_init(|| {
regex::Regex::new(r"\$\{([A-Za-z_][A-Za-z0-9_]*)\}").expect("valid env-var expansion regex")
});
re.replace_all(s, |caps: ®ex::Captures| {
std::env::var(&caps[1]).unwrap_or_default()
})
.into_owned()
}
impl OrchestratorConfig {
pub fn project_by_id(&self, id: &str) -> Option<&Project> {
self.projects.iter().find(|p| p.id == id)
}
pub fn agents_on_pr_open_for_project(&self, project: &str) -> Vec<PrDispatchEntry> {
if let Some(d) = self.pr_dispatch_per_project.get(project) {
return d.agents_on_pr_open.clone();
}
if let Some(d) = self.pr_dispatch.as_ref() {
return d.agents_on_pr_open.clone();
}
PrDispatchConfig::legacy_default().agents_on_pr_open
}
pub fn working_dir_for_agent(&self, agent: &AgentDefinition) -> PathBuf {
agent
.project
.as_deref()
.and_then(|pid| self.project_by_id(pid))
.map(|p| p.working_dir.clone())
.unwrap_or_else(|| self.working_dir.clone())
}
pub fn from_toml(toml_str: &str) -> Result<Self, crate::error::OrchestratorError> {
let expanded = expand_env_vars(toml_str);
toml::from_str(&expanded)
.map_err(|e| crate::error::OrchestratorError::Config(e.to_string()))
}
pub fn from_file(
path: impl AsRef<std::path::Path>,
) -> Result<Self, crate::error::OrchestratorError> {
let path = path.as_ref();
warn_if_world_readable(path);
let content = std::fs::read_to_string(path)?;
let mut config = Self::from_toml(&content)?;
if config.include.is_empty() {
return Ok(config);
}
let base_dir = path.parent().unwrap_or_else(|| std::path::Path::new("."));
let patterns = std::mem::take(&mut config.include);
for pattern in &patterns {
let full_pattern = if std::path::Path::new(pattern).is_absolute() {
pattern.clone()
} else {
base_dir.join(pattern).to_string_lossy().into_owned()
};
let entries = glob::glob(&full_pattern).map_err(|e| {
crate::error::OrchestratorError::InvalidIncludeGlob {
pattern: pattern.clone(),
reason: e.to_string(),
}
})?;
let mut matched: Vec<std::path::PathBuf> = entries
.filter_map(|r| r.ok())
.filter(|p| p != path)
.collect();
matched.sort();
for include_path in matched {
let include_content = std::fs::read_to_string(&include_path)?;
let fragment: IncludeFragment = toml::from_str(&include_content).map_err(|e| {
crate::error::OrchestratorError::Config(format!(
"failed to parse include file '{}': {e}",
include_path.display()
))
})?;
if let Some(dispatch) = fragment.pr_dispatch.as_ref() {
if fragment.projects.is_empty() {
tracing::warn!(
include_path = %include_path.display(),
"pr_dispatch block in include without projects; ignored"
);
} else {
for project in &fragment.projects {
config
.pr_dispatch_per_project
.insert(project.id.clone(), dispatch.clone());
}
}
}
config.projects.extend(fragment.projects);
config.agents.extend(fragment.agents);
config.flows.extend(fragment.flows);
}
}
config.include = patterns;
Ok(config)
}
pub fn load_and_validate(
path: impl AsRef<std::path::Path>,
) -> Result<Self, crate::error::OrchestratorError> {
let mut cfg = Self::from_file(path)?;
cfg.substitute_env_vars();
cfg.validate()?;
Ok(cfg)
}
pub fn substitute_env_vars(&mut self) {
if let Some(ref mut workflow) = self.workflow {
workflow.tracker.api_key = substitute_env(&workflow.tracker.api_key);
}
}
pub fn validate(&self) -> Result<(), crate::error::OrchestratorError> {
if let Some(ref workflow) = self.workflow {
if workflow.enabled {
if workflow.tracker.api_key.is_empty() {
return Err(crate::error::OrchestratorError::Config(
"workflow.tracker.api_key is required when workflow is enabled".into(),
));
}
if workflow.tracker.endpoint.is_empty() {
return Err(crate::error::OrchestratorError::Config(
"workflow.tracker.endpoint is required when workflow is enabled".into(),
));
}
}
}
for agent in &self.agents {
if let Some(PreCheckStrategy::GiteaIssue { .. }) = &agent.pre_check {
if self.workflow.is_none() {
return Err(crate::error::OrchestratorError::PreCheckConfig {
agent: agent.name.clone(),
reason: "gitea-issue strategy requires [workflow] config section".into(),
});
}
}
}
let mut seen_ids: std::collections::HashSet<&str> = std::collections::HashSet::new();
for project in &self.projects {
if !seen_ids.insert(project.id.as_str()) {
return Err(crate::error::OrchestratorError::DuplicateProjectId(
project.id.clone(),
));
}
}
let multi_project = !self.projects.is_empty();
for agent in &self.agents {
match (&agent.project, multi_project) {
(Some(pid), _) => {
if !seen_ids.contains(pid.as_str()) {
return Err(crate::error::OrchestratorError::UnknownAgentProject {
agent: agent.name.clone(),
project: pid.clone(),
});
}
}
(None, true) => {
return Err(crate::error::OrchestratorError::MixedProjectMode {
kind: "agent",
name: agent.name.clone(),
});
}
(None, false) => {}
}
}
for flow in &self.flows {
if !multi_project {
return Err(crate::error::OrchestratorError::MixedProjectMode {
kind: "flow",
name: flow.name.clone(),
});
}
if !seen_ids.contains(flow.project.as_str()) {
return Err(crate::error::OrchestratorError::UnknownFlowProject {
flow: flow.name.clone(),
project: flow.project.clone(),
});
}
}
for agent in &self.agents {
if let Some(model) = &agent.model {
validate_model_provider(&agent.name, "model", model)?;
}
if let Some(model) = &agent.fallback_model {
validate_model_provider(&agent.name, "fallback_model", model)?;
}
}
for agent in &self.agents {
if let Some(grace) = agent.grace_period_secs {
if !(GRACE_PERIOD_MIN_SECS..=GRACE_PERIOD_MAX_SECS).contains(&grace) {
return Err(crate::error::OrchestratorError::AgentFieldOutOfRange {
agent: agent.name.clone(),
field: "grace_period_secs".into(),
value: grace,
min: GRACE_PERIOD_MIN_SECS,
max: GRACE_PERIOD_MAX_SECS,
});
}
}
}
for agent in &self.agents {
if let Some(cpu) = agent.max_cpu_seconds {
if !(MAX_CPU_MIN_SECS..=MAX_CPU_MAX_SECS).contains(&cpu) {
return Err(crate::error::OrchestratorError::AgentFieldOutOfRange {
agent: agent.name.clone(),
field: "max_cpu_seconds".into(),
value: cpu,
min: MAX_CPU_MIN_SECS,
max: MAX_CPU_MAX_SECS,
});
}
}
}
if let Some(ref routing) = self.routing {
if routing.probe_ttl_secs < PROBE_TTL_MIN_SECS {
return Err(crate::error::OrchestratorError::ProbeTtlTooShort {
value: routing.probe_ttl_secs,
min: PROBE_TTL_MIN_SECS,
});
}
}
Ok(())
}
}
fn substitute_env(s: &str) -> String {
let mut result = s.to_string();
while let Some(start) = result.find("${") {
if let Some(end) = result[start..].find('}') {
let var_name = &result[start + 2..start + end];
let var_value = std::env::var(var_name).unwrap_or_default();
result.replace_range(start..start + end + 1, &var_value);
} else {
break;
}
}
result
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn gitea_skill_repo_token_redacted_in_debug() {
let cfg = GiteaSkillRepoConfig {
url: "https://git.example".to_string(),
owner: "acme".to_string(),
repo: "skills".to_string(),
git_ref: "main".to_string(),
cache_dir: PathBuf::from("/tmp/skills"),
token: Some("super-secret-do-not-leak".to_string()),
fetch_timeout_secs: 30,
skills: vec![],
};
let dbg = format!("{:?}", cfg);
assert!(
!dbg.contains("super-secret-do-not-leak"),
"token must be redacted in Debug output"
);
assert!(
dbg.contains("***REDACTED***"),
"Debug output should mark token as redacted"
);
}
#[test]
fn gitea_skill_repo_default_cache_dir_non_empty() {
let dir = default_cache_dir();
assert!(!dir.as_os_str().is_empty());
assert!(dir.ends_with("terraphim/skills"));
}
#[test]
fn test_config_parse_minimal() {
let toml_str = r#"
working_dir = "/tmp/terraphim"
[nightwatch]
[compound_review]
schedule = "0 2 * * *"
repo_path = "/tmp/repo"
[[agents]]
name = "test-agent"
layer = "Safety"
cli_tool = "codex"
task = "Run tests"
"#;
let config = OrchestratorConfig::from_toml(toml_str).unwrap();
assert_eq!(config.agents.len(), 1);
assert_eq!(config.agents[0].name, "test-agent");
assert_eq!(config.agents[0].layer, AgentLayer::Safety);
assert!(config.agents[0].schedule.is_none());
assert!(config.agents[0].capabilities.is_empty());
}
#[test]
fn test_config_parse_full() {
let toml_str = r#"
working_dir = "/Users/alex/projects/terraphim/terraphim-ai"
[nightwatch]
eval_interval_secs = 300
minor_threshold = 0.10
moderate_threshold = 0.20
severe_threshold = 0.40
critical_threshold = 0.70
[compound_review]
schedule = "0 2 * * *"
max_duration_secs = 1800
repo_path = "/Users/alex/projects/terraphim/terraphim-ai"
create_prs = false
[[agents]]
name = "security-sentinel"
layer = "Safety"
cli_tool = "codex"
task = "Scan for CVEs"
capabilities = ["security", "vulnerability-scanning"]
max_memory_bytes = 2147483648
[[agents]]
name = "upstream-synchronizer"
layer = "Core"
cli_tool = "codex"
task = "Sync upstream"
schedule = "0 3 * * *"
capabilities = ["sync"]
[[agents]]
name = "code-reviewer"
layer = "Growth"
cli_tool = "claude"
task = "Review PRs"
capabilities = ["code-review", "architecture"]
"#;
let config = OrchestratorConfig::from_toml(toml_str).unwrap();
assert_eq!(config.agents.len(), 3);
assert_eq!(config.agents[0].name, "security-sentinel");
assert_eq!(config.agents[0].layer, AgentLayer::Safety);
assert!(config.agents[0].schedule.is_none());
assert_eq!(config.agents[0].max_memory_bytes, Some(2_147_483_648));
assert_eq!(config.agents[1].name, "upstream-synchronizer");
assert_eq!(config.agents[1].layer, AgentLayer::Core);
assert_eq!(config.agents[1].schedule.as_deref(), Some("0 3 * * *"));
assert_eq!(config.agents[2].name, "code-reviewer");
assert_eq!(config.agents[2].layer, AgentLayer::Growth);
assert!(config.agents[2].schedule.is_none());
assert_eq!(
config.agents[2].capabilities,
vec!["code-review", "architecture"]
);
assert_eq!(config.nightwatch.eval_interval_secs, 300);
assert!((config.nightwatch.minor_threshold - 0.10).abs() < f64::EPSILON);
assert!((config.nightwatch.critical_threshold - 0.70).abs() < f64::EPSILON);
assert_eq!(config.compound_review.schedule, "0 2 * * *");
assert_eq!(config.compound_review.max_duration_secs, 1800);
assert!(!config.compound_review.create_prs);
}
#[test]
fn test_config_defaults() {
let toml_str = r#"
working_dir = "/tmp"
[nightwatch]
[compound_review]
schedule = "0 0 * * *"
repo_path = "/tmp"
[[agents]]
name = "a"
layer = "Safety"
cli_tool = "codex"
task = "t"
"#;
let config = OrchestratorConfig::from_toml(toml_str).unwrap();
assert_eq!(config.nightwatch.eval_interval_secs, 300);
assert!((config.nightwatch.minor_threshold - 0.10).abs() < f64::EPSILON);
assert!((config.nightwatch.moderate_threshold - 0.20).abs() < f64::EPSILON);
assert!((config.nightwatch.severe_threshold - 0.40).abs() < f64::EPSILON);
assert!((config.nightwatch.critical_threshold - 0.70).abs() < f64::EPSILON);
assert_eq!(config.compound_review.max_duration_secs, 1800);
assert!(!config.compound_review.create_prs);
assert!(config.agents[0].capabilities.is_empty());
assert!(config.agents[0].max_memory_bytes.is_none());
assert!(config.agents[0].schedule.is_none());
}
#[test]
fn test_config_restart_defaults() {
let toml_str = r#"
working_dir = "/tmp"
[nightwatch]
[compound_review]
schedule = "0 0 * * *"
repo_path = "/tmp"
[[agents]]
name = "a"
layer = "Safety"
cli_tool = "echo"
task = "t"
"#;
let config = OrchestratorConfig::from_toml(toml_str).unwrap();
assert_eq!(config.restart_cooldown_secs, 60);
assert_eq!(config.max_restart_count, 10);
assert_eq!(config.restart_budget_window_secs, 43_200);
assert_eq!(config.tick_interval_secs, 30);
}
#[test]
fn test_config_restart_custom() {
let toml_str = r#"
working_dir = "/tmp"
restart_cooldown_secs = 120
max_restart_count = 5
restart_budget_window_secs = 3600
tick_interval_secs = 15
[nightwatch]
[compound_review]
schedule = "0 0 * * *"
repo_path = "/tmp"
[[agents]]
name = "a"
layer = "Safety"
cli_tool = "echo"
task = "t"
"#;
let config = OrchestratorConfig::from_toml(toml_str).unwrap();
assert_eq!(config.restart_cooldown_secs, 120);
assert_eq!(config.max_restart_count, 5);
assert_eq!(config.restart_budget_window_secs, 3600);
assert_eq!(config.tick_interval_secs, 15);
}
#[test]
fn test_example_config_parses() {
let example_path =
std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("orchestrator.example.toml");
let config = OrchestratorConfig::from_file(&example_path).unwrap();
assert_eq!(config.agents.len(), 18);
assert_eq!(config.agents[0].layer, AgentLayer::Safety);
assert_eq!(config.agents[1].layer, AgentLayer::Safety);
assert_eq!(config.agents[2].layer, AgentLayer::Core);
assert!(config.agents[2].schedule.is_some());
}
#[test]
fn test_config_backward_compatible_without_workflow() {
let toml_str = r#"
working_dir = "/tmp"
[nightwatch]
[compound_review]
schedule = "0 0 * * *"
repo_path = "/tmp"
[[agents]]
name = "a"
layer = "Safety"
cli_tool = "echo"
task = "t"
"#;
let config = OrchestratorConfig::from_toml(toml_str).unwrap();
assert!(config.workflow.is_none());
}
#[test]
fn test_config_with_workflow_section() {
let toml_str = r#"
working_dir = "/tmp"
[nightwatch]
[compound_review]
schedule = "0 0 * * *"
repo_path = "/tmp"
[workflow]
enabled = true
poll_interval_secs = 120
workflow_file = "./WORKFLOW.md"
[workflow.tracker]
kind = "gitea"
endpoint = "https://git.terraphim.cloud"
api_key = "..."
owner = "terraphim"
repo = "terraphim-ai"
use_robot_api = true
[workflow.tracker.states]
active = ["Todo", "In Progress"]
terminal = ["Done", "Closed"]
[workflow.concurrency]
global_max = 5
issue_max = 3
fairness = "round_robin"
[[agents]]
name = "a"
layer = "Safety"
cli_tool = "echo"
task = "t"
"#;
let config = OrchestratorConfig::from_toml(toml_str).unwrap();
let workflow = config.workflow.expect("workflow config should exist");
assert!(workflow.enabled);
assert_eq!(workflow.poll_interval_secs, 120);
assert_eq!(
workflow.workflow_file,
std::path::PathBuf::from("./WORKFLOW.md")
);
assert_eq!(workflow.tracker.kind, "gitea");
assert_eq!(workflow.tracker.endpoint, "https://git.terraphim.cloud");
assert_eq!(workflow.tracker.owner, "terraphim");
assert!(workflow.tracker.use_robot_api);
assert_eq!(workflow.tracker.states.active.len(), 2);
assert_eq!(workflow.tracker.states.terminal.len(), 2);
assert_eq!(workflow.concurrency.global_max, 5);
assert_eq!(workflow.concurrency.issue_max, 3);
assert_eq!(workflow.concurrency.fairness, "round_robin");
}
#[test]
fn test_workflow_defaults() {
let toml_str = r#"
working_dir = "/tmp"
[nightwatch]
[compound_review]
schedule = "0 0 * * *"
repo_path = "/tmp"
[workflow]
enabled = true
workflow_file = "./WORKFLOW.md"
[workflow.tracker]
kind = "gitea"
endpoint = "https://git.example.com"
api_key = "..."
owner = "owner"
repo = "repo"
[[agents]]
name = "a"
layer = "Safety"
cli_tool = "echo"
task = "t"
"#;
let config = OrchestratorConfig::from_toml(toml_str).unwrap();
let workflow = config.workflow.expect("workflow config should exist");
assert_eq!(workflow.poll_interval_secs, 120);
assert!(!workflow.tracker.use_robot_api);
assert_eq!(workflow.concurrency.global_max, 5);
assert_eq!(workflow.concurrency.issue_max, 3);
assert_eq!(workflow.concurrency.fairness, "round_robin");
}
#[test]
fn test_validate_workflow_missing_api_key() {
let toml_str = r#"
working_dir = "/tmp"
[nightwatch]
[compound_review]
schedule = "0 0 * * *"
repo_path = "/tmp"
[workflow]
enabled = true
workflow_file = "./WORKFLOW.md"
[workflow.tracker]
kind = "gitea"
endpoint = "https://git.example.com"
api_key = ""
owner = "owner"
repo = "repo"
[[agents]]
name = "a"
layer = "Safety"
cli_tool = "echo"
task = "t"
"#;
let config = OrchestratorConfig::from_toml(toml_str).unwrap();
assert!(config.validate().is_err());
}
#[test]
fn test_config_parse_with_budget() {
let toml_str = r#"
working_dir = "/tmp"
[nightwatch]
[compound_review]
schedule = "0 0 * * *"
repo_path = "/tmp"
[[agents]]
name = "a"
layer = "Safety"
cli_tool = "echo"
task = "t"
budget_monthly_cents = 5000
"#;
let config = OrchestratorConfig::from_toml(toml_str).unwrap();
assert_eq!(config.agents.len(), 1);
assert_eq!(config.agents[0].budget_monthly_cents, Some(5000));
}
#[test]
fn test_config_parse_without_budget() {
let toml_str = r#"
working_dir = "/tmp"
[nightwatch]
[compound_review]
schedule = "0 0 * * *"
repo_path = "/tmp"
[[agents]]
name = "a"
layer = "Safety"
cli_tool = "echo"
task = "t"
"#;
let config = OrchestratorConfig::from_toml(toml_str).unwrap();
assert_eq!(config.agents.len(), 1);
assert!(config.agents[0].budget_monthly_cents.is_none());
}
#[test]
fn test_config_parse_with_persona_fields() {
let toml_str = r#"
working_dir = "/tmp"
persona_data_dir = "/tmp/personas"
[nightwatch]
[compound_review]
schedule = "0 0 * * *"
repo_path = "/tmp"
[[agents]]
name = "test-agent"
layer = "Safety"
cli_tool = "codex"
task = "Test task"
provider = "openai"
persona = "Security Analyst"
terraphim_role = "Terraphim Engineer"
skill_chain = ["security", "analysis"]
sfia_skills = [{code = "SCTY", level = 5}, {code = "PROG", level = 4}]
fallback_provider = "anthropic"
fallback_model = "claude-sonnet"
grace_period_secs = 30
max_cpu_seconds = 300
"#;
let config = OrchestratorConfig::from_toml(toml_str).unwrap();
assert_eq!(config.agents.len(), 1);
let agent = &config.agents[0];
assert_eq!(agent.provider, Some("openai".to_string()));
assert_eq!(agent.persona, Some("Security Analyst".to_string()));
assert_eq!(agent.terraphim_role, Some("Terraphim Engineer".to_string()));
assert_eq!(agent.skill_chain, vec!["security", "analysis"]);
assert_eq!(agent.sfia_skills.len(), 2);
assert_eq!(agent.sfia_skills[0].code, "SCTY");
assert_eq!(agent.sfia_skills[0].level, 5);
assert_eq!(agent.sfia_skills[1].code, "PROG");
assert_eq!(agent.sfia_skills[1].level, 4);
assert_eq!(agent.fallback_provider, Some("anthropic".to_string()));
assert_eq!(agent.fallback_model, Some("claude-sonnet".to_string()));
assert_eq!(agent.grace_period_secs, Some(30));
assert_eq!(agent.max_cpu_seconds, Some(300));
assert_eq!(
config.persona_data_dir,
Some(PathBuf::from("/tmp/personas"))
);
}
#[test]
fn test_config_parse_without_persona_fields() {
let toml_str = r#"
working_dir = "/tmp"
[nightwatch]
[compound_review]
schedule = "0 0 * * *"
repo_path = "/tmp"
[[agents]]
name = "test-agent"
layer = "Safety"
cli_tool = "codex"
task = "Test task"
"#;
let config = OrchestratorConfig::from_toml(toml_str).unwrap();
assert_eq!(config.agents.len(), 1);
let agent = &config.agents[0];
assert!(agent.provider.is_none());
assert!(agent.persona.is_none());
assert!(agent.terraphim_role.is_none());
assert!(agent.skill_chain.is_empty());
assert!(agent.sfia_skills.is_empty());
assert!(agent.fallback_provider.is_none());
assert!(agent.fallback_model.is_none());
assert!(agent.grace_period_secs.is_none());
assert!(agent.max_cpu_seconds.is_none());
assert!(config.persona_data_dir.is_none());
}
#[test]
fn test_config_persona_defaults() {
let toml_str = r#"
working_dir = "/tmp"
[nightwatch]
[compound_review]
schedule = "0 0 * * *"
repo_path = "/tmp"
[[agents]]
name = "a"
layer = "Safety"
cli_tool = "echo"
task = "t"
"#;
let config = OrchestratorConfig::from_toml(toml_str).unwrap();
let agent = &config.agents[0];
assert!(agent.provider.is_none());
assert!(agent.persona.is_none());
assert!(agent.terraphim_role.is_none());
assert!(agent.skill_chain.is_empty());
assert!(agent.sfia_skills.is_empty());
assert!(agent.fallback_provider.is_none());
assert!(agent.fallback_model.is_none());
assert!(agent.grace_period_secs.is_none());
assert!(agent.max_cpu_seconds.is_none());
}
#[test]
fn test_config_sfia_skills_parse() {
let toml_str = r#"
working_dir = "/tmp"
[nightwatch]
[compound_review]
schedule = "0 0 * * *"
repo_path = "/tmp"
[[agents]]
name = "a"
layer = "Safety"
cli_tool = "echo"
task = "t"
sfia_skills = [{code = "SCTY", level = 5}]
"#;
let config = OrchestratorConfig::from_toml(toml_str).unwrap();
assert_eq!(config.agents[0].sfia_skills.len(), 1);
assert_eq!(config.agents[0].sfia_skills[0].code, "SCTY");
assert_eq!(config.agents[0].sfia_skills[0].level, 5);
}
#[test]
fn test_config_skill_chain_parse() {
let toml_str = r#"
working_dir = "/tmp"
[nightwatch]
[compound_review]
schedule = "0 0 * * *"
repo_path = "/tmp"
[[agents]]
name = "a"
layer = "Safety"
cli_tool = "echo"
task = "t"
skill_chain = ["a", "b"]
"#;
let config = OrchestratorConfig::from_toml(toml_str).unwrap();
assert_eq!(config.agents[0].skill_chain, vec!["a", "b"]);
}
#[test]
fn test_config_persona_data_dir() {
let toml_str = r#"
working_dir = "/tmp"
persona_data_dir = "/tmp/personas"
[nightwatch]
[compound_review]
schedule = "0 0 * * *"
repo_path = "/tmp"
[[agents]]
name = "a"
layer = "Safety"
cli_tool = "echo"
task = "t"
"#;
let config = OrchestratorConfig::from_toml(toml_str).unwrap();
assert_eq!(
config.persona_data_dir,
Some(PathBuf::from("/tmp/personas"))
);
}
#[test]
fn test_config_persona_data_dir_default() {
let toml_str = r#"
working_dir = "/tmp"
[nightwatch]
[compound_review]
schedule = "0 0 * * *"
repo_path = "/tmp"
[[agents]]
name = "a"
layer = "Safety"
cli_tool = "echo"
task = "t"
"#;
let config = OrchestratorConfig::from_toml(toml_str).unwrap();
assert!(config.persona_data_dir.is_none());
}
#[test]
fn test_example_config_parses_with_persona() {
let example_path =
std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("orchestrator.example.toml");
if example_path.exists() {
let config = OrchestratorConfig::from_file(&example_path).unwrap();
assert!(config.agents.len() >= 3);
}
}
#[test]
fn test_config_parse_pre_check_always() {
let toml_str = r#"
working_dir = "/tmp"
[nightwatch]
[compound_review]
schedule = "0 0 * * *"
repo_path = "/tmp"
[[agents]]
name = "a"
layer = "Safety"
cli_tool = "echo"
task = "t"
[agents.pre_check]
kind = "always"
"#;
let config = OrchestratorConfig::from_toml(toml_str).unwrap();
assert_eq!(config.agents[0].pre_check, Some(PreCheckStrategy::Always));
}
#[test]
fn test_config_parse_pre_check_git_diff() {
let toml_str = r#"
working_dir = "/tmp"
[nightwatch]
[compound_review]
schedule = "0 0 * * *"
repo_path = "/tmp"
[[agents]]
name = "a"
layer = "Safety"
cli_tool = "echo"
task = "t"
[agents.pre_check]
kind = "git-diff"
watch_paths = ["crates/", "Cargo.toml"]
"#;
let config = OrchestratorConfig::from_toml(toml_str).unwrap();
match &config.agents[0].pre_check {
Some(PreCheckStrategy::GitDiff { watch_paths }) => {
assert_eq!(
watch_paths,
&vec!["crates/".to_string(), "Cargo.toml".to_string()]
);
}
other => panic!("expected GitDiff, got {:?}", other),
}
}
#[test]
fn test_config_parse_pre_check_gitea_issue() {
let toml_str = r#"
working_dir = "/tmp"
[nightwatch]
[compound_review]
schedule = "0 0 * * *"
repo_path = "/tmp"
[[agents]]
name = "a"
layer = "Safety"
cli_tool = "echo"
task = "t"
[agents.pre_check]
kind = "gitea-issue"
issue_number = 637
"#;
let config = OrchestratorConfig::from_toml(toml_str).unwrap();
match &config.agents[0].pre_check {
Some(PreCheckStrategy::GiteaIssue { issue_number }) => {
assert_eq!(*issue_number, 637);
}
other => panic!("expected GiteaIssue, got {:?}", other),
}
}
#[test]
fn test_config_parse_pre_check_shell() {
let toml_str = r#"
working_dir = "/tmp"
[nightwatch]
[compound_review]
schedule = "0 0 * * *"
repo_path = "/tmp"
[[agents]]
name = "a"
layer = "Safety"
cli_tool = "echo"
task = "t"
[agents.pre_check]
kind = "shell"
script = "echo hello"
"#;
let config = OrchestratorConfig::from_toml(toml_str).unwrap();
match &config.agents[0].pre_check {
Some(PreCheckStrategy::Shell {
script,
timeout_secs,
}) => {
assert_eq!(script, "echo hello");
assert_eq!(*timeout_secs, 60); }
other => panic!("expected Shell, got {:?}", other),
}
}
#[test]
fn test_config_parse_pre_check_shell_custom_timeout() {
let toml_str = r#"
working_dir = "/tmp"
[nightwatch]
[compound_review]
schedule = "0 0 * * *"
repo_path = "/tmp"
[[agents]]
name = "a"
layer = "Safety"
cli_tool = "echo"
task = "t"
[agents.pre_check]
kind = "shell"
script = "test -f /tmp/flag"
timeout_secs = 10
"#;
let config = OrchestratorConfig::from_toml(toml_str).unwrap();
match &config.agents[0].pre_check {
Some(PreCheckStrategy::Shell {
script,
timeout_secs,
}) => {
assert_eq!(script, "test -f /tmp/flag");
assert_eq!(*timeout_secs, 10);
}
other => panic!("expected Shell, got {:?}", other),
}
}
#[test]
fn test_config_parse_no_pre_check() {
let toml_str = r#"
working_dir = "/tmp"
[nightwatch]
[compound_review]
schedule = "0 0 * * *"
repo_path = "/tmp"
[[agents]]
name = "a"
layer = "Safety"
cli_tool = "echo"
task = "t"
"#;
let config = OrchestratorConfig::from_toml(toml_str).unwrap();
assert!(config.agents[0].pre_check.is_none());
}
#[test]
fn test_config_validate_gitea_issue_requires_workflow() {
let toml_str = r#"
working_dir = "/tmp"
[nightwatch]
[compound_review]
schedule = "0 0 * * *"
repo_path = "/tmp"
[[agents]]
name = "a"
layer = "Safety"
cli_tool = "echo"
task = "t"
[agents.pre_check]
kind = "gitea-issue"
issue_number = 42
"#;
let config = OrchestratorConfig::from_toml(toml_str).unwrap();
let result = config.validate();
assert!(result.is_err());
let err = result.unwrap_err().to_string();
assert!(
err.contains("gitea-issue"),
"error should mention gitea-issue: {}",
err
);
}
#[test]
fn test_config_validate_gitea_issue_with_workflow_ok() {
let toml_str = r#"
working_dir = "/tmp"
[nightwatch]
[compound_review]
schedule = "0 0 * * *"
repo_path = "/tmp"
[workflow]
enabled = true
workflow_file = "./WORKFLOW.md"
[workflow.tracker]
kind = "gitea"
endpoint = "https://git.example.com"
api_key = "token123"
owner = "owner"
repo = "repo"
[[agents]]
name = "a"
layer = "Safety"
cli_tool = "echo"
task = "t"
[agents.pre_check]
kind = "gitea-issue"
issue_number = 42
"#;
let config = OrchestratorConfig::from_toml(toml_str).unwrap();
assert!(config.validate().is_ok());
}
#[test]
fn test_config_with_flows() {
let toml_str = r#"
working_dir = "/tmp"
flow_state_dir = "/tmp/flow-states"
[nightwatch]
[compound_review]
schedule = "0 0 * * *"
repo_path = "/tmp"
[[agents]]
name = "a"
layer = "Safety"
cli_tool = "echo"
task = "t"
[[flows]]
name = "test-flow"
project = "default"
repo_path = "/home/user/project"
[[flows.steps]]
name = "build"
kind = "action"
command = "cargo build"
[[flows.steps]]
name = "test"
kind = "action"
command = "cargo test"
"#;
let config = OrchestratorConfig::from_toml(toml_str).unwrap();
assert_eq!(config.flows.len(), 1);
assert_eq!(config.flows[0].name, "test-flow");
assert_eq!(config.flows[0].repo_path, "/home/user/project");
assert_eq!(config.flows[0].steps.len(), 2);
assert_eq!(config.flows[0].steps[0].name, "build");
assert_eq!(
config.flows[0].steps[0].kind,
crate::flow::config::StepKind::Action
);
assert_eq!(
config.flows[0].steps[0].command,
Some("cargo build".to_string())
);
assert_eq!(config.flows[0].steps[1].name, "test");
assert_eq!(
config.flows[0].steps[1].command,
Some("cargo test".to_string())
);
assert_eq!(
config.flow_state_dir,
Some(PathBuf::from("/tmp/flow-states"))
);
}
#[test]
fn test_config_without_flows() {
let toml_str = r#"
working_dir = "/tmp"
[nightwatch]
[compound_review]
schedule = "0 0 * * *"
repo_path = "/tmp"
[[agents]]
name = "a"
layer = "Safety"
cli_tool = "echo"
task = "t"
"#;
let config = OrchestratorConfig::from_toml(toml_str).unwrap();
assert!(config.flows.is_empty());
assert!(config.flow_state_dir.is_none());
}
#[test]
fn test_is_allowed_provider_c1_allowed_prefixes() {
assert!(is_allowed_provider("claude-code/sonnet-4.5"));
assert!(is_allowed_provider("opencode-go/kimi-k2.5"));
assert!(is_allowed_provider("kimi-for-coding/k2p5"));
assert!(is_allowed_provider("minimax-coding-plan/MiniMax-M2.5"));
assert!(is_allowed_provider("zai-coding-plan/glm-4.6"));
assert!(is_allowed_provider("openai/gpt-5.4"));
assert!(is_allowed_provider("openai/gpt-5.3-codex"));
assert!(is_allowed_provider("openai/gpt-5.4"));
}
#[test]
fn test_is_allowed_provider_c3_banned_prefixes() {
assert!(!is_allowed_provider("opencode/whatever"));
assert!(!is_allowed_provider("github-copilot/gpt-4.1"));
assert!(!is_allowed_provider("google/gemini-2.5"));
assert!(!is_allowed_provider("huggingface/llama-3"));
assert!(!is_allowed_provider("minimax/MiniMax-M2.5"));
}
#[test]
fn test_is_allowed_provider_c3_prefix_boundary() {
assert!(is_allowed_provider("opencode-go/any"));
assert!(!is_allowed_provider("opencode/any"));
assert!(is_allowed_provider("minimax-coding-plan/any"));
assert!(!is_allowed_provider("minimax/any"));
}
#[test]
fn test_is_allowed_provider_bare_claude_cli() {
assert!(is_allowed_provider("sonnet"));
assert!(is_allowed_provider("opus"));
assert!(is_allowed_provider("haiku"));
assert!(is_allowed_provider("anthropic"));
}
#[test]
fn test_is_allowed_provider_bare_allowed_id() {
assert!(is_allowed_provider("claude-code"));
assert!(is_allowed_provider("opencode-go"));
assert!(is_allowed_provider("kimi-for-coding"));
}
#[test]
fn test_is_allowed_provider_anthropic_prefixed() {
assert!(is_allowed_provider("anthropic/claude-3.5-sonnet"));
assert!(is_allowed_provider("anthropic/claude-opus-4"));
}
#[test]
fn test_is_allowed_provider_rejects_unknown_bare_names() {
assert!(!is_allowed_provider("minimax"));
assert!(!is_allowed_provider("opencode"));
assert!(!is_allowed_provider("google"));
assert!(!is_allowed_provider("github-copilot"));
assert!(!is_allowed_provider("huggingface"));
assert!(!is_allowed_provider("unknown"));
assert!(!is_allowed_provider(""));
}
#[test]
fn test_validate_model_provider_rejects_bare_banned() {
let err = validate_model_provider("bare-banned", "model", "minimax")
.expect_err("bare 'minimax' must be rejected");
assert!(matches!(
err,
crate::error::OrchestratorError::BannedProvider { .. }
));
validate_model_provider("ok", "model", "sonnet").expect("sonnet is a bare claude CLI");
validate_model_provider("ok", "model", "anthropic").expect("anthropic bare passes");
}
#[test]
fn pr_dispatch_absent_block_yields_none() {
let toml_str = r#"
working_dir = "/tmp/pr-dispatch-default-test"
[nightwatch]
[compound_review]
schedule = "0 2 * * *"
repo_path = "/tmp"
"#;
let cfg = OrchestratorConfig::from_toml(toml_str).unwrap();
assert!(cfg.pr_dispatch.is_none());
}
#[test]
fn pr_dispatch_legacy_default_lists_only_pr_reviewer() {
let dft = PrDispatchConfig::legacy_default();
assert_eq!(dft.agents_on_pr_open.len(), 1);
assert_eq!(dft.agents_on_pr_open[0].name, "pr-reviewer");
assert_eq!(dft.agents_on_pr_open[0].context, "adf/pr-reviewer");
}
#[test]
fn agents_on_pr_open_for_project_falls_back_to_legacy_default() {
let toml_str = r#"
working_dir = "/tmp/pr-dispatch-accessor-test"
[nightwatch]
[compound_review]
schedule = "0 2 * * *"
repo_path = "/tmp"
"#;
let cfg = OrchestratorConfig::from_toml(toml_str).unwrap();
let list = cfg.agents_on_pr_open_for_project("any-project");
assert_eq!(list.len(), 1, "legacy default must be a single entry");
assert_eq!(list[0].name, "pr-reviewer");
assert_eq!(list[0].context, "adf/pr-reviewer");
}
#[test]
fn agents_on_pr_open_for_project_falls_back_to_top_level_block() {
let toml_str = r#"
working_dir = "/tmp/pr-dispatch-accessor-configured"
[nightwatch]
[compound_review]
schedule = "0 2 * * *"
repo_path = "/tmp"
[pr_dispatch]
agents_on_pr_open = [
{ name = "build-runner", context = "adf/build" },
{ name = "pr-reviewer", context = "adf/pr-reviewer" },
]
"#;
let cfg = OrchestratorConfig::from_toml(toml_str).unwrap();
let list = cfg.agents_on_pr_open_for_project("any-project");
assert_eq!(list.len(), 2);
assert_eq!(list[0].name, "build-runner");
assert_eq!(list[1].name, "pr-reviewer");
}
#[test]
fn agents_on_pr_open_for_project_returns_per_project_block() {
let mut cfg = OrchestratorConfig::from_toml(
r#"
working_dir = "/tmp/per-project"
[nightwatch]
[compound_review]
schedule = "0 2 * * *"
repo_path = "/tmp"
[pr_dispatch]
agents_on_pr_open = [
{ name = "pr-reviewer", context = "adf/pr-reviewer" },
]
"#,
)
.unwrap();
cfg.pr_dispatch_per_project.insert(
"alpha".to_string(),
PrDispatchConfig {
agents_on_pr_open: vec![
PrDispatchEntry {
name: "build-runner".to_string(),
context: "adf/build".to_string(),
},
PrDispatchEntry {
name: "pr-reviewer".to_string(),
context: "adf/pr-reviewer".to_string(),
},
],
},
);
let alpha = cfg.agents_on_pr_open_for_project("alpha");
assert_eq!(
alpha.len(),
2,
"per-project block must override the top-level fallback for alpha"
);
assert_eq!(alpha[0].name, "build-runner");
let other = cfg.agents_on_pr_open_for_project("beta");
assert_eq!(
other.len(),
1,
"beta has no per-project block so the top-level fallback applies"
);
assert_eq!(other[0].name, "pr-reviewer");
}
#[test]
fn pr_dispatch_block_parses_two_entry_list() {
let toml_str = r#"
working_dir = "/tmp/pr-dispatch-parse-test"
[nightwatch]
[compound_review]
schedule = "0 2 * * *"
repo_path = "/tmp"
[pr_dispatch]
agents_on_pr_open = [
{ name = "build-runner", context = "adf/build" },
{ name = "pr-reviewer", context = "adf/pr-reviewer" },
]
"#;
let cfg = OrchestratorConfig::from_toml(toml_str).unwrap();
let dispatch = cfg
.pr_dispatch
.as_ref()
.expect("pr_dispatch block must deserialise when present");
assert_eq!(dispatch.agents_on_pr_open.len(), 2);
assert_eq!(dispatch.agents_on_pr_open[0].name, "build-runner");
assert_eq!(dispatch.agents_on_pr_open[0].context, "adf/build");
assert_eq!(dispatch.agents_on_pr_open[1].name, "pr-reviewer");
assert_eq!(dispatch.agents_on_pr_open[1].context, "adf/pr-reviewer");
}
#[test]
fn include_fragment_parses_pr_dispatch_block() {
let toml_str = r#"
[[projects]]
id = "terraphim"
working_dir = "/tmp/terraphim"
[[agents]]
name = "pr-reviewer"
layer = "Safety"
cli_tool = "echo"
task = "review"
project = "terraphim"
[pr_dispatch]
agents_on_pr_open = [
{ name = "build-runner", context = "adf/build" },
{ name = "pr-reviewer", context = "adf/pr-reviewer" },
]
"#;
let fragment: IncludeFragment =
toml::from_str(toml_str).expect("IncludeFragment must accept pr_dispatch block");
assert_eq!(fragment.projects.len(), 1);
assert_eq!(fragment.projects[0].id, "terraphim");
assert_eq!(fragment.agents.len(), 1);
let dispatch = fragment
.pr_dispatch
.expect("pr_dispatch block must deserialise inside an include");
assert_eq!(dispatch.agents_on_pr_open.len(), 2);
assert_eq!(dispatch.agents_on_pr_open[0].name, "build-runner");
assert_eq!(dispatch.agents_on_pr_open[0].context, "adf/build");
assert_eq!(dispatch.agents_on_pr_open[1].name, "pr-reviewer");
assert_eq!(dispatch.agents_on_pr_open[1].context, "adf/pr-reviewer");
}
#[test]
fn from_file_aggregates_pr_dispatch_from_includes() {
use tempfile::TempDir;
let tmp = TempDir::new().unwrap();
let conf_d = tmp.path().join("conf.d");
std::fs::create_dir(&conf_d).unwrap();
std::fs::write(
conf_d.join("alpha.toml"),
r#"
[[projects]]
id = "alpha"
working_dir = "/tmp/alpha"
[pr_dispatch]
agents_on_pr_open = [
{ name = "build-runner", context = "adf/build" },
{ name = "pr-reviewer", context = "adf/pr-reviewer" },
]
"#,
)
.unwrap();
std::fs::write(
conf_d.join("beta.toml"),
r#"
[[projects]]
id = "beta"
working_dir = "/tmp/beta"
[pr_dispatch]
agents_on_pr_open = [
{ name = "pr-reviewer", context = "adf/pr-reviewer" },
]
"#,
)
.unwrap();
let base_path = tmp.path().join("orchestrator.toml");
std::fs::write(
&base_path,
r#"
working_dir = "/tmp/o"
include = ["conf.d/*.toml"]
[nightwatch]
[compound_review]
schedule = "0 0 * * *"
repo_path = "/tmp/o"
"#,
)
.unwrap();
let cfg = OrchestratorConfig::from_file(&base_path).unwrap();
assert_eq!(
cfg.projects.len(),
2,
"include expansion must merge both project blocks; got: {:?}",
cfg.projects.iter().map(|p| &p.id).collect::<Vec<_>>()
);
assert_eq!(
cfg.pr_dispatch_per_project.len(),
2,
"both per-project blocks must be aggregated; map keys: {:?}",
cfg.pr_dispatch_per_project.keys().collect::<Vec<_>>()
);
let alpha = cfg
.pr_dispatch_per_project
.get("alpha")
.expect("alpha block must be present");
assert_eq!(alpha.agents_on_pr_open.len(), 2);
assert_eq!(alpha.agents_on_pr_open[0].name, "build-runner");
let beta = cfg
.pr_dispatch_per_project
.get("beta")
.expect("beta block must be present");
assert_eq!(beta.agents_on_pr_open.len(), 1);
assert_eq!(beta.agents_on_pr_open[0].name, "pr-reviewer");
}
#[test]
fn from_file_warns_when_pr_dispatch_in_include_has_no_projects() {
use tempfile::TempDir;
let tmp = TempDir::new().unwrap();
let conf_d = tmp.path().join("conf.d");
std::fs::create_dir(&conf_d).unwrap();
std::fs::write(
conf_d.join("orphan.toml"),
r#"
[pr_dispatch]
agents_on_pr_open = [
{ name = "pr-reviewer", context = "adf/pr-reviewer" },
]
"#,
)
.unwrap();
let base_path = tmp.path().join("orchestrator.toml");
std::fs::write(
&base_path,
r#"
working_dir = "/tmp/o"
include = ["conf.d/*.toml"]
[nightwatch]
[compound_review]
schedule = "0 0 * * *"
repo_path = "/tmp/o"
"#,
)
.unwrap();
let cfg = OrchestratorConfig::from_file(&base_path).unwrap();
assert!(
cfg.pr_dispatch_per_project.is_empty(),
"orphan pr_dispatch (no projects) must not pollute the map"
);
}
#[test]
fn test_agent_definition_event_only_default_false() {
let toml_str = r#"
working_dir = "/tmp/terraphim"
[nightwatch]
[compound_review]
schedule = "0 2 * * *"
repo_path = "/tmp/repo"
[[agents]]
name = "llm-agent"
layer = "Growth"
cli_tool = "codex"
task = "Do something"
"#;
let config = OrchestratorConfig::from_toml(toml_str).unwrap();
assert_eq!(config.agents.len(), 1);
assert!(
!config.agents[0].event_only,
"event_only must default to false when not specified in TOML"
);
}
#[test]
fn test_agent_definition_event_only_true_round_trip() {
let toml_str = r#"
working_dir = "/tmp/terraphim"
[nightwatch]
[compound_review]
schedule = "0 2 * * *"
repo_path = "/tmp/repo"
[[agents]]
name = "build-runner"
layer = "Core"
cli_tool = "/bin/bash"
event_only = true
task = "build"
"#;
let config = OrchestratorConfig::from_toml(toml_str).unwrap();
assert_eq!(config.agents.len(), 1);
assert!(
config.agents[0].event_only,
"event_only must survive TOML round-trip when set to true"
);
}
#[cfg(unix)]
mod permission_tests {
use std::os::unix::fs::PermissionsExt;
fn temp_file_with_mode(content: &str, mode: u32) -> tempfile::TempDir {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("test_config.toml");
std::fs::write(&path, content).unwrap();
let mut perms = std::fs::metadata(&path).unwrap().permissions();
perms.set_mode(mode);
std::fs::set_permissions(&path, perms).unwrap();
dir
}
#[test]
fn warn_if_world_readable_does_not_panic_on_secure_file() {
let dir = temp_file_with_mode("content", 0o600);
let path = dir.path().join("test_config.toml");
super::super::warn_if_world_readable(&path);
}
#[test]
fn warn_if_world_readable_does_not_panic_on_world_readable_file() {
let dir = temp_file_with_mode("content", 0o644);
let path = dir.path().join("test_config.toml");
super::super::warn_if_world_readable(&path);
}
#[test]
fn warn_if_world_readable_does_not_panic_on_missing_file() {
let path = std::path::Path::new("/nonexistent/path/config.toml");
super::super::warn_if_world_readable(path);
}
}
#[test]
fn test_validate_grace_period_too_low() {
let toml_str = r#"
working_dir = "/tmp"
[nightwatch]
[compound_review]
schedule = "0 0 * * *"
repo_path = "/tmp"
[[agents]]
name = "test-agent"
layer = "Safety"
cli_tool = "echo"
task = "test"
grace_period_secs = 2
"#;
let config = OrchestratorConfig::from_toml(toml_str).unwrap();
let err = config.validate().unwrap_err();
assert!(matches!(
err,
crate::error::OrchestratorError::AgentFieldOutOfRange {
ref field,
..
} if field == "grace_period_secs"
));
}
#[test]
fn test_validate_grace_period_too_high() {
let toml_str = r#"
working_dir = "/tmp"
[nightwatch]
[compound_review]
schedule = "0 0 * * *"
repo_path = "/tmp"
[[agents]]
name = "test-agent"
layer = "Safety"
cli_tool = "echo"
task = "test"
grace_period_secs = 500
"#;
let config = OrchestratorConfig::from_toml(toml_str).unwrap();
let err = config.validate().unwrap_err();
assert!(matches!(
err,
crate::error::OrchestratorError::AgentFieldOutOfRange {
ref field,
..
} if field == "grace_period_secs"
));
}
#[test]
fn test_validate_grace_period_in_range() {
let toml_str = r#"
working_dir = "/tmp"
[nightwatch]
[compound_review]
schedule = "0 0 * * *"
repo_path = "/tmp"
[[agents]]
name = "test-agent"
layer = "Safety"
cli_tool = "echo"
task = "test"
grace_period_secs = 30
"#;
let config = OrchestratorConfig::from_toml(toml_str).unwrap();
assert!(config.validate().is_ok());
}
#[test]
fn test_validate_max_cpu_too_low() {
let toml_str = r#"
working_dir = "/tmp"
[nightwatch]
[compound_review]
schedule = "0 0 * * *"
repo_path = "/tmp"
[[agents]]
name = "test-agent"
layer = "Safety"
cli_tool = "echo"
task = "test"
max_cpu_seconds = 30
"#;
let config = OrchestratorConfig::from_toml(toml_str).unwrap();
let err = config.validate().unwrap_err();
assert!(matches!(
err,
crate::error::OrchestratorError::AgentFieldOutOfRange {
ref field,
..
} if field == "max_cpu_seconds"
));
}
#[test]
fn test_validate_max_cpu_too_high() {
let toml_str = r#"
working_dir = "/tmp"
[nightwatch]
[compound_review]
schedule = "0 0 * * *"
repo_path = "/tmp"
[[agents]]
name = "test-agent"
layer = "Safety"
cli_tool = "echo"
task = "test"
max_cpu_seconds = 10000
"#;
let config = OrchestratorConfig::from_toml(toml_str).unwrap();
let err = config.validate().unwrap_err();
assert!(matches!(
err,
crate::error::OrchestratorError::AgentFieldOutOfRange {
ref field,
..
} if field == "max_cpu_seconds"
));
}
#[test]
fn test_validate_max_cpu_in_range() {
let toml_str = r#"
working_dir = "/tmp"
[nightwatch]
[compound_review]
schedule = "0 0 * * *"
repo_path = "/tmp"
[[agents]]
name = "test-agent"
layer = "Safety"
cli_tool = "echo"
task = "test"
max_cpu_seconds = 3600
"#;
let config = OrchestratorConfig::from_toml(toml_str).unwrap();
assert!(config.validate().is_ok());
}
#[test]
fn test_validate_probe_ttl_too_short() {
let toml_str = r#"
working_dir = "/tmp"
[nightwatch]
[compound_review]
schedule = "0 0 * * *"
repo_path = "/tmp"
[routing]
taxonomy_path = "/tmp/taxonomy"
probe_ttl_secs = 30
[[agents]]
name = "test-agent"
layer = "Safety"
cli_tool = "echo"
task = "test"
"#;
let config = OrchestratorConfig::from_toml(toml_str).unwrap();
let err = config.validate().unwrap_err();
assert!(matches!(
err,
crate::error::OrchestratorError::ProbeTtlTooShort { .. }
));
}
#[test]
fn test_validate_probe_ttl_in_range() {
let toml_str = r#"
working_dir = "/tmp"
[nightwatch]
[compound_review]
schedule = "0 0 * * *"
repo_path = "/tmp"
[routing]
taxonomy_path = "/tmp/taxonomy"
probe_ttl_secs = 120
[[agents]]
name = "test-agent"
layer = "Safety"
cli_tool = "echo"
task = "test"
"#;
let config = OrchestratorConfig::from_toml(toml_str).unwrap();
assert!(config.validate().is_ok());
}
#[test]
fn test_validate_no_routing_no_probe_validation() {
let toml_str = r#"
working_dir = "/tmp"
[nightwatch]
[compound_review]
schedule = "0 0 * * *"
repo_path = "/tmp"
[[agents]]
name = "test-agent"
layer = "Safety"
cli_tool = "echo"
task = "test"
"#;
let config = OrchestratorConfig::from_toml(toml_str).unwrap();
assert!(config.validate().is_ok());
}
#[test]
fn gitea_output_config_token_redacted_in_debug() {
let cfg = GiteaOutputConfig {
base_url: "https://git.example".to_string(),
token: "super-secret-gitea-token".to_string(),
owner: "acme".to_string(),
repo: "platform".to_string(),
agent_tokens_path: None,
};
let dbg = format!("{:?}", cfg);
assert!(
!dbg.contains("super-secret-gitea-token"),
"Gitea token must be redacted in Debug output, got: {dbg}"
);
assert!(
dbg.contains("***REDACTED***"),
"Debug output should mark token as redacted, got: {dbg}"
);
}
#[test]
fn webhook_config_secret_redacted_in_debug() {
let cfg = WebhookConfig {
bind: "127.0.0.1:9090".to_string(),
secret: Some("hmac-webhook-secret".to_string()),
};
let dbg = format!("{:?}", cfg);
assert!(
!dbg.contains("hmac-webhook-secret"),
"Webhook secret must be redacted in Debug output, got: {dbg}"
);
assert!(
dbg.contains("***REDACTED***"),
"Debug output should mark secret as redacted, got: {dbg}"
);
}
#[test]
fn webhook_config_none_secret_debug_shows_none() {
let cfg = WebhookConfig {
bind: "127.0.0.1:9090".to_string(),
secret: None,
};
let dbg = format!("{:?}", cfg);
assert!(
dbg.contains("None"),
"None secret should show as None in Debug output, got: {dbg}"
);
}
#[test]
fn expand_env_vars_substitutes_set_variable() {
std::env::set_var("_TEST_EXPAND_VAR_1546", "my_secret_value");
let result = expand_env_vars("secret = \"${_TEST_EXPAND_VAR_1546}\"");
assert_eq!(result, "secret = \"my_secret_value\"");
std::env::remove_var("_TEST_EXPAND_VAR_1546");
}
#[test]
fn expand_env_vars_empty_string_for_unset_variable() {
std::env::remove_var("_TEST_UNSET_VAR_1546");
let result = expand_env_vars("secret = \"${_TEST_UNSET_VAR_1546}\"");
assert_eq!(result, "secret = \"\"");
}
#[test]
fn expand_env_vars_dollar_brace_syntax_only() {
let result = expand_env_vars("secret = \"$PLAIN_VAR\"");
assert_eq!(result, "secret = \"$PLAIN_VAR\"");
}
#[test]
fn tracker_config_api_key_redacted_in_debug() {
let cfg = TrackerConfig {
kind: "gitea".to_string(),
endpoint: "https://git.example/api/v1".to_string(),
api_key: "live-gitea-api-token".to_string(),
owner: "acme".to_string(),
repo: "platform".to_string(),
project_slug: None,
use_robot_api: false,
states: TrackerStates::default(),
};
let dbg = format!("{:?}", cfg);
assert!(
!dbg.contains("live-gitea-api-token"),
"Tracker api_key must be redacted in Debug output, got: {dbg}"
);
assert!(
dbg.contains("***REDACTED***"),
"Debug output should mark api_key as redacted, got: {dbg}"
);
}
}