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
10/// Business error code carried by [`EngineError::RunBudgetExceeded`].
11pub const RUN_BUDGET_EXCEEDED_CODE: &str = "RUN_BUDGET_EXCEEDED";
12
13/// Business error code carried by [`EngineError::MonthlyBudgetExceeded`].
14pub const MONTHLY_BUDGET_EXCEEDED_CODE: &str = "MONTHLY_BUDGET_EXCEEDED";
15
16/// Business error code for handler-version mismatch on retry.
17pub const HANDLER_VERSION_MISMATCH_CODE: &str = "HANDLER_VERSION_MISMATCH";
18
19/// Errors produced by the workflow engine.
20#[derive(Debug, Error)]
21pub enum EngineError {
22    /// An operation (Shell, Http, Agent) failed during step execution.
23    #[error("operation failed: {0}")]
24    Operation(#[from] OperationError),
25
26    /// The backing store returned an error.
27    #[error("store error: {0}")]
28    Store(#[from] StoreError),
29
30    /// The workflow definition is invalid.
31    #[error("invalid workflow: {0}")]
32    InvalidWorkflow(String),
33
34    /// A step configuration could not be deserialized for execution.
35    #[error("step config error: {0}")]
36    StepConfig(String),
37
38    /// JSON serialization error.
39    #[error("serialization error: {0}")]
40    Serialization(#[from] serde_json::Error),
41
42    /// The run reached its cumulative cost cap before launching an agent step.
43    ///
44    /// Raised *before* the step is created, so no work and no spend happen.
45    /// The engine transitions the run to
46    /// [`Cancelled`](ironflow_store::entities::RunStatus::Cancelled).
47    #[error(
48        "{RUN_BUDGET_EXCEEDED_CODE}: run {run_id} would exceed its cost cap \
49         (spent {spent_usd} USD + next step {step_budget_usd} USD > cap {limit_usd} USD)"
50    )]
51    RunBudgetExceeded {
52        /// The run that hit its cap.
53        run_id: uuid::Uuid,
54        /// The configured cap, in USD.
55        limit_usd: Decimal,
56        /// Cost already accumulated by this run and its ancestors, in USD.
57        spent_usd: Decimal,
58        /// Declared budget of the step that was about to run, in USD.
59        step_budget_usd: Decimal,
60    },
61
62    /// The global monthly cost quota is exhausted; no new run may be created.
63    ///
64    /// Runs already in flight are never interrupted by this error.
65    #[error(
66        "{MONTHLY_BUDGET_EXCEEDED_CODE}: monthly cost quota exhausted \
67         ({spent_usd} USD spent of {limit_usd} USD)"
68    )]
69    MonthlyBudgetExceeded {
70        /// The configured monthly quota, in USD.
71        limit_usd: Decimal,
72        /// Cost already spent during the current calendar month, in USD.
73        spent_usd: Decimal,
74    },
75
76    /// A step declared an output that produced no file.
77    ///
78    /// Raised only when the step itself succeeded: a declared output that never
79    /// materialised is a broken contract, and failing here beats failing later
80    /// in whichever step tried to consume it.
81    #[error("step {step:?} declared output {pattern:?} but no file matched")]
82    MissingArtifact {
83        /// Name of the step that declared the output.
84        step: String,
85        /// The unmatched pattern.
86        pattern: String,
87    },
88
89    /// A step asked for an artifact that no earlier step produced.
90    #[error("no artifact {name:?} produced by step {step:?} before this point")]
91    ArtifactNotFound {
92        /// Name of the producing step that was searched for.
93        step: String,
94        /// Name of the artifact that was searched for.
95        name: String,
96    },
97
98    /// Artifacts were used but no storage backend is configured.
99    #[error("artifact storage is not configured: {0}")]
100    ArtifactsUnavailable(String),
101
102    /// The artifact storage backend failed.
103    #[error("artifact storage error: {0}")]
104    Artifact(#[from] ArtifactError),
105
106    /// The run requires human approval before continuing.
107    #[error("approval required for run {run_id}, step {step_id}: {message}")]
108    ApprovalRequired {
109        /// The run that is awaiting approval.
110        run_id: uuid::Uuid,
111        /// The approval step that triggered the pause.
112        step_id: uuid::Uuid,
113        /// The approval message.
114        message: String,
115    },
116}
117
118#[cfg(test)]
119mod tests {
120    use super::*;
121
122    #[test]
123    fn invalid_workflow_display() {
124        let err = EngineError::InvalidWorkflow("unknown-handler".to_string());
125        assert!(err.to_string().contains("invalid workflow"));
126        assert!(err.to_string().contains("unknown-handler"));
127    }
128
129    #[test]
130    fn step_config_display() {
131        let err = EngineError::StepConfig("bad shell config".to_string());
132        assert!(err.to_string().contains("step config error"));
133        assert!(err.to_string().contains("bad shell config"));
134    }
135
136    #[test]
137    fn store_error_from_conversion() {
138        let store_err = StoreError::RunNotFound(uuid::Uuid::nil());
139        let engine_err = EngineError::from(store_err);
140        assert!(engine_err.to_string().contains("store error"));
141    }
142
143    #[test]
144    fn run_budget_exceeded_display_carries_code_and_amounts() {
145        let err = EngineError::RunBudgetExceeded {
146            run_id: uuid::Uuid::nil(),
147            limit_usd: Decimal::new(200, 2),
148            spent_usd: Decimal::new(180, 2),
149            step_budget_usd: Decimal::new(50, 2),
150        };
151
152        let msg = err.to_string();
153        assert!(msg.contains(RUN_BUDGET_EXCEEDED_CODE));
154        assert!(msg.contains("2.00"));
155        assert!(msg.contains("1.80"));
156        assert!(msg.contains("0.50"));
157    }
158
159    #[test]
160    fn monthly_budget_exceeded_display_carries_code_and_amounts() {
161        let err = EngineError::MonthlyBudgetExceeded {
162            limit_usd: Decimal::new(10000, 2),
163            spent_usd: Decimal::new(10500, 2),
164        };
165
166        let msg = err.to_string();
167        assert!(msg.contains(MONTHLY_BUDGET_EXCEEDED_CODE));
168        assert!(msg.contains("100.00"));
169        assert!(msg.contains("105.00"));
170    }
171
172    #[test]
173    fn missing_artifact_display_names_the_step_and_pattern() {
174        let err = EngineError::MissingArtifact {
175            step: "build".to_string(),
176            pattern: "target/report.html".to_string(),
177        };
178
179        let msg = err.to_string();
180        assert!(msg.contains("\"build\""));
181        assert!(msg.contains("target/report.html"));
182    }
183
184    #[test]
185    fn artifact_not_found_display_names_the_producer() {
186        let err = EngineError::ArtifactNotFound {
187            step: "build".to_string(),
188            name: "report.html".to_string(),
189        };
190
191        let msg = err.to_string();
192        assert!(msg.contains("\"build\""));
193        assert!(msg.contains("report.html"));
194    }
195
196    #[test]
197    fn artifacts_unavailable_display() {
198        let err = EngineError::ArtifactsUnavailable("no blob store".to_string());
199        assert!(err.to_string().contains("not configured"));
200    }
201
202    #[test]
203    fn artifact_error_from_conversion() {
204        let engine_err = EngineError::from(ArtifactError::NotFound("a/b".to_string()));
205        assert!(engine_err.to_string().contains("artifact storage error"));
206    }
207
208    #[test]
209    fn serialization_error_from_conversion() {
210        let serde_err = serde_json::from_str::<String>("not json").unwrap_err();
211        let engine_err = EngineError::from(serde_err);
212        assert!(engine_err.to_string().contains("serialization error"));
213    }
214}