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    /// JSON serialization error.
41    #[error("serialization error: {0}")]
42    Serialization(#[from] serde_json::Error),
43
44    /// The run reached its cumulative cost cap before launching an agent step.
45    ///
46    /// Raised *before* the step is created, so no work and no spend happen.
47    /// The engine transitions the run to
48    /// [`Cancelled`](ironflow_store::entities::RunStatus::Cancelled).
49    #[error(
50        "{RUN_BUDGET_EXCEEDED_CODE}: run {run_id} would exceed its cost cap \
51         (spent {spent_usd} USD + next step {step_budget_usd} USD > cap {limit_usd} USD)"
52    )]
53    RunBudgetExceeded {
54        /// The run that hit its cap.
55        run_id: uuid::Uuid,
56        /// The configured cap, in USD.
57        limit_usd: Decimal,
58        /// Cost already accumulated by this run and its ancestors, in USD.
59        spent_usd: Decimal,
60        /// Declared budget of the step that was about to run, in USD.
61        step_budget_usd: Decimal,
62    },
63
64    /// The global monthly cost quota is exhausted; no new run may be created.
65    ///
66    /// Runs already in flight are never interrupted by this error.
67    #[error(
68        "{MONTHLY_BUDGET_EXCEEDED_CODE}: monthly cost quota exhausted \
69         ({spent_usd} USD spent of {limit_usd} USD)"
70    )]
71    MonthlyBudgetExceeded {
72        /// The configured monthly quota, in USD.
73        limit_usd: Decimal,
74        /// Cost already spent during the current calendar month, in USD.
75        spent_usd: Decimal,
76    },
77
78    /// A step declared an output that produced no file.
79    ///
80    /// Raised only when the step itself succeeded: a declared output that never
81    /// materialised is a broken contract, and failing here beats failing later
82    /// in whichever step tried to consume it.
83    #[error("step {step:?} declared output {pattern:?} but no file matched")]
84    MissingArtifact {
85        /// Name of the step that declared the output.
86        step: String,
87        /// The unmatched pattern.
88        pattern: String,
89    },
90
91    /// A step asked for an artifact that no earlier step produced.
92    #[error("no artifact {name:?} produced by step {step:?} before this point")]
93    ArtifactNotFound {
94        /// Name of the producing step that was searched for.
95        step: String,
96        /// Name of the artifact that was searched for.
97        name: String,
98    },
99
100    /// Artifacts were used but no storage backend is configured.
101    #[error("artifact storage is not configured: {0}")]
102    ArtifactsUnavailable(String),
103
104    /// The artifact storage backend failed.
105    #[error("artifact storage error: {0}")]
106    Artifact(#[from] ArtifactError),
107
108    /// The run requires human approval before continuing.
109    #[error("approval required for run {run_id}, step {step_id}: {message}")]
110    ApprovalRequired {
111        /// The run that is awaiting approval.
112        run_id: uuid::Uuid,
113        /// The approval step that triggered the pause.
114        step_id: uuid::Uuid,
115        /// The approval message.
116        message: String,
117    },
118
119    /// A delay step suspended the run until the given time.
120    ///
121    /// The engine transitions the run to
122    /// [`Sleeping`](ironflow_store::entities::RunStatus::Sleeping) and sets
123    /// `scheduled_at` so the worker re-queues it automatically.
124    #[error("delay sleeping for run {run_id}, step {step_id}: wake at {wake_at}")]
125    DelaySleeping {
126        /// The run that is sleeping.
127        run_id: uuid::Uuid,
128        /// The delay step.
129        step_id: uuid::Uuid,
130        /// When the run should be woken up.
131        wake_at: chrono::DateTime<chrono::Utc>,
132    },
133
134    /// A workflow invocation was rejected by the [workflow guard](crate::guard).
135    ///
136    /// The run is transitioned to
137    /// [`Cancelled`](ironflow_store::entities::RunStatus::Cancelled) when this
138    /// error is raised.
139    #[error("{WORKFLOW_GUARD_REJECTED_CODE}: {0}")]
140    WorkflowGuardRejected(#[from] WorkflowRejection),
141}
142
143#[cfg(test)]
144mod tests {
145    use super::*;
146
147    #[test]
148    fn invalid_workflow_display() {
149        let err = EngineError::InvalidWorkflow("unknown-handler".to_string());
150        assert!(err.to_string().contains("invalid workflow"));
151        assert!(err.to_string().contains("unknown-handler"));
152    }
153
154    #[test]
155    fn step_config_display() {
156        let err = EngineError::StepConfig("bad shell config".to_string());
157        assert!(err.to_string().contains("step config error"));
158        assert!(err.to_string().contains("bad shell config"));
159    }
160
161    #[test]
162    fn store_error_from_conversion() {
163        let store_err = StoreError::RunNotFound(uuid::Uuid::nil());
164        let engine_err = EngineError::from(store_err);
165        assert!(engine_err.to_string().contains("store error"));
166    }
167
168    #[test]
169    fn run_budget_exceeded_display_carries_code_and_amounts() {
170        let err = EngineError::RunBudgetExceeded {
171            run_id: uuid::Uuid::nil(),
172            limit_usd: Decimal::new(200, 2),
173            spent_usd: Decimal::new(180, 2),
174            step_budget_usd: Decimal::new(50, 2),
175        };
176
177        let msg = err.to_string();
178        assert!(msg.contains(RUN_BUDGET_EXCEEDED_CODE));
179        assert!(msg.contains("2.00"));
180        assert!(msg.contains("1.80"));
181        assert!(msg.contains("0.50"));
182    }
183
184    #[test]
185    fn monthly_budget_exceeded_display_carries_code_and_amounts() {
186        let err = EngineError::MonthlyBudgetExceeded {
187            limit_usd: Decimal::new(10000, 2),
188            spent_usd: Decimal::new(10500, 2),
189        };
190
191        let msg = err.to_string();
192        assert!(msg.contains(MONTHLY_BUDGET_EXCEEDED_CODE));
193        assert!(msg.contains("100.00"));
194        assert!(msg.contains("105.00"));
195    }
196
197    #[test]
198    fn missing_artifact_display_names_the_step_and_pattern() {
199        let err = EngineError::MissingArtifact {
200            step: "build".to_string(),
201            pattern: "target/report.html".to_string(),
202        };
203
204        let msg = err.to_string();
205        assert!(msg.contains("\"build\""));
206        assert!(msg.contains("target/report.html"));
207    }
208
209    #[test]
210    fn artifact_not_found_display_names_the_producer() {
211        let err = EngineError::ArtifactNotFound {
212            step: "build".to_string(),
213            name: "report.html".to_string(),
214        };
215
216        let msg = err.to_string();
217        assert!(msg.contains("\"build\""));
218        assert!(msg.contains("report.html"));
219    }
220
221    #[test]
222    fn artifacts_unavailable_display() {
223        let err = EngineError::ArtifactsUnavailable("no blob store".to_string());
224        assert!(err.to_string().contains("not configured"));
225    }
226
227    #[test]
228    fn artifact_error_from_conversion() {
229        let engine_err = EngineError::from(ArtifactError::NotFound("a/b".to_string()));
230        assert!(engine_err.to_string().contains("artifact storage error"));
231    }
232
233    #[test]
234    fn serialization_error_from_conversion() {
235        let serde_err = serde_json::from_str::<String>("not json").unwrap_err();
236        let engine_err = EngineError::from(serde_err);
237        assert!(engine_err.to_string().contains("serialization error"));
238    }
239
240    #[test]
241    fn workflow_guard_rejected_display_carries_code_and_detail() {
242        use crate::guard::WorkflowRejection;
243
244        let rejection = WorkflowRejection::MaxDepthExceeded { depth: 6, max: 5 };
245        let err = EngineError::from(rejection);
246
247        let msg = err.to_string();
248        assert!(msg.contains(WORKFLOW_GUARD_REJECTED_CODE));
249        assert!(msg.contains("max call depth exceeded"));
250        assert!(msg.contains("6/5"));
251    }
252
253    #[test]
254    fn workflow_guard_rejected_from_conversion() {
255        use crate::guard::WorkflowRejection;
256
257        let rejection = WorkflowRejection::CycleDetected {
258            target: "wf-b".to_string(),
259            chain: vec!["wf-a".to_string(), "wf-b".to_string()],
260        };
261        let engine_err = EngineError::from(rejection);
262        assert!(engine_err.to_string().contains("cycle detected"));
263    }
264}