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