1use chrono::{DateTime, Utc};
4use rust_decimal::Decimal;
5use thiserror::Error;
6use uuid::Uuid;
7
8use ironflow_artifacts::error::ArtifactError;
9use ironflow_core::error::OperationError;
10use ironflow_store::error::StoreError;
11
12use crate::guard::{WORKFLOW_GUARD_REJECTED_CODE, WorkflowRejection};
13
14pub const RUN_BUDGET_EXCEEDED_CODE: &str = "RUN_BUDGET_EXCEEDED";
16
17pub const MONTHLY_BUDGET_EXCEEDED_CODE: &str = "MONTHLY_BUDGET_EXCEEDED";
19
20pub const HANDLER_VERSION_MISMATCH_CODE: &str = "HANDLER_VERSION_MISMATCH";
22
23#[derive(Debug, Error)]
25pub enum EngineError {
26 #[error("operation failed: {0}")]
28 Operation(#[from] OperationError),
29
30 #[error("store error: {0}")]
32 Store(#[from] StoreError),
33
34 #[error("invalid workflow: {0}")]
36 InvalidWorkflow(String),
37
38 #[error("step config error: {0}")]
40 StepConfig(String),
41
42 #[error("decision error: {0}")]
44 Decision(#[from] ironflow_core::error::DecisionError),
45
46 #[error(
49 "decision step '{step}' requires a decision provider; \
50 wire one with Engine::with_decision_provider(...)"
51 )]
52 NoDecisionProvider {
53 step: String,
55 },
56
57 #[error("serialization error: {0}")]
59 Serialization(#[from] serde_json::Error),
60
61 #[error(
67 "{RUN_BUDGET_EXCEEDED_CODE}: run {run_id} would exceed its cost cap \
68 (spent {spent_usd} USD + next step {step_budget_usd} USD > cap {limit_usd} USD)"
69 )]
70 RunBudgetExceeded {
71 run_id: uuid::Uuid,
73 limit_usd: Decimal,
75 spent_usd: Decimal,
77 step_budget_usd: Decimal,
79 },
80
81 #[error(
85 "{MONTHLY_BUDGET_EXCEEDED_CODE}: monthly cost quota exhausted \
86 ({spent_usd} USD spent of {limit_usd} USD)"
87 )]
88 MonthlyBudgetExceeded {
89 limit_usd: Decimal,
91 spent_usd: Decimal,
93 },
94
95 #[error("step {step:?} declared output {pattern:?} but no file matched")]
101 MissingArtifact {
102 step: String,
104 pattern: String,
106 },
107
108 #[error("step {step:?} declares no artifact output named {name:?}")]
110 ArtifactNotDeclared {
111 step: String,
113 name: String,
115 },
116
117 #[error("no artifact {name:?} produced by step {step:?} before this point")]
119 ArtifactNotFound {
120 step: String,
122 name: String,
124 },
125
126 #[error("artifact storage is not configured: {0}")]
128 ArtifactsUnavailable(String),
129
130 #[error("artifact storage error: {0}")]
132 Artifact(#[from] ArtifactError),
133
134 #[error("approval required for run {run_id}, step {step_id}: {message}")]
136 ApprovalRequired {
137 run_id: uuid::Uuid,
139 step_id: uuid::Uuid,
141 message: String,
143 },
144
145 #[error("approval rejected for run {run_id}, step {step_id}: {reason}")]
152 ApprovalRejected {
153 run_id: uuid::Uuid,
155 step_id: uuid::Uuid,
157 reason: String,
159 },
160
161 #[error("human input required for run {run_id}, step {step_id}: {message}")]
167 HumanInputRequired {
168 run_id: uuid::Uuid,
170 step_id: uuid::Uuid,
172 message: String,
174 },
175
176 #[error("human input rejected for run {run_id}, step {step_id}: {reason}")]
183 HumanInputRejected {
184 run_id: uuid::Uuid,
186 step_id: uuid::Uuid,
188 reason: String,
190 },
191
192 #[error("delay sleeping for run {run_id}, step {step_id}: wake at {wake_at}")]
198 DelaySleeping {
199 run_id: uuid::Uuid,
201 step_id: uuid::Uuid,
203 wake_at: chrono::DateTime<chrono::Utc>,
205 },
206
207 #[error("run {run_id} waiting for signal {name:?} with key {key:?} until {deadline_at}")]
215 SignalWaiting {
216 run_id: Uuid,
218 step_id: Uuid,
220 step_name: String,
222 name: String,
224 key: String,
226 deadline_at: DateTime<Utc>,
228 },
229
230 #[error("invalid signal: {0}")]
233 InvalidSignal(String),
234
235 #[error("{WORKFLOW_GUARD_REJECTED_CODE}: {0}")]
241 WorkflowGuardRejected(#[from] WorkflowRejection),
242
243 #[error(
254 "replay divergence at position {position}: handler called '{expected}' but the run \
255 recorded '{recorded}' (handler changed since the run was suspended?)"
256 )]
257 ReplayDivergence {
258 position: u32,
261 expected: String,
263 recorded: String,
265 },
266
267 #[error(
277 "{HANDLER_VERSION_MISMATCH_CODE}: run {run_id} for handler '{workflow_name}' was created \
278 with version {run_version}, but the handler is now at version {current_version}; \
279 resume refused (no force override for resume)"
280 )]
281 HandlerVersionMismatch {
282 run_id: uuid::Uuid,
284 workflow_name: String,
286 run_version: String,
288 current_version: String,
290 },
291}
292
293#[cfg(test)]
294mod tests {
295 use super::*;
296
297 #[test]
298 fn invalid_workflow_display() {
299 let err = EngineError::InvalidWorkflow("unknown-handler".to_string());
300 assert!(err.to_string().contains("invalid workflow"));
301 assert!(err.to_string().contains("unknown-handler"));
302 }
303
304 #[test]
305 fn step_config_display() {
306 let err = EngineError::StepConfig("bad shell config".to_string());
307 assert!(err.to_string().contains("step config error"));
308 assert!(err.to_string().contains("bad shell config"));
309 }
310
311 #[test]
312 fn human_input_required_display() {
313 let err = EngineError::HumanInputRequired {
314 run_id: uuid::Uuid::nil(),
315 step_id: uuid::Uuid::nil(),
316 message: "Answer the questions".to_string(),
317 };
318 let text = err.to_string();
319 assert!(text.contains("human input required"));
320 assert!(text.contains("Answer the questions"));
321 }
322
323 #[test]
324 fn human_input_rejected_display() {
325 let err = EngineError::HumanInputRejected {
326 run_id: uuid::Uuid::nil(),
327 step_id: uuid::Uuid::nil(),
328 reason: "not relevant".to_string(),
329 };
330 let text = err.to_string();
331 assert!(text.contains("human input rejected"));
332 assert!(text.contains("not relevant"));
333 }
334
335 #[test]
336 fn store_error_from_conversion() {
337 let store_err = StoreError::RunNotFound(uuid::Uuid::nil());
338 let engine_err = EngineError::from(store_err);
339 assert!(engine_err.to_string().contains("store error"));
340 }
341
342 #[test]
343 fn run_budget_exceeded_display_carries_code_and_amounts() {
344 let err = EngineError::RunBudgetExceeded {
345 run_id: uuid::Uuid::nil(),
346 limit_usd: Decimal::new(200, 2),
347 spent_usd: Decimal::new(180, 2),
348 step_budget_usd: Decimal::new(50, 2),
349 };
350
351 let msg = err.to_string();
352 assert!(msg.contains(RUN_BUDGET_EXCEEDED_CODE));
353 assert!(msg.contains("2.00"));
354 assert!(msg.contains("1.80"));
355 assert!(msg.contains("0.50"));
356 }
357
358 #[test]
359 fn monthly_budget_exceeded_display_carries_code_and_amounts() {
360 let err = EngineError::MonthlyBudgetExceeded {
361 limit_usd: Decimal::new(10000, 2),
362 spent_usd: Decimal::new(10500, 2),
363 };
364
365 let msg = err.to_string();
366 assert!(msg.contains(MONTHLY_BUDGET_EXCEEDED_CODE));
367 assert!(msg.contains("100.00"));
368 assert!(msg.contains("105.00"));
369 }
370
371 #[test]
372 fn missing_artifact_display_names_the_step_and_pattern() {
373 let err = EngineError::MissingArtifact {
374 step: "build".to_string(),
375 pattern: "target/report.html".to_string(),
376 };
377
378 let msg = err.to_string();
379 assert!(msg.contains("\"build\""));
380 assert!(msg.contains("target/report.html"));
381 }
382
383 #[test]
384 fn artifact_not_declared_display_names_the_step_and_artifact() {
385 let err = EngineError::ArtifactNotDeclared {
386 step: "build".to_string(),
387 name: "report.htm".to_string(),
388 };
389
390 let msg = err.to_string();
391 assert!(msg.contains("\"build\""));
392 assert!(msg.contains("\"report.htm\""));
393 }
394
395 #[test]
396 fn artifact_not_found_display_names_the_producer() {
397 let err = EngineError::ArtifactNotFound {
398 step: "build".to_string(),
399 name: "report.html".to_string(),
400 };
401
402 let msg = err.to_string();
403 assert!(msg.contains("\"build\""));
404 assert!(msg.contains("report.html"));
405 }
406
407 #[test]
408 fn artifacts_unavailable_display() {
409 let err = EngineError::ArtifactsUnavailable("no blob store".to_string());
410 assert!(err.to_string().contains("not configured"));
411 }
412
413 #[test]
414 fn artifact_error_from_conversion() {
415 let engine_err = EngineError::from(ArtifactError::NotFound("a/b".to_string()));
416 assert!(engine_err.to_string().contains("artifact storage error"));
417 }
418
419 #[test]
420 fn serialization_error_from_conversion() {
421 let serde_err = serde_json::from_str::<String>("not json").unwrap_err();
422 let engine_err = EngineError::from(serde_err);
423 assert!(engine_err.to_string().contains("serialization error"));
424 }
425
426 #[test]
427 fn workflow_guard_rejected_display_carries_code_and_detail() {
428 use crate::guard::WorkflowRejection;
429
430 let rejection = WorkflowRejection::MaxDepthExceeded { depth: 6, max: 5 };
431 let err = EngineError::from(rejection);
432
433 let msg = err.to_string();
434 assert!(msg.contains(WORKFLOW_GUARD_REJECTED_CODE));
435 assert!(msg.contains("max call depth exceeded"));
436 assert!(msg.contains("6/5"));
437 }
438
439 #[test]
440 fn workflow_guard_rejected_from_conversion() {
441 use crate::guard::WorkflowRejection;
442
443 let rejection = WorkflowRejection::CycleDetected {
444 target: "wf-b".to_string(),
445 chain: vec!["wf-a".to_string(), "wf-b".to_string()],
446 };
447 let engine_err = EngineError::from(rejection);
448 assert!(engine_err.to_string().contains("cycle detected"));
449 }
450
451 #[test]
452 fn replay_divergence_display_carries_position_and_identities() {
453 let err = EngineError::ReplayDivergence {
454 position: 5,
455 expected: "resolve-base-branch (Shell)".to_string(),
456 recorded: "create-worktree (Shell)".to_string(),
457 };
458
459 let msg = err.to_string();
460 assert!(msg.contains("divergence"));
461 assert!(msg.contains("position 5"));
462 assert!(msg.contains("resolve-base-branch"));
463 assert!(msg.contains("create-worktree"));
464 }
465
466 #[test]
467 fn handler_version_mismatch_display_carries_code_and_versions() {
468 let err = EngineError::HandlerVersionMismatch {
469 run_id: uuid::Uuid::nil(),
470 workflow_name: "deploy".to_string(),
471 run_version: "1.0.0".to_string(),
472 current_version: "2.0.0".to_string(),
473 };
474
475 let msg = err.to_string();
476 assert!(msg.contains(HANDLER_VERSION_MISMATCH_CODE));
477 assert!(msg.contains("1.0.0"));
478 assert!(msg.contains("2.0.0"));
479 }
480}