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 step asked for an artifact that no earlier step produced.
107    #[error("no artifact {name:?} produced by step {step:?} before this point")]
108    ArtifactNotFound {
109        /// Name of the producing step that was searched for.
110        step: String,
111        /// Name of the artifact that was searched for.
112        name: String,
113    },
114
115    /// Artifacts were used but no storage backend is configured.
116    #[error("artifact storage is not configured: {0}")]
117    ArtifactsUnavailable(String),
118
119    /// The artifact storage backend failed.
120    #[error("artifact storage error: {0}")]
121    Artifact(#[from] ArtifactError),
122
123    /// The run requires human approval before continuing.
124    #[error("approval required for run {run_id}, step {step_id}: {message}")]
125    ApprovalRequired {
126        /// The run that is awaiting approval.
127        run_id: uuid::Uuid,
128        /// The approval step that triggered the pause.
129        step_id: uuid::Uuid,
130        /// The approval message.
131        message: String,
132    },
133
134    /// An approval gate was rejected instead of granted.
135    ///
136    /// Raised when a [`StepInterceptor`](crate::executor::StepInterceptor) resolves
137    /// the gate with [`ApprovalOutcome::Rejected`](crate::executor::ApprovalOutcome::Rejected).
138    /// The engine fails the run; the rejection is deterministic, so the run is
139    /// never replayed.
140    #[error("approval rejected for run {run_id}, step {step_id}: {reason}")]
141    ApprovalRejected {
142        /// The run that was stopped.
143        run_id: uuid::Uuid,
144        /// The approval step that was rejected.
145        step_id: uuid::Uuid,
146        /// Why the gate was refused.
147        reason: String,
148    },
149
150    /// A delay step suspended the run until the given time.
151    ///
152    /// The engine transitions the run to
153    /// [`Sleeping`](ironflow_store::entities::RunStatus::Sleeping) and sets
154    /// `scheduled_at` so the worker re-queues it automatically.
155    #[error("delay sleeping for run {run_id}, step {step_id}: wake at {wake_at}")]
156    DelaySleeping {
157        /// The run that is sleeping.
158        run_id: uuid::Uuid,
159        /// The delay step.
160        step_id: uuid::Uuid,
161        /// When the run should be woken up.
162        wake_at: chrono::DateTime<chrono::Utc>,
163    },
164
165    /// A workflow invocation was rejected by the [workflow guard](crate::guard).
166    ///
167    /// The run is transitioned to
168    /// [`Cancelled`](ironflow_store::entities::RunStatus::Cancelled) when this
169    /// error is raised.
170    #[error("{WORKFLOW_GUARD_REJECTED_CODE}: {0}")]
171    WorkflowGuardRejected(#[from] WorkflowRejection),
172}
173
174#[cfg(test)]
175mod tests {
176    use super::*;
177
178    #[test]
179    fn invalid_workflow_display() {
180        let err = EngineError::InvalidWorkflow("unknown-handler".to_string());
181        assert!(err.to_string().contains("invalid workflow"));
182        assert!(err.to_string().contains("unknown-handler"));
183    }
184
185    #[test]
186    fn step_config_display() {
187        let err = EngineError::StepConfig("bad shell config".to_string());
188        assert!(err.to_string().contains("step config error"));
189        assert!(err.to_string().contains("bad shell config"));
190    }
191
192    #[test]
193    fn store_error_from_conversion() {
194        let store_err = StoreError::RunNotFound(uuid::Uuid::nil());
195        let engine_err = EngineError::from(store_err);
196        assert!(engine_err.to_string().contains("store error"));
197    }
198
199    #[test]
200    fn run_budget_exceeded_display_carries_code_and_amounts() {
201        let err = EngineError::RunBudgetExceeded {
202            run_id: uuid::Uuid::nil(),
203            limit_usd: Decimal::new(200, 2),
204            spent_usd: Decimal::new(180, 2),
205            step_budget_usd: Decimal::new(50, 2),
206        };
207
208        let msg = err.to_string();
209        assert!(msg.contains(RUN_BUDGET_EXCEEDED_CODE));
210        assert!(msg.contains("2.00"));
211        assert!(msg.contains("1.80"));
212        assert!(msg.contains("0.50"));
213    }
214
215    #[test]
216    fn monthly_budget_exceeded_display_carries_code_and_amounts() {
217        let err = EngineError::MonthlyBudgetExceeded {
218            limit_usd: Decimal::new(10000, 2),
219            spent_usd: Decimal::new(10500, 2),
220        };
221
222        let msg = err.to_string();
223        assert!(msg.contains(MONTHLY_BUDGET_EXCEEDED_CODE));
224        assert!(msg.contains("100.00"));
225        assert!(msg.contains("105.00"));
226    }
227
228    #[test]
229    fn missing_artifact_display_names_the_step_and_pattern() {
230        let err = EngineError::MissingArtifact {
231            step: "build".to_string(),
232            pattern: "target/report.html".to_string(),
233        };
234
235        let msg = err.to_string();
236        assert!(msg.contains("\"build\""));
237        assert!(msg.contains("target/report.html"));
238    }
239
240    #[test]
241    fn artifact_not_found_display_names_the_producer() {
242        let err = EngineError::ArtifactNotFound {
243            step: "build".to_string(),
244            name: "report.html".to_string(),
245        };
246
247        let msg = err.to_string();
248        assert!(msg.contains("\"build\""));
249        assert!(msg.contains("report.html"));
250    }
251
252    #[test]
253    fn artifacts_unavailable_display() {
254        let err = EngineError::ArtifactsUnavailable("no blob store".to_string());
255        assert!(err.to_string().contains("not configured"));
256    }
257
258    #[test]
259    fn artifact_error_from_conversion() {
260        let engine_err = EngineError::from(ArtifactError::NotFound("a/b".to_string()));
261        assert!(engine_err.to_string().contains("artifact storage error"));
262    }
263
264    #[test]
265    fn serialization_error_from_conversion() {
266        let serde_err = serde_json::from_str::<String>("not json").unwrap_err();
267        let engine_err = EngineError::from(serde_err);
268        assert!(engine_err.to_string().contains("serialization error"));
269    }
270
271    #[test]
272    fn workflow_guard_rejected_display_carries_code_and_detail() {
273        use crate::guard::WorkflowRejection;
274
275        let rejection = WorkflowRejection::MaxDepthExceeded { depth: 6, max: 5 };
276        let err = EngineError::from(rejection);
277
278        let msg = err.to_string();
279        assert!(msg.contains(WORKFLOW_GUARD_REJECTED_CODE));
280        assert!(msg.contains("max call depth exceeded"));
281        assert!(msg.contains("6/5"));
282    }
283
284    #[test]
285    fn workflow_guard_rejected_from_conversion() {
286        use crate::guard::WorkflowRejection;
287
288        let rejection = WorkflowRejection::CycleDetected {
289            target: "wf-b".to_string(),
290            chain: vec!["wf-a".to_string(), "wf-b".to_string()],
291        };
292        let engine_err = EngineError::from(rejection);
293        assert!(engine_err.to_string().contains("cycle detected"));
294    }
295}