Skip to main content

orchestrator_config/config/
workflow.rs

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/// Workflow step configuration.
11#[derive(Debug, Clone, Serialize, Deserialize)]
12pub struct WorkflowStepConfig {
13    /// Stable step identifier used in workflow definitions and traces.
14    pub id: String,
15    /// Human-readable description shown in generated docs or diagnostics.
16    #[serde(default, skip_serializing_if = "Option::is_none")]
17    pub description: Option<String>,
18    /// Required agent capability for agent-backed steps.
19    #[serde(default, skip_serializing_if = "Option::is_none")]
20    pub required_capability: Option<String>,
21    /// Reference to a StepTemplate resource name
22    #[serde(default, skip_serializing_if = "Option::is_none")]
23    pub template: Option<String>,
24    /// Execution profile name used to select host or sandbox behavior.
25    #[serde(default, skip_serializing_if = "Option::is_none")]
26    pub execution_profile: Option<String>,
27    /// Builtin implementation name for builtin-backed steps.
28    #[serde(default, skip_serializing_if = "Option::is_none")]
29    pub builtin: Option<String>,
30    /// Whether the step is enabled.
31    pub enabled: bool,
32    /// Whether the step should run again on subsequent loop cycles.
33    #[serde(default = "default_true")]
34    pub repeatable: bool,
35    /// Whether this step can terminate the workflow loop.
36    #[serde(default)]
37    pub is_guard: bool,
38    /// Optional cost preference hint used during agent selection.
39    #[serde(default, skip_serializing_if = "Option::is_none")]
40    pub cost_preference: Option<CostPreference>,
41    /// Conditional execution hook evaluated before the step runs.
42    #[serde(default, skip_serializing_if = "Option::is_none")]
43    pub prehook: Option<StepPrehookConfig>,
44    /// Whether command execution should request a TTY.
45    #[serde(default)]
46    pub tty: bool,
47    /// Named outputs this step produces (for pipeline variable passing)
48    #[serde(default, skip_serializing_if = "Vec::is_empty")]
49    pub outputs: Vec<String>,
50    /// Pipe this step's output to the named step as input
51    #[serde(default, skip_serializing_if = "Option::is_none")]
52    pub pipe_to: Option<String>,
53    /// Build command for builtin build/test/lint steps
54    #[serde(default, skip_serializing_if = "Option::is_none")]
55    pub command: Option<String>,
56    /// Sub-steps to execute in sequence for smoke_chain step
57    #[serde(default, skip_serializing_if = "Vec::is_empty")]
58    pub chain_steps: Vec<WorkflowStepConfig>,
59    /// Execution scope (defaults based on step id)
60    #[serde(default, skip_serializing_if = "Option::is_none")]
61    pub scope: Option<StepScope>,
62    /// Declarative step behavior (on_failure, captures, post_actions, etc.)
63    #[serde(default)]
64    pub behavior: StepBehavior,
65    /// Maximum parallel items for item-scoped steps (per-step override)
66    #[serde(default, skip_serializing_if = "Option::is_none")]
67    pub max_parallel: Option<usize>,
68    /// Stagger delay in ms between parallel agent spawns (per-step override)
69    #[serde(default, skip_serializing_if = "Option::is_none")]
70    pub stagger_delay_ms: Option<u64>,
71    /// Per-step timeout in seconds (overrides global safety.step_timeout_secs)
72    #[serde(default, skip_serializing_if = "Option::is_none")]
73    pub timeout_secs: Option<u64>,
74    /// Per-step stall auto-kill threshold in seconds (overrides global safety.stall_timeout_secs)
75    #[serde(default, skip_serializing_if = "Option::is_none")]
76    pub stall_timeout_secs: Option<u64>,
77    /// WP03: Configuration for item_select builtin step
78    #[serde(default, skip_serializing_if = "Option::is_none")]
79    pub item_select_config: Option<ItemSelectConfig>,
80    /// Store inputs: read values from workflow stores before step execution
81    #[serde(default, skip_serializing_if = "Vec::is_empty")]
82    pub store_inputs: Vec<StoreInputConfig>,
83    /// Store outputs: write pipeline vars to workflow stores after step execution
84    #[serde(default, skip_serializing_if = "Vec::is_empty")]
85    pub store_outputs: Vec<StoreOutputConfig>,
86    /// Step-scoped variable overrides applied as a temporary overlay on pipeline
87    /// variables during this step's execution. Does not modify global pipeline state.
88    #[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/// Execution mode used to schedule a workflow.
97#[derive(Debug, Clone, Copy, Serialize, Deserialize, Default, PartialEq, Eq)]
98#[serde(rename_all = "snake_case")]
99pub enum WorkflowExecutionMode {
100    /// Execute static task and item segments defined directly in YAML.
101    #[default]
102    StaticSegment,
103    /// Materialize a dynamic DAG at runtime before execution.
104    DynamicDag,
105}
106
107/// Failure handling strategy when dynamic DAG planning is unavailable.
108#[derive(Debug, Clone, Copy, Serialize, Deserialize, Default, PartialEq, Eq)]
109#[serde(rename_all = "snake_case")]
110pub enum DagFallbackMode {
111    /// Use the deterministic DAG builder.
112    #[default]
113    DeterministicDag,
114    /// Fall back to the static segment executor.
115    StaticSegment,
116    /// Treat planning failures as terminal errors.
117    FailClosed,
118}
119
120/// Workflow-level execution settings for dynamic planning persistence and fallback.
121#[derive(Debug, Clone, Serialize, Deserialize, Default)]
122pub struct WorkflowExecutionConfig {
123    /// Runtime execution mode for the workflow.
124    #[serde(default)]
125    pub mode: WorkflowExecutionMode,
126    /// Fallback strategy used when dynamic planning fails.
127    #[serde(default)]
128    pub fallback_mode: DagFallbackMode,
129    /// Whether graph runs and snapshots should be persisted.
130    #[serde(default = "default_true")]
131    pub persist_graph_snapshots: bool,
132}
133
134/// Complete workflow definition used by the scheduler.
135///
136/// # Examples
137///
138/// ```rust
139/// use orchestrator_config::config::{LoopMode, WorkflowConfig};
140///
141/// let workflow = WorkflowConfig::default();
142/// assert!(workflow.steps.is_empty());
143/// assert!(matches!(workflow.loop_policy.mode, LoopMode::Once));
144/// ```
145#[derive(Debug, Clone, Serialize, Deserialize, Default)]
146pub struct WorkflowConfig {
147    /// Ordered step list for static execution segments.
148    #[serde(default)]
149    pub steps: Vec<WorkflowStepConfig>,
150    /// Workflow-level execution mode and persistence settings.
151    #[serde(default)]
152    pub execution: WorkflowExecutionConfig,
153    /// Loop policy controlling cycle count and guard behavior.
154    #[serde(rename = "loop", default)]
155    pub loop_policy: WorkflowLoopConfig,
156    /// Finalization behavior applied after loop completion.
157    #[serde(default)]
158    pub finalize: WorkflowFinalizeConfig,
159    /// Legacy QA template identifier preserved for compatibility.
160    #[serde(default)]
161    pub qa: Option<String>,
162    /// Legacy fix template identifier preserved for compatibility.
163    #[serde(default)]
164    pub fix: Option<String>,
165    /// Legacy retest template identifier preserved for compatibility.
166    #[serde(default)]
167    pub retest: Option<String>,
168    /// Dynamically eligible steps that can be added at runtime.
169    #[serde(default)]
170    pub dynamic_steps: Vec<crate::dynamic_step::DynamicStepConfig>,
171    /// Adaptive planning configuration for agent-driven DAG generation.
172    #[serde(default, skip_serializing_if = "Option::is_none")]
173    pub adaptive: Option<crate::adaptive::AdaptivePlannerConfig>,
174    /// Safety configuration for self-bootstrap scenarios
175    #[serde(default)]
176    pub safety: SafetyConfig,
177    /// Default max parallelism for item-scoped segments (1 = sequential)
178    #[serde(default, skip_serializing_if = "Option::is_none")]
179    pub max_parallel: Option<usize>,
180    /// Default stagger delay in ms between parallel agent spawns
181    #[serde(default, skip_serializing_if = "Option::is_none")]
182    pub stagger_delay_ms: Option<u64>,
183    /// Workflow-level item isolation for item-scoped execution.
184    #[serde(default, skip_serializing_if = "Option::is_none")]
185    pub item_isolation: Option<ItemIsolationConfig>,
186}
187
188/// Loop mode used to control workflow repetition.
189#[derive(Debug, Clone, Serialize, Deserialize, Default)]
190#[serde(rename_all = "snake_case")]
191pub enum LoopMode {
192    /// Run the workflow exactly once.
193    #[default]
194    Once,
195    /// Run the workflow for a fixed number of cycles.
196    Fixed,
197    /// Continue looping until a guard or external action stops execution.
198    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/// Guard settings evaluated between workflow cycles.
218#[derive(Debug, Clone, Serialize, Deserialize)]
219pub struct WorkflowLoopGuardConfig {
220    /// Whether loop-guard evaluation is enabled.
221    pub enabled: bool,
222    /// Stop execution once no unresolved items remain.
223    pub stop_when_no_unresolved: bool,
224    /// Optional hard cap on the number of cycles.
225    pub max_cycles: Option<u32>,
226    /// Optional agent template used for guard evaluation.
227    #[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/// A single convergence expression evaluated by the loop guard.
243#[derive(Debug, Clone, Serialize, Deserialize)]
244pub struct ConvergenceExprEntry {
245    /// Expression engine (only CEL supported).
246    #[serde(default)]
247    pub engine: StepHookEngine,
248    /// CEL expression that returns bool — `true` means "converged, stop".
249    pub when: String,
250    /// Human-readable reason logged when expression triggers.
251    #[serde(default, skip_serializing_if = "Option::is_none")]
252    pub reason: Option<String>,
253}
254
255/// Loop policy combining mode and guard settings.
256#[derive(Debug, Clone, Serialize, Deserialize, Default)]
257pub struct WorkflowLoopConfig {
258    /// Loop repetition mode.
259    pub mode: LoopMode,
260    /// Guard settings evaluated after each cycle.
261    #[serde(default)]
262    pub guard: WorkflowLoopGuardConfig,
263    /// Optional CEL convergence expressions evaluated each cycle by the loop guard.
264    #[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}