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/// Framework builtin step names — these have Rust implementations in the
176/// scheduler and form a security boundary: only names in this list may be
177/// dispatched as `ExecutionMode::Builtin`.
178const KNOWN_BUILTIN_STEP_NAMES: &[&str] = &[
179    "init_once",
180    "loop_guard",
181    "ticket_scan",
182    "self_test",
183    "self_restart",
184    "item_select",
185];
186
187/// Accepts any non-empty step type string.
188///
189/// The framework no longer maintains a whitelist of known step IDs.
190/// Custom step IDs are legal and resolve to `Agent { capability = step_id }`
191/// via the universal fallback rule.
192pub fn validate_step_type(value: &str) -> Result<String, String> {
193    if value.trim().is_empty() {
194        Err("step type cannot be empty".to_string())
195    } else {
196        Ok(value.to_string())
197    }
198}
199
200/// Returns `true` when a builtin step name is recognized by the scheduler.
201pub fn is_known_builtin_step_name(value: &str) -> bool {
202    KNOWN_BUILTIN_STEP_NAMES.contains(&value)
203}
204
205/// Resolves the semantic step kind after applying convention-registry defaults.
206///
207/// Resolution priority:
208/// 1. chain_steps → Chain
209/// 2. command → Command
210/// 3. explicit builtin (validated against KNOWN_BUILTIN_STEP_NAMES) → Builtin
211/// 4. explicit required_capability → Agent
212/// 5. convention-registry builtin → Builtin
213/// 6. universal fallback → Agent { capability = step_id }
214pub fn resolve_step_semantic_kind(step: &WorkflowStepConfig) -> Result<StepSemanticKind, String> {
215    if step.builtin.is_some() && step.required_capability.is_some() {
216        return Err(format!(
217            "step '{}' cannot define both builtin and required_capability",
218            step.id
219        ));
220    }
221
222    if !step.chain_steps.is_empty() {
223        return Ok(StepSemanticKind::Chain);
224    }
225
226    if step.command.is_some() {
227        return Ok(StepSemanticKind::Command);
228    }
229
230    if let Some(ref builtin) = step.builtin {
231        if !is_known_builtin_step_name(builtin) {
232            return Err(format!(
233                "step '{}' uses unknown builtin '{}'",
234                step.id, builtin
235            ));
236        }
237        return Ok(StepSemanticKind::Builtin {
238            name: builtin.clone(),
239        });
240    }
241
242    if let Some(ref capability) = step.required_capability {
243        return Ok(StepSemanticKind::Agent {
244            capability: capability.clone(),
245        });
246    }
247
248    // Convention-registry lookup: check if this step ID maps to a framework builtin.
249    if let Some(builtin_name) = super::CONVENTIONS.builtin_name(&step.id) {
250        return Ok(StepSemanticKind::Builtin { name: builtin_name });
251    }
252
253    // Universal fallback: any step dispatches to an agent whose capability
254    // matches the step ID.  This replaces the former hardcoded capability table.
255    Ok(StepSemanticKind::Agent {
256        capability: step.id.clone(),
257    })
258}
259
260/// Normalizes the execution mode and default selectors for one workflow step.
261pub fn normalize_step_execution_mode(step: &mut WorkflowStepConfig) -> Result<(), String> {
262    match resolve_step_semantic_kind(step)? {
263        StepSemanticKind::Builtin { name } => {
264            step.builtin = Some(name.clone());
265            step.required_capability = None;
266            step.behavior.execution = ExecutionMode::Builtin { name };
267        }
268        StepSemanticKind::Agent { capability } => {
269            step.required_capability = Some(capability);
270            step.behavior.execution = ExecutionMode::Agent;
271        }
272        StepSemanticKind::Command => {
273            step.behavior.execution = ExecutionMode::Builtin {
274                name: step.id.clone(),
275            };
276        }
277        StepSemanticKind::Chain => {
278            step.behavior.execution = ExecutionMode::Chain;
279        }
280    }
281    Ok(())
282}
283
284/// Returns the default execution scope for a step ID.
285/// Delegates to the convention registry; falls back to Task scope.
286pub fn default_scope_for_step_id(step_id: &str) -> StepScope {
287    super::CONVENTIONS.default_scope(step_id)
288}
289
290#[cfg(test)]
291mod tests {
292    use super::*;
293
294    #[test]
295    fn capture_decl_deserializes_without_json_path() {
296        let capture: CaptureDecl = serde_yaml::from_str(
297            r#"
298var: score
299source: stdout
300"#,
301        )
302        .expect("capture should deserialize");
303
304        assert_eq!(capture.var, "score");
305        assert_eq!(capture.source, CaptureSource::Stdout);
306        assert_eq!(capture.json_path, None);
307    }
308
309    #[test]
310    fn capture_decl_deserializes_with_json_path() {
311        let capture: CaptureDecl = serde_yaml::from_str(
312            r#"
313var: score
314source: stdout
315json_path: $.total_score
316"#,
317        )
318        .expect("capture should deserialize");
319
320        assert_eq!(capture.var, "score");
321        assert_eq!(capture.source, CaptureSource::Stdout);
322        assert_eq!(capture.json_path.as_deref(), Some("$.total_score"));
323    }
324
325    #[test]
326    fn test_validate_step_type_known_ids() {
327        for id in &[
328            "init_once",
329            "plan",
330            "qa",
331            "ticket_scan",
332            "fix",
333            "retest",
334            "loop_guard",
335            "build",
336            "test",
337            "lint",
338            "implement",
339            "review",
340            "git_ops",
341            "qa_doc_gen",
342            "qa_testing",
343            "ticket_fix",
344            "doc_governance",
345            "align_tests",
346            "self_test",
347            "self_restart",
348            "smoke_chain",
349            "evaluate",
350            "item_select",
351        ] {
352            assert!(validate_step_type(id).is_ok(), "expected valid for {}", id);
353        }
354    }
355
356    #[test]
357    fn test_validate_step_type_accepts_custom_ids() {
358        // Custom step IDs are now accepted — no whitelist restriction.
359        let result = validate_step_type("my_custom_step");
360        assert!(result.is_ok(), "custom step IDs should be accepted");
361        assert_eq!(result.unwrap(), "my_custom_step");
362    }
363
364    #[test]
365    fn test_validate_step_type_rejects_empty() {
366        assert!(validate_step_type("").is_err());
367        assert!(validate_step_type("  ").is_err());
368    }
369
370    #[test]
371    fn test_default_scope_task_steps() {
372        let task_scoped = vec![
373            "plan",
374            "qa_doc_gen",
375            "implement",
376            "self_test",
377            "align_tests",
378            "doc_governance",
379            "review",
380            "build",
381            "test",
382            "lint",
383            "git_ops",
384            "smoke_chain",
385            "loop_guard",
386            "init_once",
387        ];
388        for id in task_scoped {
389            assert_eq!(
390                default_scope_for_step_id(id),
391                StepScope::Task,
392                "expected Task for {}",
393                id
394            );
395        }
396    }
397
398    #[test]
399    fn test_default_scope_item_steps() {
400        let item_scoped = vec![
401            "qa",
402            "qa_testing",
403            "ticket_fix",
404            "ticket_scan",
405            "fix",
406            "retest",
407        ];
408        for id in item_scoped {
409            assert_eq!(
410                default_scope_for_step_id(id),
411                StepScope::Item,
412                "expected Item for {}",
413                id
414            );
415        }
416    }
417
418    #[test]
419    fn test_unknown_step_scope_defaults_to_task() {
420        assert_eq!(default_scope_for_step_id("my_custom_step"), StepScope::Task);
421    }
422
423    #[test]
424    fn test_step_scope_default() {
425        let scope = StepScope::default();
426        assert_eq!(scope, StepScope::Item);
427    }
428
429    #[test]
430    fn test_cost_preference_default() {
431        let pref = CostPreference::default();
432        assert_eq!(pref, CostPreference::Balance);
433    }
434
435    #[test]
436    fn test_cost_preference_serde_round_trip() {
437        for pref_str in &["\"performance\"", "\"quality\"", "\"balance\""] {
438            let pref: CostPreference =
439                serde_json::from_str(pref_str).expect("deserialize cost preference");
440            let json = serde_json::to_string(&pref).expect("serialize cost preference");
441            assert_eq!(&json, pref_str);
442        }
443    }
444
445    #[test]
446    fn test_step_scope_serde_round_trip() {
447        for scope_str in &["\"task\"", "\"item\""] {
448            let scope: StepScope = serde_json::from_str(scope_str).expect("deserialize step scope");
449            let json = serde_json::to_string(&scope).expect("serialize step scope");
450            assert_eq!(&json, scope_str);
451        }
452    }
453
454    #[test]
455    fn test_post_action_store_put_serde_round_trip() {
456        let action = PostAction::StorePut {
457            store: "metrics".to_string(),
458            key: "bench_result".to_string(),
459            from_var: "qa_score".to_string(),
460        };
461        let json = serde_json::to_string(&action).expect("serialize StorePut");
462        assert!(json.contains("\"type\":\"store_put\""));
463        assert!(json.contains("\"store\":\"metrics\""));
464        assert!(json.contains("\"key\":\"bench_result\""));
465        assert!(json.contains("\"from_var\":\"qa_score\""));
466
467        let deserialized: PostAction = serde_json::from_str(&json).expect("deserialize StorePut");
468        match deserialized {
469            PostAction::StorePut {
470                store,
471                key,
472                from_var,
473            } => {
474                assert_eq!(store, "metrics");
475                assert_eq!(key, "bench_result");
476                assert_eq!(from_var, "qa_score");
477            }
478            _ => panic!("expected StorePut variant"),
479        }
480    }
481}