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