use serde::{Deserialize, Serialize};
use std::str::FromStr;
use super::{
CostPreference, ItemIsolationConfig, ItemSelectConfig, SafetyConfig, StepBehavior,
StepHookEngine, StepPrehookConfig, StepScope, StoreInputConfig, StoreOutputConfig,
WorkflowFinalizeConfig,
};
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct WorkflowStepConfig {
pub id: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub description: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub required_capability: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub template: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub execution_profile: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub builtin: Option<String>,
pub enabled: bool,
#[serde(default = "default_true")]
pub repeatable: bool,
#[serde(default)]
pub is_guard: bool,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub cost_preference: Option<CostPreference>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub prehook: Option<StepPrehookConfig>,
#[serde(default)]
pub tty: bool,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub outputs: Vec<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub pipe_to: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub command: Option<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub chain_steps: Vec<WorkflowStepConfig>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub scope: Option<StepScope>,
#[serde(default)]
pub behavior: StepBehavior,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub max_parallel: Option<usize>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub stagger_delay_ms: Option<u64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub timeout_secs: Option<u64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub stall_timeout_secs: Option<u64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub item_select_config: Option<ItemSelectConfig>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub store_inputs: Vec<StoreInputConfig>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub store_outputs: Vec<StoreOutputConfig>,
}
fn default_true() -> bool {
true
}
#[derive(Debug, Clone, Copy, Serialize, Deserialize, Default, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum WorkflowExecutionMode {
#[default]
StaticSegment,
DynamicDag,
}
#[derive(Debug, Clone, Copy, Serialize, Deserialize, Default, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum DagFallbackMode {
#[default]
DeterministicDag,
StaticSegment,
FailClosed,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct WorkflowExecutionConfig {
#[serde(default)]
pub mode: WorkflowExecutionMode,
#[serde(default)]
pub fallback_mode: DagFallbackMode,
#[serde(default = "default_true")]
pub persist_graph_snapshots: bool,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct WorkflowConfig {
#[serde(default)]
pub steps: Vec<WorkflowStepConfig>,
#[serde(default)]
pub execution: WorkflowExecutionConfig,
#[serde(rename = "loop", default)]
pub loop_policy: WorkflowLoopConfig,
#[serde(default)]
pub finalize: WorkflowFinalizeConfig,
#[serde(default)]
pub qa: Option<String>,
#[serde(default)]
pub fix: Option<String>,
#[serde(default)]
pub retest: Option<String>,
#[serde(default)]
pub dynamic_steps: Vec<crate::dynamic_step::DynamicStepConfig>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub adaptive: Option<crate::adaptive::AdaptivePlannerConfig>,
#[serde(default)]
pub safety: SafetyConfig,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub max_parallel: Option<usize>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub stagger_delay_ms: Option<u64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub item_isolation: Option<ItemIsolationConfig>,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
#[serde(rename_all = "snake_case")]
pub enum LoopMode {
#[default]
Once,
Fixed,
Infinite,
}
impl FromStr for LoopMode {
type Err = String;
fn from_str(value: &str) -> Result<Self, Self::Err> {
match value {
"once" => Ok(Self::Once),
"fixed" => Ok(Self::Fixed),
"infinite" => Ok(Self::Infinite),
_ => Err(format!(
"unknown loop mode: {} (expected once|fixed|infinite)",
value
)),
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct WorkflowLoopGuardConfig {
pub enabled: bool,
pub stop_when_no_unresolved: bool,
pub max_cycles: Option<u32>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub agent_template: Option<String>,
}
impl Default for WorkflowLoopGuardConfig {
fn default() -> Self {
Self {
enabled: true,
stop_when_no_unresolved: true,
max_cycles: None,
agent_template: None,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ConvergenceExprEntry {
#[serde(default)]
pub engine: StepHookEngine,
pub when: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub reason: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct WorkflowLoopConfig {
pub mode: LoopMode,
#[serde(default)]
pub guard: WorkflowLoopGuardConfig,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub convergence_expr: Option<Vec<ConvergenceExprEntry>>,
}
#[cfg(test)]
mod tests {
use super::*;
use crate::config::{ItemIsolationCleanup, ItemIsolationStrategy};
#[test]
fn test_workflow_loop_guard_default() {
let cfg = WorkflowLoopGuardConfig::default();
assert!(cfg.enabled);
assert!(cfg.stop_when_no_unresolved);
assert!(cfg.max_cycles.is_none());
assert!(cfg.agent_template.is_none());
}
#[test]
fn test_loop_mode_default() {
let mode = LoopMode::default();
assert!(matches!(mode, LoopMode::Once));
}
#[test]
fn test_loop_mode_from_str_valid() {
assert!(matches!(
LoopMode::from_str("once").expect("parse once"),
LoopMode::Once
));
assert!(matches!(
LoopMode::from_str("fixed").expect("parse fixed"),
LoopMode::Fixed
));
assert!(matches!(
LoopMode::from_str("infinite").expect("parse infinite"),
LoopMode::Infinite
));
}
#[test]
fn test_loop_mode_from_str_invalid() {
let err = LoopMode::from_str("bogus").expect_err("operation should fail");
assert!(err.contains("unknown loop mode"));
assert!(err.contains("bogus"));
}
#[test]
fn test_loop_mode_serde_round_trip() {
for mode_str in &["\"once\"", "\"fixed\"", "\"infinite\""] {
let mode: LoopMode = serde_json::from_str(mode_str).expect("deserialize loop mode");
let json = serde_json::to_string(&mode).expect("serialize loop mode");
assert_eq!(&json, mode_str);
}
}
#[test]
fn test_workflow_loop_config_default() {
let cfg = WorkflowLoopConfig::default();
assert!(matches!(cfg.mode, LoopMode::Once));
assert!(cfg.guard.enabled);
assert!(cfg.convergence_expr.is_none());
}
#[test]
fn test_convergence_expr_serde_round_trip() {
let cfg = WorkflowLoopConfig {
mode: LoopMode::Infinite,
guard: WorkflowLoopGuardConfig {
max_cycles: Some(20),
..WorkflowLoopGuardConfig::default()
},
convergence_expr: Some(vec![ConvergenceExprEntry {
engine: StepHookEngine::default(),
when: "cycle >= 2".to_string(),
reason: Some("test convergence".to_string()),
}]),
};
let json = serde_json::to_string(&cfg).expect("serialize");
let decoded: WorkflowLoopConfig = serde_json::from_str(&json).expect("deserialize");
let exprs = decoded.convergence_expr.expect("convergence_expr present");
assert_eq!(exprs.len(), 1);
assert_eq!(exprs[0].when, "cycle >= 2");
assert_eq!(exprs[0].reason.as_deref(), Some("test convergence"));
}
#[test]
fn workflow_config_item_isolation_round_trips_through_serde() {
let workflow = WorkflowConfig {
item_isolation: Some(ItemIsolationConfig {
strategy: ItemIsolationStrategy::GitWorktree,
branch_prefix: Some("evo-item".to_string()),
cleanup: ItemIsolationCleanup::AfterWorkflow,
}),
..WorkflowConfig::default()
};
let json = serde_json::to_string(&workflow).expect("serialize workflow");
let decoded: WorkflowConfig = serde_json::from_str(&json).expect("deserialize workflow");
let isolation = decoded
.item_isolation
.expect("item isolation should be preserved");
assert_eq!(isolation.strategy, ItemIsolationStrategy::GitWorktree);
assert_eq!(isolation.branch_prefix.as_deref(), Some("evo-item"));
assert_eq!(isolation.cleanup, ItemIsolationCleanup::AfterWorkflow);
}
}