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;
11use ironflow_store::models::RunStatus;
12
13use crate::guard::{WORKFLOW_GUARD_REJECTED_CODE, WorkflowRejection};
14
15pub const RUN_BUDGET_EXCEEDED_CODE: &str = "RUN_BUDGET_EXCEEDED";
17
18pub const MONTHLY_BUDGET_EXCEEDED_CODE: &str = "MONTHLY_BUDGET_EXCEEDED";
20
21pub const HANDLER_VERSION_MISMATCH_CODE: &str = "HANDLER_VERSION_MISMATCH";
23
24#[derive(Debug, Error)]
26pub enum EngineError {
27 #[error("operation failed: {0}")]
29 Operation(#[from] OperationError),
30
31 #[error("store error: {0}")]
33 Store(#[from] StoreError),
34
35 #[error("invalid workflow: {0}")]
37 InvalidWorkflow(String),
38
39 #[error("step config error: {0}")]
41 StepConfig(String),
42
43 #[error("decision error: {0}")]
45 Decision(#[from] ironflow_core::error::DecisionError),
46
47 #[error(
50 "decision step '{step}' requires a decision provider; \
51 wire one with Engine::with_decision_provider(...)"
52 )]
53 NoDecisionProvider {
54 step: String,
56 },
57
58 #[error("serialization error: {0}")]
60 Serialization(#[from] serde_json::Error),
61
62 #[error(
68 "{RUN_BUDGET_EXCEEDED_CODE}: run {run_id} would exceed its cost cap \
69 (spent {spent_usd} USD + next step {step_budget_usd} USD > cap {limit_usd} USD)"
70 )]
71 RunBudgetExceeded {
72 run_id: uuid::Uuid,
74 limit_usd: Decimal,
76 spent_usd: Decimal,
78 step_budget_usd: Decimal,
80 },
81
82 #[error(
86 "{MONTHLY_BUDGET_EXCEEDED_CODE}: monthly cost quota exhausted \
87 ({spent_usd} USD spent of {limit_usd} USD)"
88 )]
89 MonthlyBudgetExceeded {
90 limit_usd: Decimal,
92 spent_usd: Decimal,
94 },
95
96 #[error("step {step:?} declared output {pattern:?} but no file matched")]
102 MissingArtifact {
103 step: String,
105 pattern: String,
107 },
108
109 #[error("step {step:?} declares no artifact output named {name:?}")]
111 ArtifactNotDeclared {
112 step: String,
114 name: String,
116 },
117
118 #[error("no artifact {name:?} produced by step {step:?} before this point")]
120 ArtifactNotFound {
121 step: String,
123 name: String,
125 },
126
127 #[error("artifact storage is not configured: {0}")]
129 ArtifactsUnavailable(String),
130
131 #[error("artifact storage error: {0}")]
133 Artifact(#[from] ArtifactError),
134
135 #[error("approval required for run {run_id}, step {step_id}: {message}")]
137 ApprovalRequired {
138 run_id: uuid::Uuid,
140 step_id: uuid::Uuid,
142 message: String,
144 },
145
146 #[error("approval rejected for run {run_id}, step {step_id}: {reason}")]
153 ApprovalRejected {
154 run_id: uuid::Uuid,
156 step_id: uuid::Uuid,
158 reason: String,
160 },
161
162 #[error("human input required for run {run_id}, step {step_id}: {message}")]
168 HumanInputRequired {
169 run_id: uuid::Uuid,
171 step_id: uuid::Uuid,
173 message: String,
175 },
176
177 #[error("human input rejected for run {run_id}, step {step_id}: {reason}")]
184 HumanInputRejected {
185 run_id: uuid::Uuid,
187 step_id: uuid::Uuid,
189 reason: String,
191 },
192
193 #[error("delay sleeping for run {run_id}, step {step_id}: wake at {wake_at}")]
199 DelaySleeping {
200 run_id: uuid::Uuid,
202 step_id: uuid::Uuid,
204 wake_at: chrono::DateTime<chrono::Utc>,
206 },
207
208 #[error("run {run_id} waiting for signal {name:?} with key {key:?} until {deadline_at}")]
216 SignalWaiting {
217 run_id: Uuid,
219 step_id: Uuid,
221 step_name: String,
223 name: String,
225 key: String,
227 deadline_at: DateTime<Utc>,
229 },
230
231 #[error("child run {run_id} suspended: {cause}")]
257 ChildSuspended {
258 run_id: Uuid,
260 cause: Box<EngineError>,
267 },
268
269 #[error("invalid signal: {0}")]
272 InvalidSignal(String),
273
274 #[error("{WORKFLOW_GUARD_REJECTED_CODE}: {0}")]
280 WorkflowGuardRejected(#[from] WorkflowRejection),
281
282 #[error(
293 "replay divergence at position {position}: handler called '{expected}' but the run \
294 recorded '{recorded}' (handler changed since the run was suspended?)"
295 )]
296 ReplayDivergence {
297 position: u32,
300 expected: String,
302 recorded: String,
304 },
305
306 #[error(
316 "{HANDLER_VERSION_MISMATCH_CODE}: run {run_id} for handler '{workflow_name}' was created \
317 with version {run_version}, but the handler is now at version {current_version}; \
318 resume refused (no force override for resume)"
319 )]
320 HandlerVersionMismatch {
321 run_id: uuid::Uuid,
323 workflow_name: String,
325 run_version: String,
327 current_version: String,
329 },
330}
331
332impl EngineError {
333 pub fn is_suspension(&self) -> bool {
349 matches!(
350 self,
351 EngineError::ApprovalRequired { .. }
352 | EngineError::HumanInputRequired { .. }
353 | EngineError::DelaySleeping { .. }
354 | EngineError::SignalWaiting { .. }
355 | EngineError::ChildSuspended { .. }
356 )
357 }
358
359 pub fn suspension_leaf(&self) -> &EngineError {
381 let mut current = self;
382 while let EngineError::ChildSuspended { cause, .. } = current {
383 current = cause;
384 }
385 current
386 }
387
388 pub(crate) fn suspension_status(&self) -> RunStatus {
392 match self.suspension_leaf() {
393 EngineError::DelaySleeping { .. } | EngineError::SignalWaiting { .. } => {
394 RunStatus::Sleeping
395 }
396 _ => RunStatus::AwaitingApproval,
397 }
398 }
399}
400
401#[cfg(test)]
402mod tests {
403 use super::*;
404
405 #[test]
406 fn invalid_workflow_display() {
407 let err = EngineError::InvalidWorkflow("unknown-handler".to_string());
408 assert!(err.to_string().contains("invalid workflow"));
409 assert!(err.to_string().contains("unknown-handler"));
410 }
411
412 #[test]
413 fn step_config_display() {
414 let err = EngineError::StepConfig("bad shell config".to_string());
415 assert!(err.to_string().contains("step config error"));
416 assert!(err.to_string().contains("bad shell config"));
417 }
418
419 #[test]
420 fn human_input_required_display() {
421 let err = EngineError::HumanInputRequired {
422 run_id: uuid::Uuid::nil(),
423 step_id: uuid::Uuid::nil(),
424 message: "Answer the questions".to_string(),
425 };
426 let text = err.to_string();
427 assert!(text.contains("human input required"));
428 assert!(text.contains("Answer the questions"));
429 }
430
431 #[test]
432 fn human_input_rejected_display() {
433 let err = EngineError::HumanInputRejected {
434 run_id: uuid::Uuid::nil(),
435 step_id: uuid::Uuid::nil(),
436 reason: "not relevant".to_string(),
437 };
438 let text = err.to_string();
439 assert!(text.contains("human input rejected"));
440 assert!(text.contains("not relevant"));
441 }
442
443 #[test]
444 fn store_error_from_conversion() {
445 let store_err = StoreError::RunNotFound(uuid::Uuid::nil());
446 let engine_err = EngineError::from(store_err);
447 assert!(engine_err.to_string().contains("store error"));
448 }
449
450 #[test]
451 fn run_budget_exceeded_display_carries_code_and_amounts() {
452 let err = EngineError::RunBudgetExceeded {
453 run_id: uuid::Uuid::nil(),
454 limit_usd: Decimal::new(200, 2),
455 spent_usd: Decimal::new(180, 2),
456 step_budget_usd: Decimal::new(50, 2),
457 };
458
459 let msg = err.to_string();
460 assert!(msg.contains(RUN_BUDGET_EXCEEDED_CODE));
461 assert!(msg.contains("2.00"));
462 assert!(msg.contains("1.80"));
463 assert!(msg.contains("0.50"));
464 }
465
466 #[test]
467 fn monthly_budget_exceeded_display_carries_code_and_amounts() {
468 let err = EngineError::MonthlyBudgetExceeded {
469 limit_usd: Decimal::new(10000, 2),
470 spent_usd: Decimal::new(10500, 2),
471 };
472
473 let msg = err.to_string();
474 assert!(msg.contains(MONTHLY_BUDGET_EXCEEDED_CODE));
475 assert!(msg.contains("100.00"));
476 assert!(msg.contains("105.00"));
477 }
478
479 #[test]
480 fn missing_artifact_display_names_the_step_and_pattern() {
481 let err = EngineError::MissingArtifact {
482 step: "build".to_string(),
483 pattern: "target/report.html".to_string(),
484 };
485
486 let msg = err.to_string();
487 assert!(msg.contains("\"build\""));
488 assert!(msg.contains("target/report.html"));
489 }
490
491 #[test]
492 fn artifact_not_declared_display_names_the_step_and_artifact() {
493 let err = EngineError::ArtifactNotDeclared {
494 step: "build".to_string(),
495 name: "report.htm".to_string(),
496 };
497
498 let msg = err.to_string();
499 assert!(msg.contains("\"build\""));
500 assert!(msg.contains("\"report.htm\""));
501 }
502
503 #[test]
504 fn artifact_not_found_display_names_the_producer() {
505 let err = EngineError::ArtifactNotFound {
506 step: "build".to_string(),
507 name: "report.html".to_string(),
508 };
509
510 let msg = err.to_string();
511 assert!(msg.contains("\"build\""));
512 assert!(msg.contains("report.html"));
513 }
514
515 #[test]
516 fn artifacts_unavailable_display() {
517 let err = EngineError::ArtifactsUnavailable("no blob store".to_string());
518 assert!(err.to_string().contains("not configured"));
519 }
520
521 #[test]
522 fn artifact_error_from_conversion() {
523 let engine_err = EngineError::from(ArtifactError::NotFound("a/b".to_string()));
524 assert!(engine_err.to_string().contains("artifact storage error"));
525 }
526
527 #[test]
528 fn serialization_error_from_conversion() {
529 let serde_err = serde_json::from_str::<String>("not json").unwrap_err();
530 let engine_err = EngineError::from(serde_err);
531 assert!(engine_err.to_string().contains("serialization error"));
532 }
533
534 #[test]
535 fn workflow_guard_rejected_display_carries_code_and_detail() {
536 use crate::guard::WorkflowRejection;
537
538 let rejection = WorkflowRejection::MaxDepthExceeded { depth: 6, max: 5 };
539 let err = EngineError::from(rejection);
540
541 let msg = err.to_string();
542 assert!(msg.contains(WORKFLOW_GUARD_REJECTED_CODE));
543 assert!(msg.contains("max call depth exceeded"));
544 assert!(msg.contains("6/5"));
545 }
546
547 #[test]
548 fn workflow_guard_rejected_from_conversion() {
549 use crate::guard::WorkflowRejection;
550
551 let rejection = WorkflowRejection::CycleDetected {
552 target: "wf-b".to_string(),
553 chain: vec!["wf-a".to_string(), "wf-b".to_string()],
554 };
555 let engine_err = EngineError::from(rejection);
556 assert!(engine_err.to_string().contains("cycle detected"));
557 }
558
559 #[test]
560 fn replay_divergence_display_carries_position_and_identities() {
561 let err = EngineError::ReplayDivergence {
562 position: 5,
563 expected: "resolve-base-branch (Shell)".to_string(),
564 recorded: "create-worktree (Shell)".to_string(),
565 };
566
567 let msg = err.to_string();
568 assert!(msg.contains("divergence"));
569 assert!(msg.contains("position 5"));
570 assert!(msg.contains("resolve-base-branch"));
571 assert!(msg.contains("create-worktree"));
572 }
573
574 #[test]
575 fn handler_version_mismatch_display_carries_code_and_versions() {
576 let err = EngineError::HandlerVersionMismatch {
577 run_id: uuid::Uuid::nil(),
578 workflow_name: "deploy".to_string(),
579 run_version: "1.0.0".to_string(),
580 current_version: "2.0.0".to_string(),
581 };
582
583 let msg = err.to_string();
584 assert!(msg.contains(HANDLER_VERSION_MISMATCH_CODE));
585 assert!(msg.contains("1.0.0"));
586 assert!(msg.contains("2.0.0"));
587 }
588
589 fn human_input_required() -> EngineError {
590 EngineError::HumanInputRequired {
591 run_id: Uuid::nil(),
592 step_id: Uuid::nil(),
593 message: "Answer the questions".to_string(),
594 }
595 }
596
597 #[test]
598 fn child_suspended_display_carries_child_and_cause() {
599 let child = Uuid::now_v7();
600 let err = EngineError::ChildSuspended {
601 run_id: child,
602 cause: Box::new(human_input_required()),
603 };
604
605 let msg = err.to_string();
606 assert!(msg.contains(&child.to_string()));
607 assert!(msg.contains("human input required"));
608 }
609
610 #[test]
611 fn leaf_suspensions_and_child_suspended_are_suspensions() {
612 let wake_at = Utc::now();
613 let suspensions = [
614 EngineError::ApprovalRequired {
615 run_id: Uuid::nil(),
616 step_id: Uuid::nil(),
617 message: "deploy?".to_string(),
618 },
619 human_input_required(),
620 EngineError::DelaySleeping {
621 run_id: Uuid::nil(),
622 step_id: Uuid::nil(),
623 wake_at,
624 },
625 EngineError::SignalWaiting {
626 run_id: Uuid::nil(),
627 step_id: Uuid::nil(),
628 step_name: "wait".to_string(),
629 name: "payment".to_string(),
630 key: "order-1".to_string(),
631 deadline_at: wake_at,
632 },
633 EngineError::ChildSuspended {
634 run_id: Uuid::nil(),
635 cause: Box::new(human_input_required()),
636 },
637 ];
638 for err in &suspensions {
639 assert!(err.is_suspension(), "{err} should be a suspension");
640 }
641 }
642
643 #[test]
644 fn failures_and_rejections_are_not_suspensions() {
645 let failures = [
646 EngineError::InvalidWorkflow("x".to_string()),
647 EngineError::HumanInputRejected {
648 run_id: Uuid::nil(),
649 step_id: Uuid::nil(),
650 reason: "no".to_string(),
651 },
652 EngineError::ApprovalRejected {
653 run_id: Uuid::nil(),
654 step_id: Uuid::nil(),
655 reason: "no".to_string(),
656 },
657 ];
658 for err in &failures {
659 assert!(!err.is_suspension(), "{err} should not be a suspension");
660 }
661 }
662
663 #[test]
664 fn suspension_leaf_unwraps_nested_child_suspensions() {
665 let err = EngineError::ChildSuspended {
666 run_id: Uuid::now_v7(),
667 cause: Box::new(EngineError::ChildSuspended {
668 run_id: Uuid::now_v7(),
669 cause: Box::new(human_input_required()),
670 }),
671 };
672
673 assert!(matches!(
674 err.suspension_leaf(),
675 EngineError::HumanInputRequired { .. }
676 ));
677 }
678
679 #[test]
680 fn suspension_status_follows_the_leaf() {
681 let human = EngineError::ChildSuspended {
682 run_id: Uuid::nil(),
683 cause: Box::new(human_input_required()),
684 };
685 assert_eq!(human.suspension_status(), RunStatus::AwaitingApproval);
686
687 let delay = EngineError::ChildSuspended {
688 run_id: Uuid::nil(),
689 cause: Box::new(EngineError::DelaySleeping {
690 run_id: Uuid::nil(),
691 step_id: Uuid::nil(),
692 wake_at: Utc::now(),
693 }),
694 };
695 assert_eq!(delay.suspension_status(), RunStatus::Sleeping);
696 }
697
698 #[test]
699 fn suspension_leaf_of_a_plain_error_is_itself() {
700 let err = EngineError::StepConfig("bad".to_string());
701 assert!(matches!(err.suspension_leaf(), EngineError::StepConfig(_)));
702 }
703}