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}