1use 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
12pub const RUN_BUDGET_EXCEEDED_CODE: &str = "RUN_BUDGET_EXCEEDED";
14
15pub const MONTHLY_BUDGET_EXCEEDED_CODE: &str = "MONTHLY_BUDGET_EXCEEDED";
17
18pub const HANDLER_VERSION_MISMATCH_CODE: &str = "HANDLER_VERSION_MISMATCH";
20
21#[derive(Debug, Error)]
23pub enum EngineError {
24 #[error("operation failed: {0}")]
26 Operation(#[from] OperationError),
27
28 #[error("store error: {0}")]
30 Store(#[from] StoreError),
31
32 #[error("invalid workflow: {0}")]
34 InvalidWorkflow(String),
35
36 #[error("step config error: {0}")]
38 StepConfig(String),
39
40 #[error("decision error: {0}")]
42 Decision(#[from] ironflow_core::error::DecisionError),
43
44 #[error(
47 "decision step '{step}' requires a decision provider; \
48 wire one with Engine::with_decision_provider(...)"
49 )]
50 NoDecisionProvider {
51 step: String,
53 },
54
55 #[error("serialization error: {0}")]
57 Serialization(#[from] serde_json::Error),
58
59 #[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 run_id: uuid::Uuid,
71 limit_usd: Decimal,
73 spent_usd: Decimal,
75 step_budget_usd: Decimal,
77 },
78
79 #[error(
83 "{MONTHLY_BUDGET_EXCEEDED_CODE}: monthly cost quota exhausted \
84 ({spent_usd} USD spent of {limit_usd} USD)"
85 )]
86 MonthlyBudgetExceeded {
87 limit_usd: Decimal,
89 spent_usd: Decimal,
91 },
92
93 #[error("step {step:?} declared output {pattern:?} but no file matched")]
99 MissingArtifact {
100 step: String,
102 pattern: String,
104 },
105
106 #[error("no artifact {name:?} produced by step {step:?} before this point")]
108 ArtifactNotFound {
109 step: String,
111 name: String,
113 },
114
115 #[error("artifact storage is not configured: {0}")]
117 ArtifactsUnavailable(String),
118
119 #[error("artifact storage error: {0}")]
121 Artifact(#[from] ArtifactError),
122
123 #[error("approval required for run {run_id}, step {step_id}: {message}")]
125 ApprovalRequired {
126 run_id: uuid::Uuid,
128 step_id: uuid::Uuid,
130 message: String,
132 },
133
134 #[error("approval rejected for run {run_id}, step {step_id}: {reason}")]
141 ApprovalRejected {
142 run_id: uuid::Uuid,
144 step_id: uuid::Uuid,
146 reason: String,
148 },
149
150 #[error("delay sleeping for run {run_id}, step {step_id}: wake at {wake_at}")]
156 DelaySleeping {
157 run_id: uuid::Uuid,
159 step_id: uuid::Uuid,
161 wake_at: chrono::DateTime<chrono::Utc>,
163 },
164
165 #[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}