Skip to main content

ironflow_engine/
error.rs

1//! Engine error types.
2
3use chrono::{DateTime, Utc};
4use rust_decimal::Decimal;
5use thiserror::Error;
6use uuid::Uuid;
7
8use ironflow_artifacts::error::ArtifactError;
9use ironflow_core::error::OperationError;
10use ironflow_store::error::StoreError;
11use ironflow_store::models::RunStatus;
12
13use crate::guard::{WORKFLOW_GUARD_REJECTED_CODE, WorkflowRejection};
14
15/// Business error code carried by [`EngineError::RunBudgetExceeded`].
16pub const RUN_BUDGET_EXCEEDED_CODE: &str = "RUN_BUDGET_EXCEEDED";
17
18/// Business error code carried by [`EngineError::MonthlyBudgetExceeded`].
19pub const MONTHLY_BUDGET_EXCEEDED_CODE: &str = "MONTHLY_BUDGET_EXCEEDED";
20
21/// Business error code carried by [`EngineError::ConcurrencyConflict`].
22///
23/// # Examples
24///
25/// ```
26/// use ironflow_engine::error::CONCURRENCY_CONFLICT_CODE;
27///
28/// assert_eq!(CONCURRENCY_CONFLICT_CODE, "CONCURRENCY_CONFLICT");
29/// ```
30pub const CONCURRENCY_CONFLICT_CODE: &str = "CONCURRENCY_CONFLICT";
31
32/// Business error code for handler-version mismatch on retry.
33pub const HANDLER_VERSION_MISMATCH_CODE: &str = "HANDLER_VERSION_MISMATCH";
34
35/// Errors produced by the workflow engine.
36#[derive(Debug, Error)]
37pub enum EngineError {
38    /// An operation (Shell, Http, Agent) failed during step execution.
39    #[error("operation failed: {0}")]
40    Operation(#[from] OperationError),
41
42    /// The backing store returned an error.
43    #[error("store error: {0}")]
44    Store(StoreError),
45
46    /// Another non-terminal run already holds the requested concurrency key.
47    ///
48    /// Converted from [`StoreError::ConcurrencyConflict`], so every `?` on a
49    /// store call yields this typed variant instead of [`EngineError::Store`].
50    #[error("concurrency key {key:?} is held by active run {run_id}")]
51    ConcurrencyConflict {
52        /// The contested key.
53        key: String,
54        /// The run holding it.
55        run_id: Uuid,
56    },
57
58    /// The workflow definition is invalid.
59    #[error("invalid workflow: {0}")]
60    InvalidWorkflow(String),
61
62    /// A step configuration could not be deserialized for execution.
63    #[error("step config error: {0}")]
64    StepConfig(String),
65
66    /// A decision answer was accessed by name with the wrong type or a missing key.
67    #[error("decision error: {0}")]
68    Decision(#[from] ironflow_core::error::DecisionError),
69
70    /// A decision step was reached but no [`DecisionProvider`](ironflow_core::decision::DecisionProvider)
71    /// is wired into the engine.
72    #[error(
73        "decision step '{step}' requires a decision provider; \
74         wire one with Engine::with_decision_provider(...)"
75    )]
76    NoDecisionProvider {
77        /// The decision step that could not run.
78        step: String,
79    },
80
81    /// JSON serialization error.
82    #[error("serialization error: {0}")]
83    Serialization(#[from] serde_json::Error),
84
85    /// The run reached its cumulative cost cap before launching an agent step.
86    ///
87    /// Raised *before* the step is created, so no work and no spend happen.
88    /// The engine transitions the run to
89    /// [`Cancelled`](ironflow_store::entities::RunStatus::Cancelled).
90    #[error(
91        "{RUN_BUDGET_EXCEEDED_CODE}: run {run_id} would exceed its cost cap \
92         (spent {spent_usd} USD + next step {step_budget_usd} USD > cap {limit_usd} USD)"
93    )]
94    RunBudgetExceeded {
95        /// The run that hit its cap.
96        run_id: uuid::Uuid,
97        /// The configured cap, in USD.
98        limit_usd: Decimal,
99        /// Cost already accumulated by this run and its ancestors, in USD.
100        spent_usd: Decimal,
101        /// Declared budget of the step that was about to run, in USD.
102        step_budget_usd: Decimal,
103    },
104
105    /// The global monthly cost quota is exhausted; no new run may be created.
106    ///
107    /// Runs already in flight are never interrupted by this error.
108    #[error(
109        "{MONTHLY_BUDGET_EXCEEDED_CODE}: monthly cost quota exhausted \
110         ({spent_usd} USD spent of {limit_usd} USD)"
111    )]
112    MonthlyBudgetExceeded {
113        /// The configured monthly quota, in USD.
114        limit_usd: Decimal,
115        /// Cost already spent during the current calendar month, in USD.
116        spent_usd: Decimal,
117    },
118
119    /// A step declared an output that produced no file.
120    ///
121    /// Raised only when the step itself succeeded: a declared output that never
122    /// materialised is a broken contract, and failing here beats failing later
123    /// in whichever step tried to consume it.
124    #[error("step {step:?} declared output {pattern:?} but no file matched")]
125    MissingArtifact {
126        /// Name of the step that declared the output.
127        step: String,
128        /// The unmatched pattern.
129        pattern: String,
130    },
131
132    /// A handle was asked for an artifact the step never declared.
133    #[error("step {step:?} declares no artifact output named {name:?}")]
134    ArtifactNotDeclared {
135        /// Name of the step the handle was asked from.
136        step: String,
137        /// Name of the artifact that was asked for.
138        name: String,
139    },
140
141    /// A step asked for an artifact that no earlier step produced.
142    #[error("no artifact {name:?} produced by step {step:?} before this point")]
143    ArtifactNotFound {
144        /// Name of the producing step that was searched for.
145        step: String,
146        /// Name of the artifact that was searched for.
147        name: String,
148    },
149
150    /// Artifacts were used but no storage backend is configured.
151    #[error("artifact storage is not configured: {0}")]
152    ArtifactsUnavailable(String),
153
154    /// The artifact storage backend failed.
155    #[error("artifact storage error: {0}")]
156    Artifact(#[from] ArtifactError),
157
158    /// The run requires human approval before continuing.
159    #[error("approval required for run {run_id}, step {step_id}: {message}")]
160    ApprovalRequired {
161        /// The run that is awaiting approval.
162        run_id: uuid::Uuid,
163        /// The approval step that triggered the pause.
164        step_id: uuid::Uuid,
165        /// The approval message.
166        message: String,
167    },
168
169    /// An approval gate was rejected instead of granted.
170    ///
171    /// Raised when a [`StepInterceptor`](crate::executor::StepInterceptor) resolves
172    /// the gate with [`ApprovalOutcome::Rejected`](crate::executor::ApprovalOutcome::Rejected).
173    /// The engine fails the run; the rejection is deterministic, so the run is
174    /// never replayed.
175    #[error("approval rejected for run {run_id}, step {step_id}: {reason}")]
176    ApprovalRejected {
177        /// The run that was stopped.
178        run_id: uuid::Uuid,
179        /// The approval step that was rejected.
180        step_id: uuid::Uuid,
181        /// Why the gate was refused.
182        reason: String,
183    },
184
185    /// The run waits for a typed human input before continuing.
186    ///
187    /// Raised by [`WorkflowContext::human_input`](crate::context::WorkflowContext::human_input)
188    /// when no answer has been given yet. The engine transitions the run to
189    /// [`AwaitingApproval`](ironflow_store::entities::RunStatus::AwaitingApproval).
190    #[error("human input required for run {run_id}, step {step_id}: {message}")]
191    HumanInputRequired {
192        /// The run that is awaiting input.
193        run_id: uuid::Uuid,
194        /// The human input step that triggered the pause.
195        step_id: uuid::Uuid,
196        /// The message displayed to the person answering.
197        message: String,
198    },
199
200    /// A human input request was rejected instead of answered.
201    ///
202    /// Returned to the handler by
203    /// [`WorkflowContext::human_input`](crate::context::WorkflowContext::human_input),
204    /// which decides what happens next. A propagated rejection fails the run,
205    /// and the run is never retried.
206    #[error("human input rejected for run {run_id}, step {step_id}: {reason}")]
207    HumanInputRejected {
208        /// The run the input belongs to.
209        run_id: uuid::Uuid,
210        /// The human input step that was rejected.
211        step_id: uuid::Uuid,
212        /// Why the input was refused.
213        reason: String,
214    },
215
216    /// A delay step suspended the run until the given time.
217    ///
218    /// The engine transitions the run to
219    /// [`Sleeping`](ironflow_store::entities::RunStatus::Sleeping) and sets
220    /// `scheduled_at` so the worker re-queues it automatically.
221    #[error("delay sleeping for run {run_id}, step {step_id}: wake at {wake_at}")]
222    DelaySleeping {
223        /// The run that is sleeping.
224        run_id: uuid::Uuid,
225        /// The delay step.
226        step_id: uuid::Uuid,
227        /// When the run should be woken up.
228        wake_at: chrono::DateTime<chrono::Utc>,
229    },
230
231    /// A signal step suspended the run until a matching signal or its deadline.
232    ///
233    /// Raised by
234    /// [`WorkflowContext::wait_for_signal`](crate::context::WorkflowContext::wait_for_signal)
235    /// when no signal was received yet. The engine transitions the run to
236    /// [`Sleeping`](ironflow_store::entities::RunStatus::Sleeping) with
237    /// `scheduled_at` set to the deadline: a delivery wakes it earlier.
238    #[error("run {run_id} waiting for signal {name:?} with key {key:?} until {deadline_at}")]
239    SignalWaiting {
240        /// The run that is waiting.
241        run_id: Uuid,
242        /// The signal step the run waits on.
243        step_id: Uuid,
244        /// Name of the signal step.
245        step_name: String,
246        /// The awaited signal name.
247        name: String,
248        /// The awaited occurrence key.
249        key: String,
250        /// When the wait times out.
251        deadline_at: DateTime<Utc>,
252    },
253
254    /// A child run started by
255    /// [`WorkflowContext::workflow`](crate::context::WorkflowContext::workflow)
256    /// suspended (approval, human input, delay or signal), and the parent run
257    /// is suspended with it.
258    ///
259    /// The child keeps its own suspension status and the parent's `Workflow`
260    /// step stays open. Resuming the child requeues the root run, which
261    /// replays and re-enters the same child run.
262    ///
263    /// # Examples
264    ///
265    /// ```
266    /// use ironflow_engine::error::EngineError;
267    /// use uuid::Uuid;
268    ///
269    /// let err = EngineError::ChildSuspended {
270    ///     run_id: Uuid::nil(),
271    ///     cause: Box::new(EngineError::HumanInputRequired {
272    ///         run_id: Uuid::nil(),
273    ///         step_id: Uuid::nil(),
274    ///         message: "Answer the questions".to_string(),
275    ///     }),
276    /// };
277    /// assert!(err.is_suspension());
278    /// ```
279    #[error("child run {run_id} suspended: {cause}")]
280    ChildSuspended {
281        /// The direct child run that suspended.
282        run_id: Uuid,
283        /// Why the child suspended: a leaf suspension
284        /// ([`ApprovalRequired`](EngineError::ApprovalRequired),
285        /// [`HumanInputRequired`](EngineError::HumanInputRequired),
286        /// [`DelaySleeping`](EngineError::DelaySleeping),
287        /// [`SignalWaiting`](EngineError::SignalWaiting)) or a nested
288        /// `ChildSuspended` for a grand-child.
289        cause: Box<EngineError>,
290    },
291
292    /// A signal could not be delivered because it is malformed (empty name or
293    /// key).
294    #[error("invalid signal: {0}")]
295    InvalidSignal(String),
296
297    /// A workflow invocation was rejected by the [workflow guard](crate::guard).
298    ///
299    /// The run is transitioned to
300    /// [`Cancelled`](ironflow_store::entities::RunStatus::Cancelled) when this
301    /// error is raised.
302    #[error("{WORKFLOW_GUARD_REJECTED_CODE}: {0}")]
303    WorkflowGuardRejected(#[from] WorkflowRejection),
304
305    /// The step stored at `position` does not match the step the handler just
306    /// called: its name or its `StepKind` differ from what was recorded before
307    /// the run was suspended.
308    ///
309    /// Raised instead of silently serving another step's cached output when the
310    /// handler's code changed while the run was suspended (a deploy during a
311    /// pending approval, human input, decision escalation or delay): step
312    /// positions can shift, and without this check every step after the
313    /// divergence point would silently receive another step's output, vote or
314    /// approval.
315    #[error(
316        "replay divergence at position {position}: handler called '{expected}' but the run \
317         recorded '{recorded}' (handler changed since the run was suspended?)"
318    )]
319    ReplayDivergence {
320        /// The step position where the recorded step and the step just called
321        /// stopped matching.
322        position: u32,
323        /// Identity (`name (kind)`) of the step the handler just called.
324        expected: String,
325        /// Identity (`name (kind)`) of the step recorded at `position`.
326        recorded: String,
327    },
328
329    /// The run's handler changed since the run was created or suspended, and the
330    /// handler's current version is not declared compatible with the version the
331    /// run was created with.
332    ///
333    /// Checked before any step is replayed, on the same rule the manual retry
334    /// endpoint applies (`HANDLER_VERSION_MISMATCH`). Unlike retry, resume has no
335    /// `force` override: replaying an incompatible handler's steps risks serving
336    /// one step's cached output to another (see
337    /// [`ReplayDivergence`](EngineError::ReplayDivergence)).
338    #[error(
339        "{HANDLER_VERSION_MISMATCH_CODE}: run {run_id} for handler '{workflow_name}' was created \
340         with version {run_version}, but the handler is now at version {current_version}; \
341         resume refused (no force override for resume)"
342    )]
343    HandlerVersionMismatch {
344        /// The run that cannot be resumed.
345        run_id: uuid::Uuid,
346        /// The handler's registered name.
347        workflow_name: String,
348        /// The handler version the run was created with.
349        run_version: String,
350        /// The handler's current version.
351        current_version: String,
352    },
353}
354
355impl From<StoreError> for EngineError {
356    fn from(err: StoreError) -> Self {
357        match err {
358            StoreError::ConcurrencyConflict { key, run_id } => {
359                EngineError::ConcurrencyConflict { key, run_id }
360            }
361            other => EngineError::Store(other),
362        }
363    }
364}
365
366impl EngineError {
367    /// Whether this error suspends the run instead of failing it.
368    ///
369    /// True for [`ApprovalRequired`](EngineError::ApprovalRequired),
370    /// [`HumanInputRequired`](EngineError::HumanInputRequired),
371    /// [`DelaySleeping`](EngineError::DelaySleeping),
372    /// [`SignalWaiting`](EngineError::SignalWaiting) and
373    /// [`ChildSuspended`](EngineError::ChildSuspended).
374    ///
375    /// # Examples
376    ///
377    /// ```
378    /// use ironflow_engine::error::EngineError;
379    ///
380    /// assert!(!EngineError::StepConfig("bad".to_string()).is_suspension());
381    /// ```
382    pub fn is_suspension(&self) -> bool {
383        matches!(
384            self,
385            EngineError::ApprovalRequired { .. }
386                | EngineError::HumanInputRequired { .. }
387                | EngineError::DelaySleeping { .. }
388                | EngineError::SignalWaiting { .. }
389                | EngineError::ChildSuspended { .. }
390        )
391    }
392
393    /// The leaf suspension behind a chain of
394    /// [`ChildSuspended`](EngineError::ChildSuspended) errors.
395    ///
396    /// Returns `self` for any error that is not `ChildSuspended`.
397    ///
398    /// # Examples
399    ///
400    /// ```
401    /// use ironflow_engine::error::EngineError;
402    /// use uuid::Uuid;
403    ///
404    /// let err = EngineError::ChildSuspended {
405    ///     run_id: Uuid::nil(),
406    ///     cause: Box::new(EngineError::ApprovalRequired {
407    ///         run_id: Uuid::nil(),
408    ///         step_id: Uuid::nil(),
409    ///         message: "deploy?".to_string(),
410    ///     }),
411    /// };
412    /// assert!(matches!(err.suspension_leaf(), EngineError::ApprovalRequired { .. }));
413    /// ```
414    pub fn suspension_leaf(&self) -> &EngineError {
415        let mut current = self;
416        while let EngineError::ChildSuspended { cause, .. } = current {
417            current = cause;
418        }
419        current
420    }
421
422    /// The status a run suspended by this error takes: `Sleeping` when the
423    /// leaf suspension is a delay or a signal, `AwaitingApproval` otherwise (a
424    /// gate a human resolves).
425    pub(crate) fn suspension_status(&self) -> RunStatus {
426        match self.suspension_leaf() {
427            EngineError::DelaySleeping { .. } | EngineError::SignalWaiting { .. } => {
428                RunStatus::Sleeping
429            }
430            _ => RunStatus::AwaitingApproval,
431        }
432    }
433}
434
435#[cfg(test)]
436mod tests {
437    use super::*;
438    use crate::retry_policy::is_run_retryable;
439
440    #[test]
441    fn invalid_workflow_display() {
442        let err = EngineError::InvalidWorkflow("unknown-handler".to_string());
443        assert!(err.to_string().contains("invalid workflow"));
444        assert!(err.to_string().contains("unknown-handler"));
445    }
446
447    #[test]
448    fn step_config_display() {
449        let err = EngineError::StepConfig("bad shell config".to_string());
450        assert!(err.to_string().contains("step config error"));
451        assert!(err.to_string().contains("bad shell config"));
452    }
453
454    #[test]
455    fn human_input_required_display() {
456        let err = EngineError::HumanInputRequired {
457            run_id: uuid::Uuid::nil(),
458            step_id: uuid::Uuid::nil(),
459            message: "Answer the questions".to_string(),
460        };
461        let text = err.to_string();
462        assert!(text.contains("human input required"));
463        assert!(text.contains("Answer the questions"));
464    }
465
466    #[test]
467    fn human_input_rejected_display() {
468        let err = EngineError::HumanInputRejected {
469            run_id: uuid::Uuid::nil(),
470            step_id: uuid::Uuid::nil(),
471            reason: "not relevant".to_string(),
472        };
473        let text = err.to_string();
474        assert!(text.contains("human input rejected"));
475        assert!(text.contains("not relevant"));
476    }
477
478    #[test]
479    fn store_error_from_conversion() {
480        let store_err = StoreError::RunNotFound(uuid::Uuid::nil());
481        let engine_err = EngineError::from(store_err);
482        assert!(engine_err.to_string().contains("store error"));
483    }
484
485    #[test]
486    fn store_concurrency_conflict_converts_to_engine_variant() {
487        let run_id = Uuid::now_v7();
488        let engine_err = EngineError::from(StoreError::ConcurrencyConflict {
489            key: "issue:12".to_string(),
490            run_id,
491        });
492        match engine_err {
493            EngineError::ConcurrencyConflict {
494                ref key,
495                run_id: holder,
496            } => {
497                assert_eq!(key, "issue:12");
498                assert_eq!(holder, run_id);
499            }
500            ref other => panic!("expected ConcurrencyConflict, got {other:?}"),
501        }
502        assert!(engine_err.to_string().contains("issue:12"));
503        assert!(!is_run_retryable(&engine_err));
504    }
505
506    #[test]
507    fn run_budget_exceeded_display_carries_code_and_amounts() {
508        let err = EngineError::RunBudgetExceeded {
509            run_id: uuid::Uuid::nil(),
510            limit_usd: Decimal::new(200, 2),
511            spent_usd: Decimal::new(180, 2),
512            step_budget_usd: Decimal::new(50, 2),
513        };
514
515        let msg = err.to_string();
516        assert!(msg.contains(RUN_BUDGET_EXCEEDED_CODE));
517        assert!(msg.contains("2.00"));
518        assert!(msg.contains("1.80"));
519        assert!(msg.contains("0.50"));
520    }
521
522    #[test]
523    fn monthly_budget_exceeded_display_carries_code_and_amounts() {
524        let err = EngineError::MonthlyBudgetExceeded {
525            limit_usd: Decimal::new(10000, 2),
526            spent_usd: Decimal::new(10500, 2),
527        };
528
529        let msg = err.to_string();
530        assert!(msg.contains(MONTHLY_BUDGET_EXCEEDED_CODE));
531        assert!(msg.contains("100.00"));
532        assert!(msg.contains("105.00"));
533    }
534
535    #[test]
536    fn missing_artifact_display_names_the_step_and_pattern() {
537        let err = EngineError::MissingArtifact {
538            step: "build".to_string(),
539            pattern: "target/report.html".to_string(),
540        };
541
542        let msg = err.to_string();
543        assert!(msg.contains("\"build\""));
544        assert!(msg.contains("target/report.html"));
545    }
546
547    #[test]
548    fn artifact_not_declared_display_names_the_step_and_artifact() {
549        let err = EngineError::ArtifactNotDeclared {
550            step: "build".to_string(),
551            name: "report.htm".to_string(),
552        };
553
554        let msg = err.to_string();
555        assert!(msg.contains("\"build\""));
556        assert!(msg.contains("\"report.htm\""));
557    }
558
559    #[test]
560    fn artifact_not_found_display_names_the_producer() {
561        let err = EngineError::ArtifactNotFound {
562            step: "build".to_string(),
563            name: "report.html".to_string(),
564        };
565
566        let msg = err.to_string();
567        assert!(msg.contains("\"build\""));
568        assert!(msg.contains("report.html"));
569    }
570
571    #[test]
572    fn artifacts_unavailable_display() {
573        let err = EngineError::ArtifactsUnavailable("no blob store".to_string());
574        assert!(err.to_string().contains("not configured"));
575    }
576
577    #[test]
578    fn artifact_error_from_conversion() {
579        let engine_err = EngineError::from(ArtifactError::NotFound("a/b".to_string()));
580        assert!(engine_err.to_string().contains("artifact storage error"));
581    }
582
583    #[test]
584    fn serialization_error_from_conversion() {
585        let serde_err = serde_json::from_str::<String>("not json").unwrap_err();
586        let engine_err = EngineError::from(serde_err);
587        assert!(engine_err.to_string().contains("serialization error"));
588    }
589
590    #[test]
591    fn workflow_guard_rejected_display_carries_code_and_detail() {
592        use crate::guard::WorkflowRejection;
593
594        let rejection = WorkflowRejection::MaxDepthExceeded { depth: 6, max: 5 };
595        let err = EngineError::from(rejection);
596
597        let msg = err.to_string();
598        assert!(msg.contains(WORKFLOW_GUARD_REJECTED_CODE));
599        assert!(msg.contains("max call depth exceeded"));
600        assert!(msg.contains("6/5"));
601    }
602
603    #[test]
604    fn workflow_guard_rejected_from_conversion() {
605        use crate::guard::WorkflowRejection;
606
607        let rejection = WorkflowRejection::CycleDetected {
608            target: "wf-b".to_string(),
609            chain: vec!["wf-a".to_string(), "wf-b".to_string()],
610        };
611        let engine_err = EngineError::from(rejection);
612        assert!(engine_err.to_string().contains("cycle detected"));
613    }
614
615    #[test]
616    fn replay_divergence_display_carries_position_and_identities() {
617        let err = EngineError::ReplayDivergence {
618            position: 5,
619            expected: "resolve-base-branch (Shell)".to_string(),
620            recorded: "create-worktree (Shell)".to_string(),
621        };
622
623        let msg = err.to_string();
624        assert!(msg.contains("divergence"));
625        assert!(msg.contains("position 5"));
626        assert!(msg.contains("resolve-base-branch"));
627        assert!(msg.contains("create-worktree"));
628    }
629
630    #[test]
631    fn handler_version_mismatch_display_carries_code_and_versions() {
632        let err = EngineError::HandlerVersionMismatch {
633            run_id: uuid::Uuid::nil(),
634            workflow_name: "deploy".to_string(),
635            run_version: "1.0.0".to_string(),
636            current_version: "2.0.0".to_string(),
637        };
638
639        let msg = err.to_string();
640        assert!(msg.contains(HANDLER_VERSION_MISMATCH_CODE));
641        assert!(msg.contains("1.0.0"));
642        assert!(msg.contains("2.0.0"));
643    }
644
645    fn human_input_required() -> EngineError {
646        EngineError::HumanInputRequired {
647            run_id: Uuid::nil(),
648            step_id: Uuid::nil(),
649            message: "Answer the questions".to_string(),
650        }
651    }
652
653    #[test]
654    fn child_suspended_display_carries_child_and_cause() {
655        let child = Uuid::now_v7();
656        let err = EngineError::ChildSuspended {
657            run_id: child,
658            cause: Box::new(human_input_required()),
659        };
660
661        let msg = err.to_string();
662        assert!(msg.contains(&child.to_string()));
663        assert!(msg.contains("human input required"));
664    }
665
666    #[test]
667    fn leaf_suspensions_and_child_suspended_are_suspensions() {
668        let wake_at = Utc::now();
669        let suspensions = [
670            EngineError::ApprovalRequired {
671                run_id: Uuid::nil(),
672                step_id: Uuid::nil(),
673                message: "deploy?".to_string(),
674            },
675            human_input_required(),
676            EngineError::DelaySleeping {
677                run_id: Uuid::nil(),
678                step_id: Uuid::nil(),
679                wake_at,
680            },
681            EngineError::SignalWaiting {
682                run_id: Uuid::nil(),
683                step_id: Uuid::nil(),
684                step_name: "wait".to_string(),
685                name: "payment".to_string(),
686                key: "order-1".to_string(),
687                deadline_at: wake_at,
688            },
689            EngineError::ChildSuspended {
690                run_id: Uuid::nil(),
691                cause: Box::new(human_input_required()),
692            },
693        ];
694        for err in &suspensions {
695            assert!(err.is_suspension(), "{err} should be a suspension");
696        }
697    }
698
699    #[test]
700    fn failures_and_rejections_are_not_suspensions() {
701        let failures = [
702            EngineError::InvalidWorkflow("x".to_string()),
703            EngineError::HumanInputRejected {
704                run_id: Uuid::nil(),
705                step_id: Uuid::nil(),
706                reason: "no".to_string(),
707            },
708            EngineError::ApprovalRejected {
709                run_id: Uuid::nil(),
710                step_id: Uuid::nil(),
711                reason: "no".to_string(),
712            },
713        ];
714        for err in &failures {
715            assert!(!err.is_suspension(), "{err} should not be a suspension");
716        }
717    }
718
719    #[test]
720    fn suspension_leaf_unwraps_nested_child_suspensions() {
721        let err = EngineError::ChildSuspended {
722            run_id: Uuid::now_v7(),
723            cause: Box::new(EngineError::ChildSuspended {
724                run_id: Uuid::now_v7(),
725                cause: Box::new(human_input_required()),
726            }),
727        };
728
729        assert!(matches!(
730            err.suspension_leaf(),
731            EngineError::HumanInputRequired { .. }
732        ));
733    }
734
735    #[test]
736    fn suspension_status_follows_the_leaf() {
737        let human = EngineError::ChildSuspended {
738            run_id: Uuid::nil(),
739            cause: Box::new(human_input_required()),
740        };
741        assert_eq!(human.suspension_status(), RunStatus::AwaitingApproval);
742
743        let delay = EngineError::ChildSuspended {
744            run_id: Uuid::nil(),
745            cause: Box::new(EngineError::DelaySleeping {
746                run_id: Uuid::nil(),
747                step_id: Uuid::nil(),
748                wake_at: Utc::now(),
749            }),
750        };
751        assert_eq!(delay.suspension_status(), RunStatus::Sleeping);
752    }
753
754    #[test]
755    fn suspension_leaf_of_a_plain_error_is_itself() {
756        let err = EngineError::StepConfig("bad".to_string());
757        assert!(matches!(err.suspension_leaf(), EngineError::StepConfig(_)));
758    }
759}