Skip to main content

orchestrator_config/config/
step.rs

1use serde::{Deserialize, Serialize};
2
3use super::{GenerateItemsAction, SpawnTaskAction, SpawnTasksAction, WorkflowStepConfig};
4
5/// Execution scope for a workflow step
6#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
7#[serde(rename_all = "snake_case")]
8pub enum StepScope {
9    /// Runs once per cycle (plan, implement, self_test, align_tests, doc_governance)
10    Task,
11    /// Runs per item/QA file (qa_testing, ticket_fix)
12    #[default]
13    Item,
14}
15
16// ── Step Behavior declarations ─────────────────────────────────────
17
18/// Declarative behavior attached to each workflow step.
19#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq)]
20pub struct StepBehavior {
21    /// Action to apply when the step returns a failure status.
22    #[serde(default)]
23    pub on_failure: OnFailureAction,
24    /// Action to apply when the step succeeds.
25    #[serde(default)]
26    pub on_success: OnSuccessAction,
27    /// Variables to capture from the step result.
28    #[serde(default)]
29    pub captures: Vec<CaptureDecl>,
30    /// Follow-up actions triggered after the step completes.
31    #[serde(default)]
32    pub post_actions: Vec<PostAction>,
33    /// Explicit execution mode chosen for the step.
34    #[serde(default)]
35    pub execution: ExecutionMode,
36    /// Whether runner artifacts should be persisted for the step.
37    #[serde(default)]
38    pub collect_artifacts: bool,
39}
40
41/// What to do when a step fails.
42#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq, Eq)]
43#[serde(tag = "action", rename_all = "snake_case")]
44pub enum OnFailureAction {
45    /// Continue the workflow without changing status.
46    #[default]
47    Continue,
48    /// Overwrite the task or task-item status and continue processing.
49    SetStatus {
50        /// Status value to persist.
51        status: String,
52    },
53    /// Set a terminal status and return early from the current segment.
54    EarlyReturn {
55        /// Status value to persist before returning.
56        status: String,
57    },
58}
59
60/// What to do when a step succeeds.
61#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq, Eq)]
62#[serde(tag = "action", rename_all = "snake_case")]
63pub enum OnSuccessAction {
64    /// Continue the workflow with no extra side effects.
65    #[default]
66    Continue,
67    /// Overwrite the task or task-item status after success.
68    SetStatus {
69        /// Status value to persist.
70        status: String,
71    },
72}
73
74/// A single capture declaration: what to extract from a step result.
75#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
76pub struct CaptureDecl {
77    /// Pipeline variable to write.
78    pub var: String,
79    /// Output channel that populates the variable.
80    pub source: CaptureSource,
81    /// Optional JSON path to extract from stdout/stderr content.
82    #[serde(default, skip_serializing_if = "Option::is_none")]
83    pub json_path: Option<String>,
84}
85
86/// Source of a captured value.
87#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
88#[serde(rename_all = "snake_case")]
89pub enum CaptureSource {
90    /// Capture standard output text.
91    Stdout,
92    /// Capture standard error text.
93    Stderr,
94    /// Capture the numeric exit code.
95    ExitCode,
96    /// Capture whether the step was marked as failed.
97    FailedFlag,
98    /// Capture whether the step was marked as successful.
99    SuccessFlag,
100}
101
102/// Post-step action to run after a step completes.
103#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
104#[serde(rename_all = "snake_case", tag = "type")]
105pub enum PostAction {
106    /// Create tickets from a failing QA step.
107    CreateTicket,
108    /// Re-scan active tickets after a step completes.
109    ScanTickets,
110    /// WP02: Spawn a single child task.
111    SpawnTask(SpawnTaskAction),
112    /// WP02: Spawn multiple child tasks from a JSON array.
113    SpawnTasks(SpawnTasksAction),
114    /// WP03: Generate dynamic task items from step output.
115    GenerateItems(GenerateItemsAction),
116    /// WP01: Write a pipeline variable to a workflow store.
117    StorePut {
118        /// Workflow store resource name.
119        store: String,
120        /// Entry key to update.
121        key: String,
122        /// Pipeline variable whose value should be written.
123        from_var: String,
124    },
125}
126
127/// How a step is executed.
128#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq, Eq)]
129#[serde(rename_all = "snake_case", tag = "type")]
130pub enum ExecutionMode {
131    /// Execute the step by selecting an agent with the required capability.
132    #[default]
133    Agent,
134    /// Execute one builtin step implementation.
135    Builtin {
136        /// Builtin step name.
137        name: String,
138    },
139    /// Execute a sequence of child steps inside one chain step.
140    Chain,
141}
142
143/// Resolved semantic meaning for a workflow step after applying defaults.
144#[derive(Debug, Clone, PartialEq, Eq)]
145pub enum StepSemanticKind {
146    /// A builtin step resolved by name.
147    Builtin {
148        /// Builtin implementation name.
149        name: String,
150    },
151    /// An agent-backed step resolved by capability.
152    Agent {
153        /// Capability required from the selected agent.
154        capability: String,
155    },
156    /// A command-backed builtin step.
157    Command,
158    /// A chain step containing nested child steps.
159    Chain,
160}
161
162/// Preference used when selecting between cost, quality, and speed tradeoffs.
163#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
164#[serde(rename_all = "snake_case")]
165pub enum CostPreference {
166    /// Favor lower latency or higher throughput.
167    Performance,
168    /// Favor higher output quality even if slower.
169    Quality,
170    #[default]
171    /// Balance quality and performance heuristics.
172    Balance,
173}
174
175/// Known workflow step IDs
176const KNOWN_STEP_IDS: &[&str] = &[
177    "init_once",
178    "plan",
179    "qa",
180    "ticket_scan",
181    "fix",
182    "retest",
183    "loop_guard",
184    "build",
185    "test",
186    "lint",
187    "implement",
188    "review",
189    "git_ops",
190    "qa_doc_gen",
191    "qa_testing",
192    "ticket_fix",
193    "doc_governance",
194    "align_tests",
195    "self_test",
196    "self_restart",
197    "smoke_chain",
198    "evaluate",
199    "item_select",
200];
201
202const KNOWN_BUILTIN_STEP_NAMES: &[&str] = &[
203    "init_once",
204    "loop_guard",
205    "ticket_scan",
206    "self_test",
207    "self_restart",
208    "item_select",
209];
210
211/// Validate that a step type string is a known step ID.
212pub fn validate_step_type(value: &str) -> Result<String, String> {
213    if KNOWN_STEP_IDS.contains(&value) {
214        Ok(value.to_string())
215    } else {
216        Err(format!("unknown workflow step type: {}", value))
217    }
218}
219
220/// Returns `true` when a builtin step name is recognized by the scheduler.
221pub fn is_known_builtin_step_name(value: &str) -> bool {
222    KNOWN_BUILTIN_STEP_NAMES.contains(&value)
223}
224
225/// Returns the implicit builtin implementation for a conventional step id.
226pub fn default_builtin_for_step_id(step_id: &str) -> Option<&'static str> {
227    match step_id {
228        "init_once" => Some("init_once"),
229        "loop_guard" => Some("loop_guard"),
230        "ticket_scan" => Some("ticket_scan"),
231        "self_test" => Some("self_test"),
232        "self_restart" => Some("self_restart"),
233        "item_select" => Some("item_select"),
234        _ => None,
235    }
236}
237
238/// Returns the implicit required capability for a conventional step id.
239pub fn default_required_capability_for_step_id(step_id: &str) -> Option<&'static str> {
240    match step_id {
241        "qa" => Some("qa"),
242        "fix" => Some("fix"),
243        "retest" => Some("retest"),
244        "plan" => Some("plan"),
245        "build" => Some("build"),
246        "test" => Some("test"),
247        "lint" => Some("lint"),
248        "implement" => Some("implement"),
249        "review" => Some("review"),
250        "git_ops" => Some("git_ops"),
251        "qa_doc_gen" => Some("qa_doc_gen"),
252        "qa_testing" => Some("qa_testing"),
253        "ticket_fix" => Some("ticket_fix"),
254        "doc_governance" => Some("doc_governance"),
255        "align_tests" => Some("align_tests"),
256        "smoke_chain" => Some("smoke_chain"),
257        "evaluate" => Some("evaluate"),
258        _ => None,
259    }
260}
261
262/// Resolves the semantic step kind after applying builtin and capability defaults.
263pub fn resolve_step_semantic_kind(step: &WorkflowStepConfig) -> Result<StepSemanticKind, String> {
264    if step.builtin.is_some() && step.required_capability.is_some() {
265        return Err(format!(
266            "step '{}' cannot define both builtin and required_capability",
267            step.id
268        ));
269    }
270
271    if !step.chain_steps.is_empty() {
272        return Ok(StepSemanticKind::Chain);
273    }
274
275    if step.command.is_some() {
276        return Ok(StepSemanticKind::Command);
277    }
278
279    if let Some(ref builtin) = step.builtin {
280        if !is_known_builtin_step_name(builtin) {
281            return Err(format!(
282                "step '{}' uses unknown builtin '{}'",
283                step.id, builtin
284            ));
285        }
286        return Ok(StepSemanticKind::Builtin {
287            name: builtin.clone(),
288        });
289    }
290
291    if let Some(ref capability) = step.required_capability {
292        return Ok(StepSemanticKind::Agent {
293            capability: capability.clone(),
294        });
295    }
296
297    if let Some(builtin) = default_builtin_for_step_id(&step.id) {
298        return Ok(StepSemanticKind::Builtin {
299            name: builtin.to_string(),
300        });
301    }
302
303    if let Some(capability) = default_required_capability_for_step_id(&step.id) {
304        return Ok(StepSemanticKind::Agent {
305            capability: capability.to_string(),
306        });
307    }
308
309    Err(format!(
310        "step '{}' is missing builtin, required_capability, command, or chain_steps",
311        step.id
312    ))
313}
314
315/// Normalizes the execution mode and default selectors for one workflow step.
316pub fn normalize_step_execution_mode(step: &mut WorkflowStepConfig) -> Result<(), String> {
317    match resolve_step_semantic_kind(step)? {
318        StepSemanticKind::Builtin { name } => {
319            step.builtin = Some(name.clone());
320            step.required_capability = None;
321            step.behavior.execution = ExecutionMode::Builtin { name };
322        }
323        StepSemanticKind::Agent { capability } => {
324            step.required_capability = Some(capability);
325            step.behavior.execution = ExecutionMode::Agent;
326        }
327        StepSemanticKind::Command => {
328            step.behavior.execution = ExecutionMode::Builtin {
329                name: step.id.clone(),
330            };
331        }
332        StepSemanticKind::Chain => {
333            step.behavior.execution = ExecutionMode::Chain;
334        }
335    }
336    Ok(())
337}
338
339/// Returns true if a step ID produces structured output for pipeline variables
340pub fn has_structured_output(step_id: &str) -> bool {
341    matches!(
342        step_id,
343        "build" | "test" | "lint" | "qa_testing" | "self_test" | "smoke_chain"
344    )
345}
346
347/// Returns the default execution scope for a step ID.
348/// Task-scoped steps run once per cycle; item-scoped steps fan-out per QA file.
349pub fn default_scope_for_step_id(step_id: &str) -> StepScope {
350    match step_id {
351        // Item-scoped: fan-out per QA file
352        "qa" | "qa_testing" | "ticket_fix" | "ticket_scan" | "fix" | "retest" => StepScope::Item,
353        // Everything else defaults to task-scoped
354        _ => StepScope::Task,
355    }
356}
357
358#[cfg(test)]
359mod tests {
360    use super::*;
361
362    #[test]
363    fn capture_decl_deserializes_without_json_path() {
364        let capture: CaptureDecl = serde_yaml::from_str(
365            r#"
366var: score
367source: stdout
368"#,
369        )
370        .expect("capture should deserialize");
371
372        assert_eq!(capture.var, "score");
373        assert_eq!(capture.source, CaptureSource::Stdout);
374        assert_eq!(capture.json_path, None);
375    }
376
377    #[test]
378    fn capture_decl_deserializes_with_json_path() {
379        let capture: CaptureDecl = serde_yaml::from_str(
380            r#"
381var: score
382source: stdout
383json_path: $.total_score
384"#,
385        )
386        .expect("capture should deserialize");
387
388        assert_eq!(capture.var, "score");
389        assert_eq!(capture.source, CaptureSource::Stdout);
390        assert_eq!(capture.json_path.as_deref(), Some("$.total_score"));
391    }
392
393    #[test]
394    fn test_validate_step_type_known_ids() {
395        for id in &[
396            "init_once",
397            "plan",
398            "qa",
399            "ticket_scan",
400            "fix",
401            "retest",
402            "loop_guard",
403            "build",
404            "test",
405            "lint",
406            "implement",
407            "review",
408            "git_ops",
409            "qa_doc_gen",
410            "qa_testing",
411            "ticket_fix",
412            "doc_governance",
413            "align_tests",
414            "self_test",
415            "self_restart",
416            "smoke_chain",
417        ] {
418            assert!(validate_step_type(id).is_ok(), "expected valid for {}", id);
419        }
420    }
421
422    #[test]
423    fn test_validate_step_type_unknown_id() {
424        let result = validate_step_type("my_custom_step");
425        assert!(result.is_err());
426        assert!(
427            result
428                .expect_err("operation should fail")
429                .contains("unknown workflow step type")
430        );
431    }
432
433    #[test]
434    fn test_has_structured_output() {
435        assert!(has_structured_output("build"));
436        assert!(has_structured_output("test"));
437        assert!(has_structured_output("lint"));
438        assert!(has_structured_output("qa_testing"));
439        assert!(has_structured_output("self_test"));
440        assert!(has_structured_output("smoke_chain"));
441
442        assert!(!has_structured_output("plan"));
443        assert!(!has_structured_output("fix"));
444        assert!(!has_structured_output("implement"));
445        assert!(!has_structured_output("review"));
446        assert!(!has_structured_output("qa"));
447        assert!(!has_structured_output("doc_governance"));
448    }
449
450    #[test]
451    fn test_default_scope_task_steps() {
452        let task_scoped = vec![
453            "plan",
454            "qa_doc_gen",
455            "implement",
456            "self_test",
457            "align_tests",
458            "doc_governance",
459            "review",
460            "build",
461            "test",
462            "lint",
463            "git_ops",
464            "smoke_chain",
465            "loop_guard",
466            "init_once",
467        ];
468        for id in task_scoped {
469            assert_eq!(
470                default_scope_for_step_id(id),
471                StepScope::Task,
472                "expected Task for {}",
473                id
474            );
475        }
476    }
477
478    #[test]
479    fn test_default_scope_item_steps() {
480        let item_scoped = vec![
481            "qa",
482            "qa_testing",
483            "ticket_fix",
484            "ticket_scan",
485            "fix",
486            "retest",
487        ];
488        for id in item_scoped {
489            assert_eq!(
490                default_scope_for_step_id(id),
491                StepScope::Item,
492                "expected Item for {}",
493                id
494            );
495        }
496    }
497
498    #[test]
499    fn test_step_scope_default() {
500        let scope = StepScope::default();
501        assert_eq!(scope, StepScope::Item);
502    }
503
504    #[test]
505    fn test_cost_preference_default() {
506        let pref = CostPreference::default();
507        assert_eq!(pref, CostPreference::Balance);
508    }
509
510    #[test]
511    fn test_cost_preference_serde_round_trip() {
512        for pref_str in &["\"performance\"", "\"quality\"", "\"balance\""] {
513            let pref: CostPreference =
514                serde_json::from_str(pref_str).expect("deserialize cost preference");
515            let json = serde_json::to_string(&pref).expect("serialize cost preference");
516            assert_eq!(&json, pref_str);
517        }
518    }
519
520    #[test]
521    fn test_step_scope_serde_round_trip() {
522        for scope_str in &["\"task\"", "\"item\""] {
523            let scope: StepScope = serde_json::from_str(scope_str).expect("deserialize step scope");
524            let json = serde_json::to_string(&scope).expect("serialize step scope");
525            assert_eq!(&json, scope_str);
526        }
527    }
528
529    #[test]
530    fn test_post_action_store_put_serde_round_trip() {
531        let action = PostAction::StorePut {
532            store: "metrics".to_string(),
533            key: "bench_result".to_string(),
534            from_var: "qa_score".to_string(),
535        };
536        let json = serde_json::to_string(&action).expect("serialize StorePut");
537        assert!(json.contains("\"type\":\"store_put\""));
538        assert!(json.contains("\"store\":\"metrics\""));
539        assert!(json.contains("\"key\":\"bench_result\""));
540        assert!(json.contains("\"from_var\":\"qa_score\""));
541
542        let deserialized: PostAction = serde_json::from_str(&json).expect("deserialize StorePut");
543        match deserialized {
544            PostAction::StorePut {
545                store,
546                key,
547                from_var,
548            } => {
549                assert_eq!(store, "metrics");
550                assert_eq!(key, "bench_result");
551                assert_eq!(from_var, "qa_score");
552            }
553            _ => panic!("expected StorePut variant"),
554        }
555    }
556}