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