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("step {step:?} declares no artifact output named {name:?}")]
108 ArtifactNotDeclared {
109 step: String,
111 name: String,
113 },
114
115 #[error("no artifact {name:?} produced by step {step:?} before this point")]
117 ArtifactNotFound {
118 step: String,
120 name: String,
122 },
123
124 #[error("artifact storage is not configured: {0}")]
126 ArtifactsUnavailable(String),
127
128 #[error("artifact storage error: {0}")]
130 Artifact(#[from] ArtifactError),
131
132 #[error("approval required for run {run_id}, step {step_id}: {message}")]
134 ApprovalRequired {
135 run_id: uuid::Uuid,
137 step_id: uuid::Uuid,
139 message: String,
141 },
142
143 #[error("approval rejected for run {run_id}, step {step_id}: {reason}")]
150 ApprovalRejected {
151 run_id: uuid::Uuid,
153 step_id: uuid::Uuid,
155 reason: String,
157 },
158
159 #[error("human input required for run {run_id}, step {step_id}: {message}")]
165 HumanInputRequired {
166 run_id: uuid::Uuid,
168 step_id: uuid::Uuid,
170 message: String,
172 },
173
174 #[error("human input rejected for run {run_id}, step {step_id}: {reason}")]
181 HumanInputRejected {
182 run_id: uuid::Uuid,
184 step_id: uuid::Uuid,
186 reason: String,
188 },
189
190 #[error("delay sleeping for run {run_id}, step {step_id}: wake at {wake_at}")]
196 DelaySleeping {
197 run_id: uuid::Uuid,
199 step_id: uuid::Uuid,
201 wake_at: chrono::DateTime<chrono::Utc>,
203 },
204
205 #[error("{WORKFLOW_GUARD_REJECTED_CODE}: {0}")]
211 WorkflowGuardRejected(#[from] WorkflowRejection),
212}
213
214#[cfg(test)]
215mod tests {
216 use super::*;
217
218 #[test]
219 fn invalid_workflow_display() {
220 let err = EngineError::InvalidWorkflow("unknown-handler".to_string());
221 assert!(err.to_string().contains("invalid workflow"));
222 assert!(err.to_string().contains("unknown-handler"));
223 }
224
225 #[test]
226 fn step_config_display() {
227 let err = EngineError::StepConfig("bad shell config".to_string());
228 assert!(err.to_string().contains("step config error"));
229 assert!(err.to_string().contains("bad shell config"));
230 }
231
232 #[test]
233 fn human_input_required_display() {
234 let err = EngineError::HumanInputRequired {
235 run_id: uuid::Uuid::nil(),
236 step_id: uuid::Uuid::nil(),
237 message: "Answer the questions".to_string(),
238 };
239 let text = err.to_string();
240 assert!(text.contains("human input required"));
241 assert!(text.contains("Answer the questions"));
242 }
243
244 #[test]
245 fn human_input_rejected_display() {
246 let err = EngineError::HumanInputRejected {
247 run_id: uuid::Uuid::nil(),
248 step_id: uuid::Uuid::nil(),
249 reason: "not relevant".to_string(),
250 };
251 let text = err.to_string();
252 assert!(text.contains("human input rejected"));
253 assert!(text.contains("not relevant"));
254 }
255
256 #[test]
257 fn store_error_from_conversion() {
258 let store_err = StoreError::RunNotFound(uuid::Uuid::nil());
259 let engine_err = EngineError::from(store_err);
260 assert!(engine_err.to_string().contains("store error"));
261 }
262
263 #[test]
264 fn run_budget_exceeded_display_carries_code_and_amounts() {
265 let err = EngineError::RunBudgetExceeded {
266 run_id: uuid::Uuid::nil(),
267 limit_usd: Decimal::new(200, 2),
268 spent_usd: Decimal::new(180, 2),
269 step_budget_usd: Decimal::new(50, 2),
270 };
271
272 let msg = err.to_string();
273 assert!(msg.contains(RUN_BUDGET_EXCEEDED_CODE));
274 assert!(msg.contains("2.00"));
275 assert!(msg.contains("1.80"));
276 assert!(msg.contains("0.50"));
277 }
278
279 #[test]
280 fn monthly_budget_exceeded_display_carries_code_and_amounts() {
281 let err = EngineError::MonthlyBudgetExceeded {
282 limit_usd: Decimal::new(10000, 2),
283 spent_usd: Decimal::new(10500, 2),
284 };
285
286 let msg = err.to_string();
287 assert!(msg.contains(MONTHLY_BUDGET_EXCEEDED_CODE));
288 assert!(msg.contains("100.00"));
289 assert!(msg.contains("105.00"));
290 }
291
292 #[test]
293 fn missing_artifact_display_names_the_step_and_pattern() {
294 let err = EngineError::MissingArtifact {
295 step: "build".to_string(),
296 pattern: "target/report.html".to_string(),
297 };
298
299 let msg = err.to_string();
300 assert!(msg.contains("\"build\""));
301 assert!(msg.contains("target/report.html"));
302 }
303
304 #[test]
305 fn artifact_not_declared_display_names_the_step_and_artifact() {
306 let err = EngineError::ArtifactNotDeclared {
307 step: "build".to_string(),
308 name: "report.htm".to_string(),
309 };
310
311 let msg = err.to_string();
312 assert!(msg.contains("\"build\""));
313 assert!(msg.contains("\"report.htm\""));
314 }
315
316 #[test]
317 fn artifact_not_found_display_names_the_producer() {
318 let err = EngineError::ArtifactNotFound {
319 step: "build".to_string(),
320 name: "report.html".to_string(),
321 };
322
323 let msg = err.to_string();
324 assert!(msg.contains("\"build\""));
325 assert!(msg.contains("report.html"));
326 }
327
328 #[test]
329 fn artifacts_unavailable_display() {
330 let err = EngineError::ArtifactsUnavailable("no blob store".to_string());
331 assert!(err.to_string().contains("not configured"));
332 }
333
334 #[test]
335 fn artifact_error_from_conversion() {
336 let engine_err = EngineError::from(ArtifactError::NotFound("a/b".to_string()));
337 assert!(engine_err.to_string().contains("artifact storage error"));
338 }
339
340 #[test]
341 fn serialization_error_from_conversion() {
342 let serde_err = serde_json::from_str::<String>("not json").unwrap_err();
343 let engine_err = EngineError::from(serde_err);
344 assert!(engine_err.to_string().contains("serialization error"));
345 }
346
347 #[test]
348 fn workflow_guard_rejected_display_carries_code_and_detail() {
349 use crate::guard::WorkflowRejection;
350
351 let rejection = WorkflowRejection::MaxDepthExceeded { depth: 6, max: 5 };
352 let err = EngineError::from(rejection);
353
354 let msg = err.to_string();
355 assert!(msg.contains(WORKFLOW_GUARD_REJECTED_CODE));
356 assert!(msg.contains("max call depth exceeded"));
357 assert!(msg.contains("6/5"));
358 }
359
360 #[test]
361 fn workflow_guard_rejected_from_conversion() {
362 use crate::guard::WorkflowRejection;
363
364 let rejection = WorkflowRejection::CycleDetected {
365 target: "wf-b".to_string(),
366 chain: vec!["wf-a".to_string(), "wf-b".to_string()],
367 };
368 let engine_err = EngineError::from(rejection);
369 assert!(engine_err.to_string().contains("cycle detected"));
370 }
371}