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 #[error(
224 "replay divergence at position {position}: handler called '{expected}' but the run \
225 recorded '{recorded}' (handler changed since the run was suspended?)"
226 )]
227 ReplayDivergence {
228 position: u32,
231 expected: String,
233 recorded: String,
235 },
236
237 #[error(
247 "{HANDLER_VERSION_MISMATCH_CODE}: run {run_id} for handler '{workflow_name}' was created \
248 with version {run_version}, but the handler is now at version {current_version}; \
249 resume refused (no force override for resume)"
250 )]
251 HandlerVersionMismatch {
252 run_id: uuid::Uuid,
254 workflow_name: String,
256 run_version: String,
258 current_version: String,
260 },
261}
262
263#[cfg(test)]
264mod tests {
265 use super::*;
266
267 #[test]
268 fn invalid_workflow_display() {
269 let err = EngineError::InvalidWorkflow("unknown-handler".to_string());
270 assert!(err.to_string().contains("invalid workflow"));
271 assert!(err.to_string().contains("unknown-handler"));
272 }
273
274 #[test]
275 fn step_config_display() {
276 let err = EngineError::StepConfig("bad shell config".to_string());
277 assert!(err.to_string().contains("step config error"));
278 assert!(err.to_string().contains("bad shell config"));
279 }
280
281 #[test]
282 fn human_input_required_display() {
283 let err = EngineError::HumanInputRequired {
284 run_id: uuid::Uuid::nil(),
285 step_id: uuid::Uuid::nil(),
286 message: "Answer the questions".to_string(),
287 };
288 let text = err.to_string();
289 assert!(text.contains("human input required"));
290 assert!(text.contains("Answer the questions"));
291 }
292
293 #[test]
294 fn human_input_rejected_display() {
295 let err = EngineError::HumanInputRejected {
296 run_id: uuid::Uuid::nil(),
297 step_id: uuid::Uuid::nil(),
298 reason: "not relevant".to_string(),
299 };
300 let text = err.to_string();
301 assert!(text.contains("human input rejected"));
302 assert!(text.contains("not relevant"));
303 }
304
305 #[test]
306 fn store_error_from_conversion() {
307 let store_err = StoreError::RunNotFound(uuid::Uuid::nil());
308 let engine_err = EngineError::from(store_err);
309 assert!(engine_err.to_string().contains("store error"));
310 }
311
312 #[test]
313 fn run_budget_exceeded_display_carries_code_and_amounts() {
314 let err = EngineError::RunBudgetExceeded {
315 run_id: uuid::Uuid::nil(),
316 limit_usd: Decimal::new(200, 2),
317 spent_usd: Decimal::new(180, 2),
318 step_budget_usd: Decimal::new(50, 2),
319 };
320
321 let msg = err.to_string();
322 assert!(msg.contains(RUN_BUDGET_EXCEEDED_CODE));
323 assert!(msg.contains("2.00"));
324 assert!(msg.contains("1.80"));
325 assert!(msg.contains("0.50"));
326 }
327
328 #[test]
329 fn monthly_budget_exceeded_display_carries_code_and_amounts() {
330 let err = EngineError::MonthlyBudgetExceeded {
331 limit_usd: Decimal::new(10000, 2),
332 spent_usd: Decimal::new(10500, 2),
333 };
334
335 let msg = err.to_string();
336 assert!(msg.contains(MONTHLY_BUDGET_EXCEEDED_CODE));
337 assert!(msg.contains("100.00"));
338 assert!(msg.contains("105.00"));
339 }
340
341 #[test]
342 fn missing_artifact_display_names_the_step_and_pattern() {
343 let err = EngineError::MissingArtifact {
344 step: "build".to_string(),
345 pattern: "target/report.html".to_string(),
346 };
347
348 let msg = err.to_string();
349 assert!(msg.contains("\"build\""));
350 assert!(msg.contains("target/report.html"));
351 }
352
353 #[test]
354 fn artifact_not_declared_display_names_the_step_and_artifact() {
355 let err = EngineError::ArtifactNotDeclared {
356 step: "build".to_string(),
357 name: "report.htm".to_string(),
358 };
359
360 let msg = err.to_string();
361 assert!(msg.contains("\"build\""));
362 assert!(msg.contains("\"report.htm\""));
363 }
364
365 #[test]
366 fn artifact_not_found_display_names_the_producer() {
367 let err = EngineError::ArtifactNotFound {
368 step: "build".to_string(),
369 name: "report.html".to_string(),
370 };
371
372 let msg = err.to_string();
373 assert!(msg.contains("\"build\""));
374 assert!(msg.contains("report.html"));
375 }
376
377 #[test]
378 fn artifacts_unavailable_display() {
379 let err = EngineError::ArtifactsUnavailable("no blob store".to_string());
380 assert!(err.to_string().contains("not configured"));
381 }
382
383 #[test]
384 fn artifact_error_from_conversion() {
385 let engine_err = EngineError::from(ArtifactError::NotFound("a/b".to_string()));
386 assert!(engine_err.to_string().contains("artifact storage error"));
387 }
388
389 #[test]
390 fn serialization_error_from_conversion() {
391 let serde_err = serde_json::from_str::<String>("not json").unwrap_err();
392 let engine_err = EngineError::from(serde_err);
393 assert!(engine_err.to_string().contains("serialization error"));
394 }
395
396 #[test]
397 fn workflow_guard_rejected_display_carries_code_and_detail() {
398 use crate::guard::WorkflowRejection;
399
400 let rejection = WorkflowRejection::MaxDepthExceeded { depth: 6, max: 5 };
401 let err = EngineError::from(rejection);
402
403 let msg = err.to_string();
404 assert!(msg.contains(WORKFLOW_GUARD_REJECTED_CODE));
405 assert!(msg.contains("max call depth exceeded"));
406 assert!(msg.contains("6/5"));
407 }
408
409 #[test]
410 fn workflow_guard_rejected_from_conversion() {
411 use crate::guard::WorkflowRejection;
412
413 let rejection = WorkflowRejection::CycleDetected {
414 target: "wf-b".to_string(),
415 chain: vec!["wf-a".to_string(), "wf-b".to_string()],
416 };
417 let engine_err = EngineError::from(rejection);
418 assert!(engine_err.to_string().contains("cycle detected"));
419 }
420
421 #[test]
422 fn replay_divergence_display_carries_position_and_identities() {
423 let err = EngineError::ReplayDivergence {
424 position: 5,
425 expected: "resolve-base-branch (Shell)".to_string(),
426 recorded: "create-worktree (Shell)".to_string(),
427 };
428
429 let msg = err.to_string();
430 assert!(msg.contains("divergence"));
431 assert!(msg.contains("position 5"));
432 assert!(msg.contains("resolve-base-branch"));
433 assert!(msg.contains("create-worktree"));
434 }
435
436 #[test]
437 fn handler_version_mismatch_display_carries_code_and_versions() {
438 let err = EngineError::HandlerVersionMismatch {
439 run_id: uuid::Uuid::nil(),
440 workflow_name: "deploy".to_string(),
441 run_version: "1.0.0".to_string(),
442 current_version: "2.0.0".to_string(),
443 };
444
445 let msg = err.to_string();
446 assert!(msg.contains(HANDLER_VERSION_MISMATCH_CODE));
447 assert!(msg.contains("1.0.0"));
448 assert!(msg.contains("2.0.0"));
449 }
450}