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
}
#[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>,
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)]
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>,
}
#[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,
}
fn default_probe_ttl() -> u64 {
300
}
fn default_true_routing() -> bool {
true
}
#[derive(Debug, 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>,
}
#[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,
}
fn default_poll_modulo() -> u64 {
2
}
fn default_max_dispatches_per_tick() -> u32 {
3
}
fn default_max_concurrent_mention_agents() -> u32 {
5
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct WebhookConfig {
#[serde(default = "default_webhook_bind")]
pub bind: String,
pub secret: Option<String>,
}
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,
}
#[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(),
}
}
}
#[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
}
#[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>,
}
#[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(Debug, 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,
}
#[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
}
impl OrchestratorConfig {
pub fn from_toml(toml_str: &str) -> Result<Self, crate::error::OrchestratorError> {
toml::from_str(toml_str).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 content = std::fs::read_to_string(path.as_ref())?;
Self::from_toml(&content)
}
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(),
});
}
}
}
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 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(), 16);
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"
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());
}
}