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 #[serde(default, skip_serializing_if = "Option::is_none")]
89 pub step_vars: Option<std::collections::HashMap<String, String>>,
90}
91
92fn default_true() -> bool {
93 true
94}
95
96#[derive(Debug, Clone, Copy, Serialize, Deserialize, Default, PartialEq, Eq)]
98#[serde(rename_all = "snake_case")]
99pub enum WorkflowExecutionMode {
100 #[default]
102 StaticSegment,
103 DynamicDag,
105}
106
107#[derive(Debug, Clone, Copy, Serialize, Deserialize, Default, PartialEq, Eq)]
109#[serde(rename_all = "snake_case")]
110pub enum DagFallbackMode {
111 #[default]
113 DeterministicDag,
114 StaticSegment,
116 FailClosed,
118}
119
120#[derive(Debug, Clone, Serialize, Deserialize, Default)]
122pub struct WorkflowExecutionConfig {
123 #[serde(default)]
125 pub mode: WorkflowExecutionMode,
126 #[serde(default)]
128 pub fallback_mode: DagFallbackMode,
129 #[serde(default = "default_true")]
131 pub persist_graph_snapshots: bool,
132}
133
134#[derive(Debug, Clone, Serialize, Deserialize, Default)]
146pub struct WorkflowConfig {
147 #[serde(default)]
149 pub steps: Vec<WorkflowStepConfig>,
150 #[serde(default)]
152 pub execution: WorkflowExecutionConfig,
153 #[serde(rename = "loop", default)]
155 pub loop_policy: WorkflowLoopConfig,
156 #[serde(default)]
158 pub finalize: WorkflowFinalizeConfig,
159 #[serde(default)]
161 pub qa: Option<String>,
162 #[serde(default)]
164 pub fix: Option<String>,
165 #[serde(default)]
167 pub retest: Option<String>,
168 #[serde(default)]
170 pub dynamic_steps: Vec<crate::dynamic_step::DynamicStepConfig>,
171 #[serde(default, skip_serializing_if = "Option::is_none")]
173 pub adaptive: Option<crate::adaptive::AdaptivePlannerConfig>,
174 #[serde(default)]
176 pub safety: SafetyConfig,
177 #[serde(default, skip_serializing_if = "Option::is_none")]
179 pub max_parallel: Option<usize>,
180 #[serde(default, skip_serializing_if = "Option::is_none")]
182 pub stagger_delay_ms: Option<u64>,
183 #[serde(default, skip_serializing_if = "Option::is_none")]
185 pub item_isolation: Option<ItemIsolationConfig>,
186}
187
188#[derive(Debug, Clone, Serialize, Deserialize, Default)]
190#[serde(rename_all = "snake_case")]
191pub enum LoopMode {
192 #[default]
194 Once,
195 Fixed,
197 Infinite,
199}
200
201impl FromStr for LoopMode {
202 type Err = String;
203
204 fn from_str(value: &str) -> Result<Self, Self::Err> {
205 match value {
206 "once" => Ok(Self::Once),
207 "fixed" => Ok(Self::Fixed),
208 "infinite" => Ok(Self::Infinite),
209 _ => Err(format!(
210 "unknown loop mode: {} (expected once|fixed|infinite)",
211 value
212 )),
213 }
214 }
215}
216
217#[derive(Debug, Clone, Serialize, Deserialize)]
219pub struct WorkflowLoopGuardConfig {
220 pub enabled: bool,
222 pub stop_when_no_unresolved: bool,
224 pub max_cycles: Option<u32>,
226 #[serde(default, skip_serializing_if = "Option::is_none")]
228 pub agent_template: Option<String>,
229}
230
231impl Default for WorkflowLoopGuardConfig {
232 fn default() -> Self {
233 Self {
234 enabled: true,
235 stop_when_no_unresolved: true,
236 max_cycles: None,
237 agent_template: None,
238 }
239 }
240}
241
242#[derive(Debug, Clone, Serialize, Deserialize)]
244pub struct ConvergenceExprEntry {
245 #[serde(default)]
247 pub engine: StepHookEngine,
248 pub when: String,
250 #[serde(default, skip_serializing_if = "Option::is_none")]
252 pub reason: Option<String>,
253}
254
255#[derive(Debug, Clone, Serialize, Deserialize, Default)]
257pub struct WorkflowLoopConfig {
258 pub mode: LoopMode,
260 #[serde(default)]
262 pub guard: WorkflowLoopGuardConfig,
263 #[serde(default, skip_serializing_if = "Option::is_none")]
265 pub convergence_expr: Option<Vec<ConvergenceExprEntry>>,
266}
267
268#[cfg(test)]
269mod tests {
270 use super::*;
271 use crate::config::{ItemIsolationCleanup, ItemIsolationStrategy};
272
273 #[test]
274 fn test_workflow_loop_guard_default() {
275 let cfg = WorkflowLoopGuardConfig::default();
276 assert!(cfg.enabled);
277 assert!(cfg.stop_when_no_unresolved);
278 assert!(cfg.max_cycles.is_none());
279 assert!(cfg.agent_template.is_none());
280 }
281
282 #[test]
283 fn test_loop_mode_default() {
284 let mode = LoopMode::default();
285 assert!(matches!(mode, LoopMode::Once));
286 }
287
288 #[test]
289 fn test_loop_mode_from_str_valid() {
290 assert!(matches!(
291 LoopMode::from_str("once").expect("parse once"),
292 LoopMode::Once
293 ));
294 assert!(matches!(
295 LoopMode::from_str("fixed").expect("parse fixed"),
296 LoopMode::Fixed
297 ));
298 assert!(matches!(
299 LoopMode::from_str("infinite").expect("parse infinite"),
300 LoopMode::Infinite
301 ));
302 }
303
304 #[test]
305 fn test_loop_mode_from_str_invalid() {
306 let err = LoopMode::from_str("bogus").expect_err("operation should fail");
307 assert!(err.contains("unknown loop mode"));
308 assert!(err.contains("bogus"));
309 }
310
311 #[test]
312 fn test_loop_mode_serde_round_trip() {
313 for mode_str in &["\"once\"", "\"fixed\"", "\"infinite\""] {
314 let mode: LoopMode = serde_json::from_str(mode_str).expect("deserialize loop mode");
315 let json = serde_json::to_string(&mode).expect("serialize loop mode");
316 assert_eq!(&json, mode_str);
317 }
318 }
319
320 #[test]
321 fn test_workflow_loop_config_default() {
322 let cfg = WorkflowLoopConfig::default();
323 assert!(matches!(cfg.mode, LoopMode::Once));
324 assert!(cfg.guard.enabled);
325 assert!(cfg.convergence_expr.is_none());
326 }
327
328 #[test]
329 fn test_convergence_expr_serde_round_trip() {
330 let cfg = WorkflowLoopConfig {
331 mode: LoopMode::Infinite,
332 guard: WorkflowLoopGuardConfig {
333 max_cycles: Some(20),
334 ..WorkflowLoopGuardConfig::default()
335 },
336 convergence_expr: Some(vec![ConvergenceExprEntry {
337 engine: StepHookEngine::default(),
338 when: "cycle >= 2".to_string(),
339 reason: Some("test convergence".to_string()),
340 }]),
341 };
342 let json = serde_json::to_string(&cfg).expect("serialize");
343 let decoded: WorkflowLoopConfig = serde_json::from_str(&json).expect("deserialize");
344 let exprs = decoded.convergence_expr.expect("convergence_expr present");
345 assert_eq!(exprs.len(), 1);
346 assert_eq!(exprs[0].when, "cycle >= 2");
347 assert_eq!(exprs[0].reason.as_deref(), Some("test convergence"));
348 }
349
350 #[test]
351 fn workflow_config_item_isolation_round_trips_through_serde() {
352 let workflow = WorkflowConfig {
353 item_isolation: Some(ItemIsolationConfig {
354 strategy: ItemIsolationStrategy::GitWorktree,
355 branch_prefix: Some("evo-item".to_string()),
356 cleanup: ItemIsolationCleanup::AfterWorkflow,
357 }),
358 ..WorkflowConfig::default()
359 };
360
361 let json = serde_json::to_string(&workflow).expect("serialize workflow");
362 let decoded: WorkflowConfig = serde_json::from_str(&json).expect("deserialize workflow");
363 let isolation = decoded
364 .item_isolation
365 .expect("item isolation should be preserved");
366 assert_eq!(isolation.strategy, ItemIsolationStrategy::GitWorktree);
367 assert_eq!(isolation.branch_prefix.as_deref(), Some("evo-item"));
368 assert_eq!(isolation.cleanup, ItemIsolationCleanup::AfterWorkflow);
369 }
370}