1use serde::{Deserialize, Serialize};
2use std::str::FromStr;
3
4use super::{
5 CostPreference, ItemIsolationConfig, ItemSelectConfig, SafetyConfig, StepBehavior,
6 StepHookEngine, StepPrehookConfig, StepScope, StoreInputConfig, StoreOutputConfig,
7 WorkflowFinalizeConfig,
8};
9
10#[derive(Debug, Clone, Serialize, Deserialize)]
12pub struct WorkflowStepConfig {
13 pub id: String,
15 #[serde(default, skip_serializing_if = "Option::is_none")]
17 pub description: Option<String>,
18 #[serde(default, skip_serializing_if = "Option::is_none")]
20 pub required_capability: Option<String>,
21 #[serde(default, skip_serializing_if = "Option::is_none")]
23 pub template: Option<String>,
24 #[serde(default, skip_serializing_if = "Option::is_none")]
26 pub execution_profile: Option<String>,
27 #[serde(default, skip_serializing_if = "Option::is_none")]
29 pub builtin: Option<String>,
30 pub enabled: bool,
32 #[serde(default = "default_true")]
34 pub repeatable: bool,
35 #[serde(default)]
37 pub is_guard: bool,
38 #[serde(default, skip_serializing_if = "Option::is_none")]
40 pub cost_preference: Option<CostPreference>,
41 #[serde(default, skip_serializing_if = "Option::is_none")]
43 pub prehook: Option<StepPrehookConfig>,
44 #[serde(default)]
46 pub tty: bool,
47 #[serde(default, skip_serializing_if = "Vec::is_empty")]
49 pub outputs: Vec<String>,
50 #[serde(default, skip_serializing_if = "Option::is_none")]
52 pub pipe_to: Option<String>,
53 #[serde(default, skip_serializing_if = "Option::is_none")]
55 pub command: Option<String>,
56 #[serde(default, skip_serializing_if = "Vec::is_empty")]
58 pub chain_steps: Vec<WorkflowStepConfig>,
59 #[serde(default, skip_serializing_if = "Option::is_none")]
61 pub scope: Option<StepScope>,
62 #[serde(default)]
64 pub behavior: StepBehavior,
65 #[serde(default, skip_serializing_if = "Option::is_none")]
67 pub max_parallel: Option<usize>,
68 #[serde(default, skip_serializing_if = "Option::is_none")]
70 pub stagger_delay_ms: Option<u64>,
71 #[serde(default, skip_serializing_if = "Option::is_none")]
73 pub timeout_secs: Option<u64>,
74 #[serde(default, skip_serializing_if = "Option::is_none")]
76 pub stall_timeout_secs: Option<u64>,
77 #[serde(default, skip_serializing_if = "Option::is_none")]
79 pub item_select_config: Option<ItemSelectConfig>,
80 #[serde(default, skip_serializing_if = "Vec::is_empty")]
82 pub store_inputs: Vec<StoreInputConfig>,
83 #[serde(default, skip_serializing_if = "Vec::is_empty")]
85 pub store_outputs: Vec<StoreOutputConfig>,
86}
87
88fn default_true() -> bool {
89 true
90}
91
92#[derive(Debug, Clone, Copy, Serialize, Deserialize, Default, PartialEq, Eq)]
94#[serde(rename_all = "snake_case")]
95pub enum WorkflowExecutionMode {
96 #[default]
98 StaticSegment,
99 DynamicDag,
101}
102
103#[derive(Debug, Clone, Copy, Serialize, Deserialize, Default, PartialEq, Eq)]
105#[serde(rename_all = "snake_case")]
106pub enum DagFallbackMode {
107 #[default]
109 DeterministicDag,
110 StaticSegment,
112 FailClosed,
114}
115
116#[derive(Debug, Clone, Serialize, Deserialize, Default)]
118pub struct WorkflowExecutionConfig {
119 #[serde(default)]
121 pub mode: WorkflowExecutionMode,
122 #[serde(default)]
124 pub fallback_mode: DagFallbackMode,
125 #[serde(default = "default_true")]
127 pub persist_graph_snapshots: bool,
128}
129
130#[derive(Debug, Clone, Serialize, Deserialize, Default)]
142pub struct WorkflowConfig {
143 #[serde(default)]
145 pub steps: Vec<WorkflowStepConfig>,
146 #[serde(default)]
148 pub execution: WorkflowExecutionConfig,
149 #[serde(rename = "loop", default)]
151 pub loop_policy: WorkflowLoopConfig,
152 #[serde(default)]
154 pub finalize: WorkflowFinalizeConfig,
155 #[serde(default)]
157 pub qa: Option<String>,
158 #[serde(default)]
160 pub fix: Option<String>,
161 #[serde(default)]
163 pub retest: Option<String>,
164 #[serde(default)]
166 pub dynamic_steps: Vec<crate::dynamic_step::DynamicStepConfig>,
167 #[serde(default, skip_serializing_if = "Option::is_none")]
169 pub adaptive: Option<crate::adaptive::AdaptivePlannerConfig>,
170 #[serde(default)]
172 pub safety: SafetyConfig,
173 #[serde(default, skip_serializing_if = "Option::is_none")]
175 pub max_parallel: Option<usize>,
176 #[serde(default, skip_serializing_if = "Option::is_none")]
178 pub stagger_delay_ms: Option<u64>,
179 #[serde(default, skip_serializing_if = "Option::is_none")]
181 pub item_isolation: Option<ItemIsolationConfig>,
182}
183
184#[derive(Debug, Clone, Serialize, Deserialize, Default)]
186#[serde(rename_all = "snake_case")]
187pub enum LoopMode {
188 #[default]
190 Once,
191 Fixed,
193 Infinite,
195}
196
197impl FromStr for LoopMode {
198 type Err = String;
199
200 fn from_str(value: &str) -> Result<Self, Self::Err> {
201 match value {
202 "once" => Ok(Self::Once),
203 "fixed" => Ok(Self::Fixed),
204 "infinite" => Ok(Self::Infinite),
205 _ => Err(format!(
206 "unknown loop mode: {} (expected once|fixed|infinite)",
207 value
208 )),
209 }
210 }
211}
212
213#[derive(Debug, Clone, Serialize, Deserialize)]
215pub struct WorkflowLoopGuardConfig {
216 pub enabled: bool,
218 pub stop_when_no_unresolved: bool,
220 pub max_cycles: Option<u32>,
222 #[serde(default, skip_serializing_if = "Option::is_none")]
224 pub agent_template: Option<String>,
225}
226
227impl Default for WorkflowLoopGuardConfig {
228 fn default() -> Self {
229 Self {
230 enabled: true,
231 stop_when_no_unresolved: true,
232 max_cycles: None,
233 agent_template: None,
234 }
235 }
236}
237
238#[derive(Debug, Clone, Serialize, Deserialize)]
240pub struct ConvergenceExprEntry {
241 #[serde(default)]
243 pub engine: StepHookEngine,
244 pub when: String,
246 #[serde(default, skip_serializing_if = "Option::is_none")]
248 pub reason: Option<String>,
249}
250
251#[derive(Debug, Clone, Serialize, Deserialize, Default)]
253pub struct WorkflowLoopConfig {
254 pub mode: LoopMode,
256 #[serde(default)]
258 pub guard: WorkflowLoopGuardConfig,
259 #[serde(default, skip_serializing_if = "Option::is_none")]
261 pub convergence_expr: Option<Vec<ConvergenceExprEntry>>,
262}
263
264#[cfg(test)]
265mod tests {
266 use super::*;
267 use crate::config::{ItemIsolationCleanup, ItemIsolationStrategy};
268
269 #[test]
270 fn test_workflow_loop_guard_default() {
271 let cfg = WorkflowLoopGuardConfig::default();
272 assert!(cfg.enabled);
273 assert!(cfg.stop_when_no_unresolved);
274 assert!(cfg.max_cycles.is_none());
275 assert!(cfg.agent_template.is_none());
276 }
277
278 #[test]
279 fn test_loop_mode_default() {
280 let mode = LoopMode::default();
281 assert!(matches!(mode, LoopMode::Once));
282 }
283
284 #[test]
285 fn test_loop_mode_from_str_valid() {
286 assert!(matches!(
287 LoopMode::from_str("once").expect("parse once"),
288 LoopMode::Once
289 ));
290 assert!(matches!(
291 LoopMode::from_str("fixed").expect("parse fixed"),
292 LoopMode::Fixed
293 ));
294 assert!(matches!(
295 LoopMode::from_str("infinite").expect("parse infinite"),
296 LoopMode::Infinite
297 ));
298 }
299
300 #[test]
301 fn test_loop_mode_from_str_invalid() {
302 let err = LoopMode::from_str("bogus").expect_err("operation should fail");
303 assert!(err.contains("unknown loop mode"));
304 assert!(err.contains("bogus"));
305 }
306
307 #[test]
308 fn test_loop_mode_serde_round_trip() {
309 for mode_str in &["\"once\"", "\"fixed\"", "\"infinite\""] {
310 let mode: LoopMode = serde_json::from_str(mode_str).expect("deserialize loop mode");
311 let json = serde_json::to_string(&mode).expect("serialize loop mode");
312 assert_eq!(&json, mode_str);
313 }
314 }
315
316 #[test]
317 fn test_workflow_loop_config_default() {
318 let cfg = WorkflowLoopConfig::default();
319 assert!(matches!(cfg.mode, LoopMode::Once));
320 assert!(cfg.guard.enabled);
321 assert!(cfg.convergence_expr.is_none());
322 }
323
324 #[test]
325 fn test_convergence_expr_serde_round_trip() {
326 let cfg = WorkflowLoopConfig {
327 mode: LoopMode::Infinite,
328 guard: WorkflowLoopGuardConfig {
329 max_cycles: Some(20),
330 ..WorkflowLoopGuardConfig::default()
331 },
332 convergence_expr: Some(vec![ConvergenceExprEntry {
333 engine: StepHookEngine::default(),
334 when: "cycle >= 2".to_string(),
335 reason: Some("test convergence".to_string()),
336 }]),
337 };
338 let json = serde_json::to_string(&cfg).expect("serialize");
339 let decoded: WorkflowLoopConfig = serde_json::from_str(&json).expect("deserialize");
340 let exprs = decoded.convergence_expr.expect("convergence_expr present");
341 assert_eq!(exprs.len(), 1);
342 assert_eq!(exprs[0].when, "cycle >= 2");
343 assert_eq!(exprs[0].reason.as_deref(), Some("test convergence"));
344 }
345
346 #[test]
347 fn workflow_config_item_isolation_round_trips_through_serde() {
348 let workflow = WorkflowConfig {
349 item_isolation: Some(ItemIsolationConfig {
350 strategy: ItemIsolationStrategy::GitWorktree,
351 branch_prefix: Some("evo-item".to_string()),
352 cleanup: ItemIsolationCleanup::AfterWorkflow,
353 }),
354 ..WorkflowConfig::default()
355 };
356
357 let json = serde_json::to_string(&workflow).expect("serialize workflow");
358 let decoded: WorkflowConfig = serde_json::from_str(&json).expect("deserialize workflow");
359 let isolation = decoded
360 .item_isolation
361 .expect("item isolation should be preserved");
362 assert_eq!(isolation.strategy, ItemIsolationStrategy::GitWorktree);
363 assert_eq!(isolation.branch_prefix.as_deref(), Some("evo-item"));
364 assert_eq!(isolation.cleanup, ItemIsolationCleanup::AfterWorkflow);
365 }
366}