Skip to main content

ironflow_engine/
error.rs

1//! Engine error types.
2
3use rust_decimal::Decimal;
4use thiserror::Error;
5
6use ironflow_artifacts::error::ArtifactError;
7use ironflow_core::error::OperationError;
8use ironflow_store::error::StoreError;
9
10use crate::guard::{WORKFLOW_GUARD_REJECTED_CODE, WorkflowRejection};
11
12/// Business error code carried by [`EngineError::RunBudgetExceeded`].
13pub const RUN_BUDGET_EXCEEDED_CODE: &str = "RUN_BUDGET_EXCEEDED";
14
15/// Business error code carried by [`EngineError::MonthlyBudgetExceeded`].
16pub const MONTHLY_BUDGET_EXCEEDED_CODE: &str = "MONTHLY_BUDGET_EXCEEDED";
17
18/// Business error code for handler-version mismatch on retry.
19pub const HANDLER_VERSION_MISMATCH_CODE: &str = "HANDLER_VERSION_MISMATCH";
20
21/// Errors produced by the workflow engine.
22#[derive(Debug, Error)]
23pub enum EngineError {
24    /// An operation (Shell, Http, Agent) failed during step execution.
25    #[error("operation failed: {0}")]
26    Operation(#[from] OperationError),
27
28    /// The backing store returned an error.
29    #[error("store error: {0}")]
30    Store(#[from] StoreError),
31
32    /// The workflow definition is invalid.
33    #[error("invalid workflow: {0}")]
34    InvalidWorkflow(String),
35
36    /// A step configuration could not be deserialized for execution.
37    #[error("step config error: {0}")]
38    StepConfig(String),
39
40    /// A decision answer was accessed by name with the wrong type or a missing key.
41    #[error("decision error: {0}")]
42    Decision(#[from] ironflow_core::error::DecisionError),
43
44    /// A decision step was reached but no [`DecisionProvider`](ironflow_core::decision::DecisionProvider)
45    /// is wired into the engine.
46    #[error(
47        "decision step '{step}' requires a decision provider; \
48         wire one with Engine::with_decision_provider(...)"
49    )]
50    NoDecisionProvider {
51        /// The decision step that could not run.
52        step: String,
53    },
54
55    /// JSON serialization error.
56    #[error("serialization error: {0}")]
57    Serialization(#[from] serde_json::Error),
58
59    /// The run reached its cumulative cost cap before launching an agent step.
60    ///
61    /// Raised *before* the step is created, so no work and no spend happen.
62    /// The engine transitions the run to
63    /// [`Cancelled`](ironflow_store::entities::RunStatus::Cancelled).
64    #[error(
65        "{RUN_BUDGET_EXCEEDED_CODE}: run {run_id} would exceed its cost cap \
66         (spent {spent_usd} USD + next step {step_budget_usd} USD > cap {limit_usd} USD)"
67    )]
68    RunBudgetExceeded {
69        /// The run that hit its cap.
70        run_id: uuid::Uuid,
71        /// The configured cap, in USD.
72        limit_usd: Decimal,
73        /// Cost already accumulated by this run and its ancestors, in USD.
74        spent_usd: Decimal,
75        /// Declared budget of the step that was about to run, in USD.
76        step_budget_usd: Decimal,
77    },
78
79    /// The global monthly cost quota is exhausted; no new run may be created.
80    ///
81    /// Runs already in flight are never interrupted by this error.
82    #[error(
83        "{MONTHLY_BUDGET_EXCEEDED_CODE}: monthly cost quota exhausted \
84         ({spent_usd} USD spent of {limit_usd} USD)"
85    )]
86    MonthlyBudgetExceeded {
87        /// The configured monthly quota, in USD.
88        limit_usd: Decimal,
89        /// Cost already spent during the current calendar month, in USD.
90        spent_usd: Decimal,
91    },
92
93    /// A step declared an output that produced no file.
94    ///
95    /// Raised only when the step itself succeeded: a declared output that never
96    /// materialised is a broken contract, and failing here beats failing later
97    /// in whichever step tried to consume it.
98    #[error("step {step:?} declared output {pattern:?} but no file matched")]
99    MissingArtifact {
100        /// Name of the step that declared the output.
101        step: String,
102        /// The unmatched pattern.
103        pattern: String,
104    },
105
106    /// A handle was asked for an artifact the step never declared.
107    #[error("step {step:?} declares no artifact output named {name:?}")]
108    ArtifactNotDeclared {
109        /// Name of the step the handle was asked from.
110        step: String,
111        /// Name of the artifact that was asked for.
112        name: String,
113    },
114
115    /// A step asked for an artifact that no earlier step produced.
116    #[error("no artifact {name:?} produced by step {step:?} before this point")]
117    ArtifactNotFound {
118        /// Name of the producing step that was searched for.
119        step: String,
120        /// Name of the artifact that was searched for.
121        name: String,
122    },
123
124    /// Artifacts were used but no storage backend is configured.
125    #[error("artifact storage is not configured: {0}")]
126    ArtifactsUnavailable(String),
127
128    /// The artifact storage backend failed.
129    #[error("artifact storage error: {0}")]
130    Artifact(#[from] ArtifactError),
131
132    /// The run requires human approval before continuing.
133    #[error("approval required for run {run_id}, step {step_id}: {message}")]
134    ApprovalRequired {
135        /// The run that is awaiting approval.
136        run_id: uuid::Uuid,
137        /// The approval step that triggered the pause.
138        step_id: uuid::Uuid,
139        /// The approval message.
140        message: String,
141    },
142
143    /// An approval gate was rejected instead of granted.
144    ///
145    /// Raised when a [`StepInterceptor`](crate::executor::StepInterceptor) resolves
146    /// the gate with [`ApprovalOutcome::Rejected`](crate::executor::ApprovalOutcome::Rejected).
147    /// The engine fails the run; the rejection is deterministic, so the run is
148    /// never replayed.
149    #[error("approval rejected for run {run_id}, step {step_id}: {reason}")]
150    ApprovalRejected {
151        /// The run that was stopped.
152        run_id: uuid::Uuid,
153        /// The approval step that was rejected.
154        step_id: uuid::Uuid,
155        /// Why the gate was refused.
156        reason: String,
157    },
158
159    /// The run waits for a typed human input before continuing.
160    ///
161    /// Raised by [`WorkflowContext::human_input`](crate::context::WorkflowContext::human_input)
162    /// when no answer has been given yet. The engine transitions the run to
163    /// [`AwaitingApproval`](ironflow_store::entities::RunStatus::AwaitingApproval).
164    #[error("human input required for run {run_id}, step {step_id}: {message}")]
165    HumanInputRequired {
166        /// The run that is awaiting input.
167        run_id: uuid::Uuid,
168        /// The human input step that triggered the pause.
169        step_id: uuid::Uuid,
170        /// The message displayed to the person answering.
171        message: String,
172    },
173
174    /// A human input request was rejected instead of answered.
175    ///
176    /// Returned to the handler by
177    /// [`WorkflowContext::human_input`](crate::context::WorkflowContext::human_input),
178    /// which decides what happens next. A propagated rejection fails the run,
179    /// and the run is never retried.
180    #[error("human input rejected for run {run_id}, step {step_id}: {reason}")]
181    HumanInputRejected {
182        /// The run the input belongs to.
183        run_id: uuid::Uuid,
184        /// The human input step that was rejected.
185        step_id: uuid::Uuid,
186        /// Why the input was refused.
187        reason: String,
188    },
189
190    /// A delay step suspended the run until the given time.
191    ///
192    /// The engine transitions the run to
193    /// [`Sleeping`](ironflow_store::entities::RunStatus::Sleeping) and sets
194    /// `scheduled_at` so the worker re-queues it automatically.
195    #[error("delay sleeping for run {run_id}, step {step_id}: wake at {wake_at}")]
196    DelaySleeping {
197        /// The run that is sleeping.
198        run_id: uuid::Uuid,
199        /// The delay step.
200        step_id: uuid::Uuid,
201        /// When the run should be woken up.
202        wake_at: chrono::DateTime<chrono::Utc>,
203    },
204
205    /// A workflow invocation was rejected by the [workflow guard](crate::guard).
206    ///
207    /// The run is transitioned to
208    /// [`Cancelled`](ironflow_store::entities::RunStatus::Cancelled) when this
209    /// error is raised.
210    #[error("{WORKFLOW_GUARD_REJECTED_CODE}: {0}")]
211    WorkflowGuardRejected(#[from] WorkflowRejection),
212
213    /// The step stored at `position` does not match the step the handler just
214    /// called: its name or its `StepKind` differ from what was recorded before
215    /// the run was suspended.
216    ///
217    /// Raised instead of silently serving another step's cached output when the
218    /// handler's code changed while the run was suspended (a deploy during a
219    /// pending approval, human input, decision escalation or delay): step
220    /// positions can shift, and without this check every step after the
221    /// divergence point would silently receive another step's output, vote or
222    /// approval.
223    #[error(
224        "replay divergence at position {position}: handler called '{expected}' but the run \
225         recorded '{recorded}' (handler changed since the run was suspended?)"
226    )]
227    ReplayDivergence {
228        /// The step position where the recorded step and the step just called
229        /// stopped matching.
230        position: u32,
231        /// Identity (`name (kind)`) of the step the handler just called.
232        expected: String,
233        /// Identity (`name (kind)`) of the step recorded at `position`.
234        recorded: String,
235    },
236
237    /// The run's handler changed since the run was created or suspended, and the
238    /// handler's current version is not declared compatible with the version the
239    /// run was created with.
240    ///
241    /// Checked before any step is replayed, on the same rule the manual retry
242    /// endpoint applies (`HANDLER_VERSION_MISMATCH`). Unlike retry, resume has no
243    /// `force` override: replaying an incompatible handler's steps risks serving
244    /// one step's cached output to another (see
245    /// [`ReplayDivergence`](EngineError::ReplayDivergence)).
246    #[error(
247        "{HANDLER_VERSION_MISMATCH_CODE}: run {run_id} for handler '{workflow_name}' was created \
248         with version {run_version}, but the handler is now at version {current_version}; \
249         resume refused (no force override for resume)"
250    )]
251    HandlerVersionMismatch {
252        /// The run that cannot be resumed.
253        run_id: uuid::Uuid,
254        /// The handler's registered name.
255        workflow_name: String,
256        /// The handler version the run was created with.
257        run_version: String,
258        /// The handler's current version.
259        current_version: String,
260    },
261}
262
263#[cfg(test)]
264mod tests {
265    use super::*;
266
267    #[test]
268    fn invalid_workflow_display() {
269        let err = EngineError::InvalidWorkflow("unknown-handler".to_string());
270        assert!(err.to_string().contains("invalid workflow"));
271        assert!(err.to_string().contains("unknown-handler"));
272    }
273
274    #[test]
275    fn step_config_display() {
276        let err = EngineError::StepConfig("bad shell config".to_string());
277        assert!(err.to_string().contains("step config error"));
278        assert!(err.to_string().contains("bad shell config"));
279    }
280
281    #[test]
282    fn human_input_required_display() {
283        let err = EngineError::HumanInputRequired {
284            run_id: uuid::Uuid::nil(),
285            step_id: uuid::Uuid::nil(),
286            message: "Answer the questions".to_string(),
287        };
288        let text = err.to_string();
289        assert!(text.contains("human input required"));
290        assert!(text.contains("Answer the questions"));
291    }
292
293    #[test]
294    fn human_input_rejected_display() {
295        let err = EngineError::HumanInputRejected {
296            run_id: uuid::Uuid::nil(),
297            step_id: uuid::Uuid::nil(),
298            reason: "not relevant".to_string(),
299        };
300        let text = err.to_string();
301        assert!(text.contains("human input rejected"));
302        assert!(text.contains("not relevant"));
303    }
304
305    #[test]
306    fn store_error_from_conversion() {
307        let store_err = StoreError::RunNotFound(uuid::Uuid::nil());
308        let engine_err = EngineError::from(store_err);
309        assert!(engine_err.to_string().contains("store error"));
310    }
311
312    #[test]
313    fn run_budget_exceeded_display_carries_code_and_amounts() {
314        let err = EngineError::RunBudgetExceeded {
315            run_id: uuid::Uuid::nil(),
316            limit_usd: Decimal::new(200, 2),
317            spent_usd: Decimal::new(180, 2),
318            step_budget_usd: Decimal::new(50, 2),
319        };
320
321        let msg = err.to_string();
322        assert!(msg.contains(RUN_BUDGET_EXCEEDED_CODE));
323        assert!(msg.contains("2.00"));
324        assert!(msg.contains("1.80"));
325        assert!(msg.contains("0.50"));
326    }
327
328    #[test]
329    fn monthly_budget_exceeded_display_carries_code_and_amounts() {
330        let err = EngineError::MonthlyBudgetExceeded {
331            limit_usd: Decimal::new(10000, 2),
332            spent_usd: Decimal::new(10500, 2),
333        };
334
335        let msg = err.to_string();
336        assert!(msg.contains(MONTHLY_BUDGET_EXCEEDED_CODE));
337        assert!(msg.contains("100.00"));
338        assert!(msg.contains("105.00"));
339    }
340
341    #[test]
342    fn missing_artifact_display_names_the_step_and_pattern() {
343        let err = EngineError::MissingArtifact {
344            step: "build".to_string(),
345            pattern: "target/report.html".to_string(),
346        };
347
348        let msg = err.to_string();
349        assert!(msg.contains("\"build\""));
350        assert!(msg.contains("target/report.html"));
351    }
352
353    #[test]
354    fn artifact_not_declared_display_names_the_step_and_artifact() {
355        let err = EngineError::ArtifactNotDeclared {
356            step: "build".to_string(),
357            name: "report.htm".to_string(),
358        };
359
360        let msg = err.to_string();
361        assert!(msg.contains("\"build\""));
362        assert!(msg.contains("\"report.htm\""));
363    }
364
365    #[test]
366    fn artifact_not_found_display_names_the_producer() {
367        let err = EngineError::ArtifactNotFound {
368            step: "build".to_string(),
369            name: "report.html".to_string(),
370        };
371
372        let msg = err.to_string();
373        assert!(msg.contains("\"build\""));
374        assert!(msg.contains("report.html"));
375    }
376
377    #[test]
378    fn artifacts_unavailable_display() {
379        let err = EngineError::ArtifactsUnavailable("no blob store".to_string());
380        assert!(err.to_string().contains("not configured"));
381    }
382
383    #[test]
384    fn artifact_error_from_conversion() {
385        let engine_err = EngineError::from(ArtifactError::NotFound("a/b".to_string()));
386        assert!(engine_err.to_string().contains("artifact storage error"));
387    }
388
389    #[test]
390    fn serialization_error_from_conversion() {
391        let serde_err = serde_json::from_str::<String>("not json").unwrap_err();
392        let engine_err = EngineError::from(serde_err);
393        assert!(engine_err.to_string().contains("serialization error"));
394    }
395
396    #[test]
397    fn workflow_guard_rejected_display_carries_code_and_detail() {
398        use crate::guard::WorkflowRejection;
399
400        let rejection = WorkflowRejection::MaxDepthExceeded { depth: 6, max: 5 };
401        let err = EngineError::from(rejection);
402
403        let msg = err.to_string();
404        assert!(msg.contains(WORKFLOW_GUARD_REJECTED_CODE));
405        assert!(msg.contains("max call depth exceeded"));
406        assert!(msg.contains("6/5"));
407    }
408
409    #[test]
410    fn workflow_guard_rejected_from_conversion() {
411        use crate::guard::WorkflowRejection;
412
413        let rejection = WorkflowRejection::CycleDetected {
414            target: "wf-b".to_string(),
415            chain: vec!["wf-a".to_string(), "wf-b".to_string()],
416        };
417        let engine_err = EngineError::from(rejection);
418        assert!(engine_err.to_string().contains("cycle detected"));
419    }
420
421    #[test]
422    fn replay_divergence_display_carries_position_and_identities() {
423        let err = EngineError::ReplayDivergence {
424            position: 5,
425            expected: "resolve-base-branch (Shell)".to_string(),
426            recorded: "create-worktree (Shell)".to_string(),
427        };
428
429        let msg = err.to_string();
430        assert!(msg.contains("divergence"));
431        assert!(msg.contains("position 5"));
432        assert!(msg.contains("resolve-base-branch"));
433        assert!(msg.contains("create-worktree"));
434    }
435
436    #[test]
437    fn handler_version_mismatch_display_carries_code_and_versions() {
438        let err = EngineError::HandlerVersionMismatch {
439            run_id: uuid::Uuid::nil(),
440            workflow_name: "deploy".to_string(),
441            run_version: "1.0.0".to_string(),
442            current_version: "2.0.0".to_string(),
443        };
444
445        let msg = err.to_string();
446        assert!(msg.contains(HANDLER_VERSION_MISMATCH_CODE));
447        assert!(msg.contains("1.0.0"));
448        assert!(msg.contains("2.0.0"));
449    }
450}