use std::collections::HashMap;
use crate::error::{OrchestratorError, Result};
use crate::hooks::HooksConfig;
use crate::vcs::VcsBackend;
use serde::{Deserialize, Serialize};
use super::defaults::{self, *};
use super::expand;
fn default_suppress_repetitive_debug() -> bool {
DEFAULT_SUPPRESS_REPETITIVE_DEBUG
}
fn default_log_summary_interval_secs() -> u64 {
DEFAULT_LOG_SUMMARY_INTERVAL_SECS
}
fn default_stall_detection_enabled() -> bool {
DEFAULT_STALL_DETECTION_ENABLED
}
fn default_stall_detection_threshold() -> u32 {
DEFAULT_STALL_DETECTION_THRESHOLD
}
fn default_error_circuit_breaker_enabled() -> bool {
DEFAULT_ERROR_CIRCUIT_BREAKER_ENABLED
}
fn default_error_circuit_breaker_threshold() -> usize {
DEFAULT_ERROR_CIRCUIT_BREAKER_THRESHOLD
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct LoggingConfig {
#[serde(default = "default_suppress_repetitive_debug")]
pub suppress_repetitive_debug: bool,
#[serde(default = "default_log_summary_interval_secs")]
pub summary_interval_secs: u64,
}
impl Default for LoggingConfig {
fn default() -> Self {
Self {
suppress_repetitive_debug: DEFAULT_SUPPRESS_REPETITIVE_DEBUG,
summary_interval_secs: DEFAULT_LOG_SUMMARY_INTERVAL_SECS,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct StallDetectionConfig {
#[serde(default = "default_stall_detection_enabled")]
pub enabled: bool,
#[serde(default = "default_stall_detection_threshold")]
pub threshold: u32,
}
impl Default for StallDetectionConfig {
fn default() -> Self {
Self {
enabled: DEFAULT_STALL_DETECTION_ENABLED,
threshold: DEFAULT_STALL_DETECTION_THRESHOLD,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct ErrorCircuitBreakerConfig {
#[serde(default = "default_error_circuit_breaker_enabled")]
pub enabled: bool,
#[serde(default = "default_error_circuit_breaker_threshold")]
pub threshold: usize,
}
impl Default for ErrorCircuitBreakerConfig {
fn default() -> Self {
Self {
enabled: default_error_circuit_breaker_enabled(),
threshold: default_error_circuit_breaker_threshold(),
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct OrchestratorConfig {
#[serde(default)]
pub server: Option<ServerConfig>,
#[serde(default)]
pub apply_command: Option<String>,
#[serde(default)]
pub archive_command: Option<String>,
#[serde(default)]
pub analyze_command: Option<String>,
#[serde(default)]
pub acceptance_command: Option<String>,
#[serde(default)]
pub apply_prompt: Option<String>,
#[serde(default)]
pub acceptance_prompt: Option<String>,
#[serde(default)]
pub acceptance_prompt_mode: Option<AcceptancePromptMode>,
#[serde(default)]
pub archive_prompt: Option<String>,
#[serde(default)]
pub hooks: Option<HooksConfig>,
#[serde(default)]
pub logging: Option<LoggingConfig>,
#[serde(default)]
pub stall_detection: Option<StallDetectionConfig>,
#[serde(default)]
pub error_circuit_breaker: Option<ErrorCircuitBreakerConfig>,
#[serde(default)]
pub completion_check_delay_ms: Option<u64>,
#[serde(default)]
pub completion_check_max_retries: Option<u32>,
#[serde(default)]
pub max_iterations: Option<u32>,
#[serde(default)]
pub parallel_mode: Option<bool>,
#[serde(default)]
pub max_concurrent_workspaces: Option<usize>,
#[serde(default)]
pub workspace_base_dir: Option<String>,
#[serde(default)]
pub resolve_command: Option<String>,
#[serde(default)]
pub use_llm_analysis: Option<bool>,
#[serde(default)]
pub vcs_backend: Option<VcsBackend>,
#[serde(default)]
pub propose_command: Option<String>,
#[serde(default)]
pub worktree_command: Option<String>,
#[serde(default)]
pub command_queue_stagger_delay_ms: Option<u64>,
#[serde(default)]
pub command_queue_max_retries: Option<u32>,
#[serde(default)]
pub command_queue_retry_delay_ms: Option<u64>,
#[serde(default)]
pub command_queue_retry_patterns: Option<Vec<String>>,
#[serde(default)]
pub command_queue_retry_if_duration_under_secs: Option<u64>,
#[serde(default)]
pub acceptance_max_continues: Option<u32>,
#[serde(default)]
pub command_inactivity_timeout_secs: Option<u64>,
#[serde(default)]
pub command_inactivity_kill_grace_secs: Option<u64>,
#[serde(default)]
pub command_inactivity_timeout_max_retries: Option<u32>,
#[serde(default)]
pub stream_json_textify: Option<bool>,
#[serde(default)]
pub command_strict_process_cleanup: Option<bool>,
#[serde(default)]
pub proposal_session: Option<ProposalSessionConfig>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ProposalSessionConfig {
#[serde(default = "default_proposal_transport_command", alias = "acp_command")]
pub transport_command: String,
#[serde(default = "default_proposal_transport_args", alias = "acp_args")]
pub transport_args: Vec<String>,
#[serde(default, alias = "acp_env")]
pub transport_env: HashMap<String, String>,
#[serde(default = "default_proposal_session_inactivity_timeout_secs")]
pub session_inactivity_timeout_secs: u64,
}
fn default_proposal_transport_command() -> String {
defaults::DEFAULT_PROPOSAL_TRANSPORT_COMMAND.to_string()
}
fn default_proposal_transport_args() -> Vec<String> {
defaults::DEFAULT_PROPOSAL_TRANSPORT_ARGS
.iter()
.map(|s| s.to_string())
.collect()
}
fn default_proposal_session_inactivity_timeout_secs() -> u64 {
defaults::DEFAULT_PROPOSAL_SESSION_INACTIVITY_TIMEOUT_SECS
}
impl Default for ProposalSessionConfig {
fn default() -> Self {
Self {
transport_command: default_proposal_transport_command(),
transport_args: default_proposal_transport_args(),
transport_env: HashMap::new(),
session_inactivity_timeout_secs: default_proposal_session_inactivity_timeout_secs(),
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
#[serde(rename_all = "snake_case")]
pub enum ServerAuthMode {
#[default]
None,
BearerToken,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct ServerAuthConfig {
#[serde(default)]
pub mode: ServerAuthMode,
#[serde(default)]
pub token: Option<String>,
#[serde(default)]
pub token_env: Option<String>,
}
impl Default for ServerAuthConfig {
fn default() -> Self {
Self {
mode: ServerAuthMode::None,
token: None,
token_env: None,
}
}
}
impl ServerAuthConfig {
pub fn resolve_token(&self) -> Option<String> {
if let Some(env_var) = &self.token_env {
if let Ok(val) = std::env::var(env_var) {
if !val.is_empty() {
return Some(val);
}
}
}
self.token.clone()
}
}
fn default_server_bind() -> String {
defaults::DEFAULT_SERVER_BIND.to_string()
}
fn default_server_port() -> u16 {
defaults::DEFAULT_SERVER_PORT
}
fn default_server_max_concurrent_total() -> usize {
defaults::DEFAULT_SERVER_MAX_CONCURRENT_TOTAL
}
fn default_server_data_dir() -> std::path::PathBuf {
defaults::default_server_data_dir()
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ServerConfig {
#[serde(default = "default_server_bind")]
pub bind: String,
#[serde(default = "default_server_port")]
pub port: u16,
#[serde(default)]
pub auth: ServerAuthConfig,
#[serde(default = "default_server_max_concurrent_total")]
pub max_concurrent_total: usize,
#[serde(default = "default_server_data_dir")]
pub data_dir: std::path::PathBuf,
#[serde(default)]
pub resolve_command: Option<String>,
}
impl Default for ServerConfig {
fn default() -> Self {
Self {
bind: default_server_bind(),
port: default_server_port(),
auth: ServerAuthConfig::default(),
max_concurrent_total: default_server_max_concurrent_total(),
data_dir: default_server_data_dir(),
resolve_command: None,
}
}
}
impl ServerConfig {
pub fn is_loopback_bind(&self) -> bool {
let addr = self.bind.trim();
if addr.starts_with("127.") || addr == "localhost" {
return true;
}
if addr == "::1" || addr == "[::1]" {
return true;
}
false
}
pub fn validate(&self) -> crate::error::Result<()> {
if self.resolve_command.is_some() {
return Err(crate::error::OrchestratorError::ConfigLoad(
"Configuration error: `server.resolve_command` is no longer supported. \
Please remove it from your config and use the top-level `resolve_command` instead."
.to_string(),
));
}
if !self.is_loopback_bind() {
match self.auth.mode {
ServerAuthMode::BearerToken => {
if self
.auth
.resolve_token()
.as_deref()
.unwrap_or("")
.is_empty()
{
return Err(crate::error::OrchestratorError::ConfigLoad(
"Server: non-loopback bind requires auth.token or auth.token_env to be set when auth.mode=bearer_token".to_string(),
));
}
}
ServerAuthMode::None => {
return Err(crate::error::OrchestratorError::ConfigLoad(
"Server: non-loopback bind requires auth.mode=bearer_token with a token"
.to_string(),
));
}
}
}
Ok(())
}
pub fn apply_cli_overrides(
&mut self,
bind: Option<&str>,
port: Option<u16>,
auth_token: Option<&str>,
max_concurrent_total: Option<usize>,
data_dir: Option<&std::path::Path>,
) {
if let Some(b) = bind {
self.bind = b.to_string();
}
if let Some(p) = port {
self.port = p;
}
if let Some(token) = auth_token {
self.auth.mode = ServerAuthMode::BearerToken;
self.auth.token = Some(token.to_string());
}
if let Some(max) = max_concurrent_total {
self.max_concurrent_total = max;
}
if let Some(dir) = data_dir {
self.data_dir = dir.to_path_buf();
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
#[serde(rename_all = "snake_case")]
pub enum AcceptancePromptMode {
#[default]
Full,
ContextOnly,
}
impl OrchestratorConfig {
#[allow(dead_code)]
pub fn new() -> Self {
Self::default()
}
pub fn merge(&mut self, other: Self) {
if other.server.is_some() {
self.server = other.server;
}
if other.apply_command.is_some() {
self.apply_command = other.apply_command;
}
if other.archive_command.is_some() {
self.archive_command = other.archive_command;
}
if other.analyze_command.is_some() {
self.analyze_command = other.analyze_command;
}
if other.acceptance_command.is_some() {
self.acceptance_command = other.acceptance_command;
}
if other.resolve_command.is_some() {
self.resolve_command = other.resolve_command;
}
if other.apply_prompt.is_some() {
self.apply_prompt = other.apply_prompt;
}
if other.acceptance_prompt.is_some() {
self.acceptance_prompt = other.acceptance_prompt;
}
if other.archive_prompt.is_some() {
self.archive_prompt = other.archive_prompt;
}
if other.hooks.is_some() {
match (&mut self.hooks, other.hooks) {
(Some(self_hooks), Some(other_hooks)) => {
self_hooks.merge(other_hooks);
}
(None, Some(other_hooks)) => {
self.hooks = Some(other_hooks);
}
_ => {}
}
}
if other.logging.is_some() {
self.logging = other.logging;
}
if other.stall_detection.is_some() {
self.stall_detection = other.stall_detection;
}
if other.error_circuit_breaker.is_some() {
self.error_circuit_breaker = other.error_circuit_breaker;
}
if other.completion_check_delay_ms.is_some() {
self.completion_check_delay_ms = other.completion_check_delay_ms;
}
if other.completion_check_max_retries.is_some() {
self.completion_check_max_retries = other.completion_check_max_retries;
}
if other.max_iterations.is_some() {
self.max_iterations = other.max_iterations;
}
if other.parallel_mode.is_some() {
self.parallel_mode = other.parallel_mode;
}
if other.max_concurrent_workspaces.is_some() {
self.max_concurrent_workspaces = other.max_concurrent_workspaces;
}
if other.workspace_base_dir.is_some() {
self.workspace_base_dir = other.workspace_base_dir;
}
if other.use_llm_analysis.is_some() {
self.use_llm_analysis = other.use_llm_analysis;
}
if other.vcs_backend.is_some() {
self.vcs_backend = other.vcs_backend;
}
if other.propose_command.is_some() {
self.propose_command = other.propose_command;
}
if other.worktree_command.is_some() {
self.worktree_command = other.worktree_command;
}
if other.command_queue_stagger_delay_ms.is_some() {
self.command_queue_stagger_delay_ms = other.command_queue_stagger_delay_ms;
}
if other.command_queue_max_retries.is_some() {
self.command_queue_max_retries = other.command_queue_max_retries;
}
if other.command_queue_retry_delay_ms.is_some() {
self.command_queue_retry_delay_ms = other.command_queue_retry_delay_ms;
}
if other.command_queue_retry_patterns.is_some() {
self.command_queue_retry_patterns = other.command_queue_retry_patterns;
}
if other.command_queue_retry_if_duration_under_secs.is_some() {
self.command_queue_retry_if_duration_under_secs =
other.command_queue_retry_if_duration_under_secs;
}
if other.acceptance_max_continues.is_some() {
self.acceptance_max_continues = other.acceptance_max_continues;
}
if other.command_inactivity_timeout_secs.is_some() {
self.command_inactivity_timeout_secs = other.command_inactivity_timeout_secs;
}
if other.command_inactivity_kill_grace_secs.is_some() {
self.command_inactivity_kill_grace_secs = other.command_inactivity_kill_grace_secs;
}
if other.command_inactivity_timeout_max_retries.is_some() {
self.command_inactivity_timeout_max_retries =
other.command_inactivity_timeout_max_retries;
}
if other.stream_json_textify.is_some() {
self.stream_json_textify = other.stream_json_textify;
}
if other.command_strict_process_cleanup.is_some() {
self.command_strict_process_cleanup = other.command_strict_process_cleanup;
}
if other.acceptance_prompt_mode.is_some() {
self.acceptance_prompt_mode = other.acceptance_prompt_mode;
}
if other.proposal_session.is_some() {
self.proposal_session = other.proposal_session;
}
}
pub fn get_apply_command(&self) -> Result<&str> {
self.apply_command
.as_deref()
.ok_or_else(|| OrchestratorError::ConfigLoad("Missing required config: apply_command. Please set it in .cflx.jsonc or global config.".to_string()))
}
pub fn get_archive_command(&self) -> Result<&str> {
self.archive_command
.as_deref()
.ok_or_else(|| OrchestratorError::ConfigLoad("Missing required config: archive_command. Please set it in .cflx.jsonc or global config.".to_string()))
}
pub fn get_analyze_command(&self) -> Result<&str> {
self.analyze_command
.as_deref()
.ok_or_else(|| OrchestratorError::ConfigLoad("Missing required config: analyze_command. Please set it in .cflx.jsonc or global config.".to_string()))
}
pub fn get_apply_prompt(&self) -> &str {
self.apply_prompt.as_deref().unwrap_or(DEFAULT_APPLY_PROMPT)
}
pub fn get_archive_prompt(&self) -> &str {
self.archive_prompt
.as_deref()
.unwrap_or(DEFAULT_ARCHIVE_PROMPT)
}
pub fn get_acceptance_command(&self) -> Result<&str> {
self.acceptance_command
.as_deref()
.ok_or_else(|| OrchestratorError::ConfigLoad("Missing required config: acceptance_command. Please set it in .cflx.jsonc or global config.".to_string()))
}
pub fn get_acceptance_prompt(&self) -> &str {
self.acceptance_prompt
.as_deref()
.unwrap_or(DEFAULT_ACCEPTANCE_PROMPT)
}
pub fn get_acceptance_prompt_mode(&self) -> AcceptancePromptMode {
self.acceptance_prompt_mode.clone().unwrap_or_default()
}
pub fn get_hooks(&self) -> HooksConfig {
self.hooks.clone().unwrap_or_default()
}
pub fn get_logging(&self) -> LoggingConfig {
self.logging.clone().unwrap_or_default()
}
pub fn get_stall_detection(&self) -> StallDetectionConfig {
self.stall_detection.clone().unwrap_or_default()
}
pub fn get_error_circuit_breaker(&self) -> ErrorCircuitBreakerConfig {
self.error_circuit_breaker.clone().unwrap_or_default()
}
pub fn get_max_iterations(&self) -> u32 {
self.max_iterations.unwrap_or(DEFAULT_MAX_ITERATIONS)
}
#[allow(dead_code)]
pub fn get_parallel_mode(&self) -> bool {
self.parallel_mode.unwrap_or(false)
}
pub fn resolve_parallel_mode(&self, cli_parallel: bool, git_repo_detected: bool) -> bool {
if cli_parallel {
return true;
}
match self.parallel_mode {
Some(value) => value,
None => git_repo_detected,
}
}
pub fn get_max_concurrent_workspaces(&self) -> usize {
self.max_concurrent_workspaces
.unwrap_or(DEFAULT_MAX_CONCURRENT_WORKSPACES)
}
pub fn get_workspace_base_dir(&self) -> Option<&str> {
self.workspace_base_dir.as_deref().filter(|s| !s.is_empty())
}
pub fn get_resolve_command(&self) -> Result<&str> {
self.resolve_command
.as_deref()
.ok_or_else(|| OrchestratorError::ConfigLoad("Missing required config: resolve_command. Please set it in .cflx.jsonc or global config.".to_string()))
}
pub fn use_llm_analysis(&self) -> bool {
self.use_llm_analysis.unwrap_or(true)
}
pub fn get_vcs_backend(&self) -> VcsBackend {
self.vcs_backend.unwrap_or(VcsBackend::Auto)
}
#[allow(dead_code)]
pub fn get_propose_command(&self) -> Option<&str> {
self.propose_command.as_deref()
}
pub fn get_worktree_command(&self) -> Option<&str> {
self.worktree_command.as_deref()
}
#[allow(dead_code)]
pub fn expand_proposal(template: &str, proposal: &str) -> String {
expand::expand_proposal(template, proposal)
}
pub fn expand_worktree_command(template: &str, workspace_dir: &str, repo_root: &str) -> String {
expand::expand_worktree_command(template, workspace_dir, repo_root)
}
#[allow(dead_code)]
pub fn expand_conflict_files(template: &str, conflict_files: &str) -> String {
expand::expand_conflict_files(template, conflict_files)
}
pub fn get_acceptance_max_continues(&self) -> u32 {
self.acceptance_max_continues
.unwrap_or(defaults::DEFAULT_ACCEPTANCE_MAX_CONTINUES)
}
pub fn get_command_inactivity_timeout_secs(&self) -> u64 {
self.command_inactivity_timeout_secs
.unwrap_or(defaults::DEFAULT_COMMAND_INACTIVITY_TIMEOUT_SECS)
}
pub fn get_command_inactivity_kill_grace_secs(&self) -> u64 {
self.command_inactivity_kill_grace_secs
.unwrap_or(defaults::DEFAULT_COMMAND_INACTIVITY_KILL_GRACE_SECS)
}
pub fn get_command_inactivity_timeout_max_retries(&self) -> u32 {
self.command_inactivity_timeout_max_retries
.unwrap_or(defaults::DEFAULT_COMMAND_INACTIVITY_TIMEOUT_MAX_RETRIES)
}
pub fn get_stream_json_textify(&self) -> bool {
self.stream_json_textify
.unwrap_or(defaults::DEFAULT_STREAM_JSON_TEXTIFY)
}
pub fn get_command_strict_process_cleanup(&self) -> bool {
self.command_strict_process_cleanup
.unwrap_or(defaults::DEFAULT_COMMAND_STRICT_PROCESS_CLEANUP)
}
pub fn expand_change_id(template: &str, change_id: &str) -> String {
expand::expand_change_id(template, change_id)
}
pub fn expand_prompt(template: &str, prompt: &str) -> String {
expand::expand_prompt(template, prompt)
}
pub fn validate_required_commands(&self) -> Result<()> {
let mut missing = Vec::new();
if self.apply_command.is_none() {
missing.push("apply_command");
}
if self.archive_command.is_none() {
missing.push("archive_command");
}
if self.analyze_command.is_none() {
missing.push("analyze_command");
}
if self.acceptance_command.is_none() {
missing.push("acceptance_command");
}
if self.resolve_command.is_none() {
missing.push("resolve_command");
}
if !missing.is_empty() {
return Err(OrchestratorError::ConfigLoad(format!(
"Missing required config: {}. Please set them in .cflx.jsonc or global config.",
missing.join(", ")
)));
}
Ok(())
}
}