Skip to main content

o7/engine/
types.rs

1#![allow(non_snake_case)]
2
3use serde::{Deserialize, Serialize};
4
5/// Unique identifier for a single execution run.
6pub type RunId = String;
7
8/// Status of an individual step in execution.
9#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
10#[serde(rename_all = "snake_case")]
11pub enum StepStatus {
12    Pending,
13    InProgress,
14    Completed,
15    Failed,
16}
17
18/// Call stack of workflow names representing the current execution position.
19pub type StepPath = Vec<String>;
20
21#[derive(Debug, Clone)]
22pub struct HarnessExecContext {
23    pub run_id: RunId,
24    pub step_path: StepPath,
25    pub exec_ordinal: usize,
26}
27
28/// All safe boundary types emitted by the execution engine.
29///
30/// These mark points where the engine can safely pause/resume.
31/// The string values match the TS implementation exactly.
32pub mod safe_boundary_types {
33    pub const BEFORE_STEP_START: &str = "before-step-start";
34    pub const AFTER_STEP_COMPLETE: &str = "after-step-complete";
35    pub const BEFORE_CONDITIONAL_BODY: &str = "before-conditional-body";
36    pub const AFTER_CONDITIONAL_BODY: &str = "after-conditional-body";
37    pub const BEFORE_LOOP_ITERATION: &str = "before-loop-iteration";
38    pub const AFTER_LOOP_ITERATION: &str = "after-loop-iteration";
39    pub const AFTER_BRANCH_TRANSITION: &str = "after-branch-transition";
40    pub const BEFORE_JOIN: &str = "before-join";
41    pub const AFTER_JOIN: &str = "after-join";
42    pub const BEFORE_MATCH_ARM: &str = "before-match-arm";
43    pub const AFTER_MATCH_ARM: &str = "after-match-arm";
44}
45
46/// Discriminated union of all execution events (ADR-26).
47///
48/// Uses internally-tagged serialization via serde, matching the TS
49/// discriminated union where each variant has a `type` field in JSON.
50/// Field names use camelCase to match TS JSON output exactly.
51#[derive(Debug, Clone, Serialize, Deserialize)]
52#[serde(tag = "type")]
53pub enum ExecutionEvent {
54    StepStarted {
55        runId: RunId,
56        stepPath: StepPath,
57    },
58    StepCompleted {
59        runId: RunId,
60        stepPath: StepPath,
61    },
62    StepFailed {
63        runId: RunId,
64        stepPath: StepPath,
65        error: String,
66    },
67    CheckEvaluated {
68        runId: RunId,
69        checkName: String,
70        result: bool,
71        #[serde(skip_serializing_if = "Option::is_none")]
72        reason: Option<String>,
73    },
74    MatchEvaluated {
75        runId: RunId,
76        checkName: String,
77        variant: String,
78        #[serde(skip_serializing_if = "Option::is_none")]
79        reason: Option<String>,
80        armIndex: Option<i64>,
81    },
82    BranchStarted {
83        runId: RunId,
84        branchPath: StepPath,
85    },
86    BranchCompleted {
87        runId: RunId,
88        branchPath: StepPath,
89        #[serde(skip_serializing_if = "Option::is_none")]
90        output: Option<serde_json::Value>,
91    },
92    BranchFailed {
93        runId: RunId,
94        branchPath: StepPath,
95        error: String,
96    },
97    JoinStarted {
98        runId: RunId,
99        joinWorkflow: String,
100    },
101    RunPaused {
102        runId: RunId,
103        position: StepPath,
104    },
105    RunCompleted {
106        runId: RunId,
107    },
108    RunFailed {
109        runId: RunId,
110        position: StepPath,
111        error: String,
112    },
113    SafeBoundary {
114        runId: RunId,
115        boundaryType: String,
116        stepPath: StepPath,
117    },
118}
119
120impl ExecutionEvent {
121    /// Returns the event type name as a static string.
122    pub fn event_type(&self) -> &'static str {
123        match self {
124            ExecutionEvent::StepStarted { .. } => "StepStarted",
125            ExecutionEvent::StepCompleted { .. } => "StepCompleted",
126            ExecutionEvent::StepFailed { .. } => "StepFailed",
127            ExecutionEvent::CheckEvaluated { .. } => "CheckEvaluated",
128            ExecutionEvent::MatchEvaluated { .. } => "MatchEvaluated",
129            ExecutionEvent::BranchStarted { .. } => "BranchStarted",
130            ExecutionEvent::BranchCompleted { .. } => "BranchCompleted",
131            ExecutionEvent::BranchFailed { .. } => "BranchFailed",
132            ExecutionEvent::JoinStarted { .. } => "JoinStarted",
133            ExecutionEvent::RunPaused { .. } => "RunPaused",
134            ExecutionEvent::RunCompleted { .. } => "RunCompleted",
135            ExecutionEvent::RunFailed { .. } => "RunFailed",
136            ExecutionEvent::SafeBoundary { .. } => "SafeBoundary",
137        }
138    }
139}
140
141/// Output from a parallel branch (ADR-27).
142#[derive(Debug, Clone)]
143pub struct BranchOutput {
144    pub workflow: String,
145    pub output: Option<String>,
146}
147
148/// Injected function type for dispatching exec blocks to a harness.
149pub type HarnessDispatchFn = std::sync::Arc<
150    dyn Fn(
151            &crate::parser::ast::ExecBlock,
152            HarnessExecContext,
153        ) -> std::pin::Pin<
154            Box<
155                dyn std::future::Future<Output = Result<crate::harness::types::ExecResult, String>>
156                    + Send,
157            >,
158        > + Send
159        + Sync,
160>;
161
162/// Per-step tracking entry within RunState.
163#[derive(Debug, Clone, Serialize, Deserialize)]
164pub struct StepState {
165    pub stepPath: StepPath,
166    pub status: StepStatus,
167}
168
169/// Run status.
170#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
171#[serde(rename_all = "snake_case")]
172pub enum RunStatus {
173    Running,
174    Paused,
175    Completed,
176    Failed,
177}
178
179/// Persistent run state for pause/resume/reset (ADR-21).
180#[derive(Debug, Clone, Serialize, Deserialize)]
181pub struct RunState {
182    pub runId: RunId,
183    pub rootWorkflow: String,
184    pub status: RunStatus,
185    pub steps: Vec<StepState>,
186    pub events: Vec<ExecutionEvent>,
187    /// The safe boundary indices into the events array.
188    pub safeBoundaries: Vec<usize>,
189    /// Index of the last safe boundary reached (for resume).
190    /// Uses -1 as sentinel for "no boundary reached yet" (TS compat).
191    pub lastSafeBoundaryIndex: i64,
192}
193
194/// Callback fired for each event during execution, enabling real-time streaming.
195pub type OnEventCallback = Box<dyn Fn(&ExecutionEvent) + Send + Sync>;
196
197/// Callback fired when the engine wants to persist its state (at safe boundaries and run end).
198pub type OnSaveCallback = Box<dyn Fn(&RunState) + Send + Sync>;
199
200#[cfg(test)]
201mod tests {
202    use super::*;
203
204    #[test]
205    fn test_step_status_serialization() {
206        assert_eq!(
207            serde_json::to_string(&StepStatus::Pending).unwrap(),
208            "\"pending\""
209        );
210        assert_eq!(
211            serde_json::to_string(&StepStatus::InProgress).unwrap(),
212            "\"in_progress\""
213        );
214        assert_eq!(
215            serde_json::to_string(&StepStatus::Completed).unwrap(),
216            "\"completed\""
217        );
218        assert_eq!(
219            serde_json::to_string(&StepStatus::Failed).unwrap(),
220            "\"failed\""
221        );
222    }
223
224    #[test]
225    fn test_step_status_deserialization() {
226        let pending: StepStatus = serde_json::from_str("\"pending\"").unwrap();
227        assert_eq!(pending, StepStatus::Pending);
228        let in_progress: StepStatus = serde_json::from_str("\"in_progress\"").unwrap();
229        assert_eq!(in_progress, StepStatus::InProgress);
230    }
231
232    #[test]
233    fn test_run_status_serialization() {
234        assert_eq!(
235            serde_json::to_string(&RunStatus::Running).unwrap(),
236            "\"running\""
237        );
238        assert_eq!(
239            serde_json::to_string(&RunStatus::Paused).unwrap(),
240            "\"paused\""
241        );
242        assert_eq!(
243            serde_json::to_string(&RunStatus::Completed).unwrap(),
244            "\"completed\""
245        );
246        assert_eq!(
247            serde_json::to_string(&RunStatus::Failed).unwrap(),
248            "\"failed\""
249        );
250    }
251
252    #[test]
253    fn test_execution_event_step_started_json() {
254        let event = ExecutionEvent::StepStarted {
255            runId: "run-1".to_string(),
256            stepPath: vec!["main".to_string(), "deploy".to_string()],
257        };
258        assert_eq!(event.event_type(), "StepStarted");
259        let json = serde_json::to_string(&event).unwrap();
260        assert!(json.contains("\"type\":\"StepStarted\""), "json: {}", json);
261        assert!(json.contains("\"runId\":\"run-1\""), "json: {}", json);
262        assert!(
263            json.contains("\"stepPath\":[\"main\",\"deploy\"]"),
264            "json: {}",
265            json
266        );
267    }
268
269    #[test]
270    fn test_execution_event_step_failed_json() {
271        let event = ExecutionEvent::StepFailed {
272            runId: "run-1".to_string(),
273            stepPath: vec!["main".to_string()],
274            error: "something broke".to_string(),
275        };
276        assert_eq!(event.event_type(), "StepFailed");
277        let json = serde_json::to_string(&event).unwrap();
278        assert!(
279            json.contains("\"error\":\"something broke\""),
280            "json: {}",
281            json
282        );
283    }
284
285    #[test]
286    fn test_execution_event_check_evaluated_with_reason() {
287        let event = ExecutionEvent::CheckEvaluated {
288            runId: "run-1".to_string(),
289            checkName: "is-ready".to_string(),
290            result: true,
291            reason: Some("all checks passed".to_string()),
292        };
293        assert_eq!(event.event_type(), "CheckEvaluated");
294        let json = serde_json::to_string(&event).unwrap();
295        assert!(
296            json.contains("\"checkName\":\"is-ready\""),
297            "json: {}",
298            json
299        );
300        assert!(json.contains("\"result\":true"), "json: {}", json);
301        assert!(
302            json.contains("\"reason\":\"all checks passed\""),
303            "json: {}",
304            json
305        );
306    }
307
308    #[test]
309    fn test_execution_event_check_evaluated_without_reason() {
310        let event = ExecutionEvent::CheckEvaluated {
311            runId: "run-1".to_string(),
312            checkName: "is-ready".to_string(),
313            result: false,
314            reason: None,
315        };
316        let json = serde_json::to_string(&event).unwrap();
317        assert!(
318            !json.contains("reason"),
319            "reason should be omitted: {}",
320            json
321        );
322    }
323
324    #[test]
325    fn test_execution_event_branch_completed_with_output() {
326        let event = ExecutionEvent::BranchCompleted {
327            runId: "run-1".to_string(),
328            branchPath: vec!["par".to_string(), "branch-a".to_string()],
329            output: Some(serde_json::json!({"status": "ok"})),
330        };
331        assert_eq!(event.event_type(), "BranchCompleted");
332        let json = serde_json::to_string(&event).unwrap();
333        assert!(
334            json.contains("\"output\":{\"status\":\"ok\"}"),
335            "json: {}",
336            json
337        );
338    }
339
340    #[test]
341    fn test_execution_event_branch_completed_without_output() {
342        let event = ExecutionEvent::BranchCompleted {
343            runId: "run-1".to_string(),
344            branchPath: vec!["par".to_string()],
345            output: None,
346        };
347        let json = serde_json::to_string(&event).unwrap();
348        assert!(
349            !json.contains("output"),
350            "output should be omitted: {}",
351            json
352        );
353    }
354
355    #[test]
356    fn test_execution_event_safe_boundary_json() {
357        let event = ExecutionEvent::SafeBoundary {
358            runId: "run-1".to_string(),
359            boundaryType: safe_boundary_types::BEFORE_STEP_START.to_string(),
360            stepPath: vec!["main".to_string()],
361        };
362        assert_eq!(event.event_type(), "SafeBoundary");
363        let json = serde_json::to_string(&event).unwrap();
364        assert!(
365            json.contains("\"boundaryType\":\"before-step-start\""),
366            "json: {}",
367            json
368        );
369    }
370
371    #[test]
372    fn test_execution_event_run_completed() {
373        let event = ExecutionEvent::RunCompleted {
374            runId: "run-1".to_string(),
375        };
376        assert_eq!(event.event_type(), "RunCompleted");
377        let json = serde_json::to_string(&event).unwrap();
378        assert!(json.contains("\"type\":\"RunCompleted\""), "json: {}", json);
379        assert!(json.contains("\"runId\":\"run-1\""), "json: {}", json);
380    }
381
382    #[test]
383    fn test_execution_event_all_types() {
384        // Ensure event_type returns correct string for every variant
385        let events: Vec<ExecutionEvent> = vec![
386            ExecutionEvent::StepStarted {
387                runId: "r".to_string(),
388                stepPath: vec![],
389            },
390            ExecutionEvent::StepCompleted {
391                runId: "r".to_string(),
392                stepPath: vec![],
393            },
394            ExecutionEvent::StepFailed {
395                runId: "r".to_string(),
396                stepPath: vec![],
397                error: "e".to_string(),
398            },
399            ExecutionEvent::CheckEvaluated {
400                runId: "r".to_string(),
401                checkName: "c".to_string(),
402                result: true,
403                reason: None,
404            },
405            ExecutionEvent::MatchEvaluated {
406                runId: "r".to_string(),
407                checkName: "c".to_string(),
408                variant: "v".to_string(),
409                reason: None,
410                armIndex: None,
411            },
412            ExecutionEvent::BranchStarted {
413                runId: "r".to_string(),
414                branchPath: vec![],
415            },
416            ExecutionEvent::BranchCompleted {
417                runId: "r".to_string(),
418                branchPath: vec![],
419                output: None,
420            },
421            ExecutionEvent::BranchFailed {
422                runId: "r".to_string(),
423                branchPath: vec![],
424                error: "e".to_string(),
425            },
426            ExecutionEvent::JoinStarted {
427                runId: "r".to_string(),
428                joinWorkflow: "w".to_string(),
429            },
430            ExecutionEvent::RunPaused {
431                runId: "r".to_string(),
432                position: vec![],
433            },
434            ExecutionEvent::RunCompleted {
435                runId: "r".to_string(),
436            },
437            ExecutionEvent::RunFailed {
438                runId: "r".to_string(),
439                position: vec![],
440                error: "e".to_string(),
441            },
442            ExecutionEvent::SafeBoundary {
443                runId: "r".to_string(),
444                boundaryType: "before-step-start".to_string(),
445                stepPath: vec![],
446            },
447        ];
448        let expected_types = [
449            "StepStarted",
450            "StepCompleted",
451            "StepFailed",
452            "CheckEvaluated",
453            "MatchEvaluated",
454            "BranchStarted",
455            "BranchCompleted",
456            "BranchFailed",
457            "JoinStarted",
458            "RunPaused",
459            "RunCompleted",
460            "RunFailed",
461            "SafeBoundary",
462        ];
463        for (event, expected) in events.iter().zip(expected_types.iter()) {
464            assert_eq!(event.event_type(), *expected);
465        }
466    }
467
468    #[test]
469    fn test_step_state_serialization() {
470        let state = StepState {
471            stepPath: vec!["main".to_string(), "step-1".to_string()],
472            status: StepStatus::Completed,
473        };
474        let json = serde_json::to_string(&state).unwrap();
475        assert!(
476            json.contains("\"stepPath\":[\"main\",\"step-1\"]"),
477            "json: {}",
478            json
479        );
480        assert!(json.contains("\"status\":\"completed\""), "json: {}", json);
481    }
482
483    #[test]
484    fn test_run_state_serialization() {
485        let state = RunState {
486            runId: "run-123".to_string(),
487            rootWorkflow: "main".to_string(),
488            status: RunStatus::Running,
489            steps: vec![StepState {
490                stepPath: vec!["main".to_string()],
491                status: StepStatus::Pending,
492            }],
493            events: vec![],
494            safeBoundaries: vec![],
495            lastSafeBoundaryIndex: -1,
496        };
497        let json = serde_json::to_string(&state).unwrap();
498        assert!(json.contains("\"runId\":\"run-123\""), "json: {}", json);
499        assert!(json.contains("\"rootWorkflow\":\"main\""), "json: {}", json);
500        assert!(json.contains("\"status\":\"running\""), "json: {}", json);
501        assert!(
502            json.contains("\"lastSafeBoundaryIndex\":-1"),
503            "json: {}",
504            json
505        );
506    }
507
508    #[test]
509    fn test_run_state_roundtrip() {
510        let state = RunState {
511            runId: "run-abc".to_string(),
512            rootWorkflow: "deploy".to_string(),
513            status: RunStatus::Completed,
514            steps: vec![
515                StepState {
516                    stepPath: vec!["deploy".to_string(), "build".to_string()],
517                    status: StepStatus::Completed,
518                },
519                StepState {
520                    stepPath: vec!["deploy".to_string(), "test".to_string()],
521                    status: StepStatus::Failed,
522                },
523            ],
524            events: vec![
525                ExecutionEvent::StepStarted {
526                    runId: "run-abc".to_string(),
527                    stepPath: vec!["deploy".to_string(), "build".to_string()],
528                },
529                ExecutionEvent::SafeBoundary {
530                    runId: "run-abc".to_string(),
531                    boundaryType: safe_boundary_types::AFTER_STEP_COMPLETE.to_string(),
532                    stepPath: vec!["deploy".to_string(), "build".to_string()],
533                },
534            ],
535            safeBoundaries: vec![1],
536            lastSafeBoundaryIndex: 1,
537        };
538        let json = serde_json::to_string(&state).unwrap();
539        let deserialized: RunState = serde_json::from_str(&json).unwrap();
540        assert_eq!(deserialized.runId, "run-abc");
541        assert_eq!(deserialized.rootWorkflow, "deploy");
542        assert_eq!(deserialized.status, RunStatus::Completed);
543        assert_eq!(deserialized.steps.len(), 2);
544        assert_eq!(deserialized.events.len(), 2);
545        assert_eq!(deserialized.safeBoundaries, vec![1]);
546        assert_eq!(deserialized.lastSafeBoundaryIndex, 1);
547    }
548
549    #[test]
550    fn test_safe_boundary_type_constants() {
551        // Verify all 11 safe boundary types are defined
552        assert_eq!(safe_boundary_types::BEFORE_STEP_START, "before-step-start");
553        assert_eq!(
554            safe_boundary_types::AFTER_STEP_COMPLETE,
555            "after-step-complete"
556        );
557        assert_eq!(
558            safe_boundary_types::BEFORE_CONDITIONAL_BODY,
559            "before-conditional-body"
560        );
561        assert_eq!(
562            safe_boundary_types::AFTER_CONDITIONAL_BODY,
563            "after-conditional-body"
564        );
565        assert_eq!(
566            safe_boundary_types::BEFORE_LOOP_ITERATION,
567            "before-loop-iteration"
568        );
569        assert_eq!(
570            safe_boundary_types::AFTER_LOOP_ITERATION,
571            "after-loop-iteration"
572        );
573        assert_eq!(
574            safe_boundary_types::AFTER_BRANCH_TRANSITION,
575            "after-branch-transition"
576        );
577        assert_eq!(safe_boundary_types::BEFORE_JOIN, "before-join");
578        assert_eq!(safe_boundary_types::AFTER_JOIN, "after-join");
579        assert_eq!(safe_boundary_types::BEFORE_MATCH_ARM, "before-match-arm");
580        assert_eq!(safe_boundary_types::AFTER_MATCH_ARM, "after-match-arm");
581    }
582
583    #[test]
584    fn test_match_evaluated_with_reason() {
585        let event = ExecutionEvent::MatchEvaluated {
586            runId: "r".to_string(),
587            checkName: "size-check".to_string(),
588            variant: "small".to_string(),
589            reason: Some("under 50 lines".to_string()),
590            armIndex: Some(0),
591        };
592        assert_eq!(event.event_type(), "MatchEvaluated");
593        let json = serde_json::to_string(&event).unwrap();
594        assert!(json.contains("\"variant\":\"small\""));
595        assert!(json.contains("\"reason\":\"under 50 lines\""));
596        assert!(json.contains("\"armIndex\":0"));
597    }
598
599    #[test]
600    fn test_match_evaluated_else_arm() {
601        let event = ExecutionEvent::MatchEvaluated {
602            runId: "r".to_string(),
603            checkName: "size-check".to_string(),
604            variant: "unknown".to_string(),
605            reason: None,
606            armIndex: None,
607        };
608        let json = serde_json::to_string(&event).unwrap();
609        assert!(
610            !json.contains("reason"),
611            "reason should be omitted: {}",
612            json
613        );
614        assert!(
615            json.contains("\"armIndex\":null"),
616            "armIndex should be null: {}",
617            json
618        );
619    }
620
621    #[test]
622    fn test_execution_event_deserialization() {
623        let json = r#"{"type":"StepStarted","runId":"run-1","stepPath":["main"]}"#;
624        let event: ExecutionEvent = serde_json::from_str(json).unwrap();
625        assert_eq!(event.event_type(), "StepStarted");
626        match event {
627            ExecutionEvent::StepStarted { runId, stepPath } => {
628                assert_eq!(runId, "run-1");
629                assert_eq!(stepPath, vec!["main".to_string()]);
630            }
631            _ => panic!("wrong variant"),
632        }
633    }
634
635    #[test]
636    fn test_join_started_serialization() {
637        let event = ExecutionEvent::JoinStarted {
638            runId: "r".to_string(),
639            joinWorkflow: "merge-results".to_string(),
640        };
641        let json = serde_json::to_string(&event).unwrap();
642        assert!(
643            json.contains("\"joinWorkflow\":\"merge-results\""),
644            "json: {}",
645            json
646        );
647    }
648
649    #[test]
650    fn test_run_paused_serialization() {
651        let event = ExecutionEvent::RunPaused {
652            runId: "r".to_string(),
653            position: vec!["main".to_string(), "step-3".to_string()],
654        };
655        let json = serde_json::to_string(&event).unwrap();
656        assert!(
657            json.contains("\"position\":[\"main\",\"step-3\"]"),
658            "json: {}",
659            json
660        );
661    }
662
663    #[test]
664    fn test_run_failed_serialization() {
665        let event = ExecutionEvent::RunFailed {
666            runId: "r".to_string(),
667            position: vec!["main".to_string()],
668            error: "timeout".to_string(),
669        };
670        let json = serde_json::to_string(&event).unwrap();
671        assert!(json.contains("\"type\":\"RunFailed\""), "json: {}", json);
672        assert!(json.contains("\"error\":\"timeout\""), "json: {}", json);
673    }
674
675    #[test]
676    fn test_branch_failed_serialization() {
677        let event = ExecutionEvent::BranchFailed {
678            runId: "r".to_string(),
679            branchPath: vec!["par".to_string(), "b1".to_string()],
680            error: "branch error".to_string(),
681        };
682        let json = serde_json::to_string(&event).unwrap();
683        assert!(json.contains("\"type\":\"BranchFailed\""), "json: {}", json);
684        assert!(
685            json.contains("\"branchPath\":[\"par\",\"b1\"]"),
686            "json: {}",
687            json
688        );
689    }
690
691    #[test]
692    fn test_branch_started_serialization() {
693        let event = ExecutionEvent::BranchStarted {
694            runId: "r".to_string(),
695            branchPath: vec!["par".to_string(), "b1".to_string()],
696        };
697        let json = serde_json::to_string(&event).unwrap();
698        assert!(
699            json.contains("\"type\":\"BranchStarted\""),
700            "json: {}",
701            json
702        );
703    }
704}