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}
87
88fn default_true() -> bool {
89    true
90}
91
92/// Execution mode used to schedule a workflow.
93#[derive(Debug, Clone, Copy, Serialize, Deserialize, Default, PartialEq, Eq)]
94#[serde(rename_all = "snake_case")]
95pub enum WorkflowExecutionMode {
96    /// Execute static task and item segments defined directly in YAML.
97    #[default]
98    StaticSegment,
99    /// Materialize a dynamic DAG at runtime before execution.
100    DynamicDag,
101}
102
103/// Failure handling strategy when dynamic DAG planning is unavailable.
104#[derive(Debug, Clone, Copy, Serialize, Deserialize, Default, PartialEq, Eq)]
105#[serde(rename_all = "snake_case")]
106pub enum DagFallbackMode {
107    /// Use the deterministic DAG builder.
108    #[default]
109    DeterministicDag,
110    /// Fall back to the static segment executor.
111    StaticSegment,
112    /// Treat planning failures as terminal errors.
113    FailClosed,
114}
115
116/// Workflow-level execution settings for dynamic planning persistence and fallback.
117#[derive(Debug, Clone, Serialize, Deserialize, Default)]
118pub struct WorkflowExecutionConfig {
119    /// Runtime execution mode for the workflow.
120    #[serde(default)]
121    pub mode: WorkflowExecutionMode,
122    /// Fallback strategy used when dynamic planning fails.
123    #[serde(default)]
124    pub fallback_mode: DagFallbackMode,
125    /// Whether graph runs and snapshots should be persisted.
126    #[serde(default = "default_true")]
127    pub persist_graph_snapshots: bool,
128}
129
130/// Complete workflow definition used by the scheduler.
131///
132/// # Examples
133///
134/// ```rust
135/// use orchestrator_config::config::{LoopMode, WorkflowConfig};
136///
137/// let workflow = WorkflowConfig::default();
138/// assert!(workflow.steps.is_empty());
139/// assert!(matches!(workflow.loop_policy.mode, LoopMode::Once));
140/// ```
141#[derive(Debug, Clone, Serialize, Deserialize, Default)]
142pub struct WorkflowConfig {
143    /// Ordered step list for static execution segments.
144    #[serde(default)]
145    pub steps: Vec<WorkflowStepConfig>,
146    /// Workflow-level execution mode and persistence settings.
147    #[serde(default)]
148    pub execution: WorkflowExecutionConfig,
149    /// Loop policy controlling cycle count and guard behavior.
150    #[serde(rename = "loop", default)]
151    pub loop_policy: WorkflowLoopConfig,
152    /// Finalization behavior applied after loop completion.
153    #[serde(default)]
154    pub finalize: WorkflowFinalizeConfig,
155    /// Legacy QA template identifier preserved for compatibility.
156    #[serde(default)]
157    pub qa: Option<String>,
158    /// Legacy fix template identifier preserved for compatibility.
159    #[serde(default)]
160    pub fix: Option<String>,
161    /// Legacy retest template identifier preserved for compatibility.
162    #[serde(default)]
163    pub retest: Option<String>,
164    /// Dynamically eligible steps that can be added at runtime.
165    #[serde(default)]
166    pub dynamic_steps: Vec<crate::dynamic_step::DynamicStepConfig>,
167    /// Adaptive planning configuration for agent-driven DAG generation.
168    #[serde(default, skip_serializing_if = "Option::is_none")]
169    pub adaptive: Option<crate::adaptive::AdaptivePlannerConfig>,
170    /// Safety configuration for self-bootstrap scenarios
171    #[serde(default)]
172    pub safety: SafetyConfig,
173    /// Default max parallelism for item-scoped segments (1 = sequential)
174    #[serde(default, skip_serializing_if = "Option::is_none")]
175    pub max_parallel: Option<usize>,
176    /// Default stagger delay in ms between parallel agent spawns
177    #[serde(default, skip_serializing_if = "Option::is_none")]
178    pub stagger_delay_ms: Option<u64>,
179    /// Workflow-level item isolation for item-scoped execution.
180    #[serde(default, skip_serializing_if = "Option::is_none")]
181    pub item_isolation: Option<ItemIsolationConfig>,
182}
183
184/// Loop mode used to control workflow repetition.
185#[derive(Debug, Clone, Serialize, Deserialize, Default)]
186#[serde(rename_all = "snake_case")]
187pub enum LoopMode {
188    /// Run the workflow exactly once.
189    #[default]
190    Once,
191    /// Run the workflow for a fixed number of cycles.
192    Fixed,
193    /// Continue looping until a guard or external action stops execution.
194    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/// Guard settings evaluated between workflow cycles.
214#[derive(Debug, Clone, Serialize, Deserialize)]
215pub struct WorkflowLoopGuardConfig {
216    /// Whether loop-guard evaluation is enabled.
217    pub enabled: bool,
218    /// Stop execution once no unresolved items remain.
219    pub stop_when_no_unresolved: bool,
220    /// Optional hard cap on the number of cycles.
221    pub max_cycles: Option<u32>,
222    /// Optional agent template used for guard evaluation.
223    #[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/// A single convergence expression evaluated by the loop guard.
239#[derive(Debug, Clone, Serialize, Deserialize)]
240pub struct ConvergenceExprEntry {
241    /// Expression engine (only CEL supported).
242    #[serde(default)]
243    pub engine: StepHookEngine,
244    /// CEL expression that returns bool — `true` means "converged, stop".
245    pub when: String,
246    /// Human-readable reason logged when expression triggers.
247    #[serde(default, skip_serializing_if = "Option::is_none")]
248    pub reason: Option<String>,
249}
250
251/// Loop policy combining mode and guard settings.
252#[derive(Debug, Clone, Serialize, Deserialize, Default)]
253pub struct WorkflowLoopConfig {
254    /// Loop repetition mode.
255    pub mode: LoopMode,
256    /// Guard settings evaluated after each cycle.
257    #[serde(default)]
258    pub guard: WorkflowLoopGuardConfig,
259    /// Optional CEL convergence expressions evaluated each cycle by the loop guard.
260    #[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}