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}