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