Skip to main content

ironflow_engine/
error.rs

1//! Engine error types.
2
3use rust_decimal::Decimal;
4use thiserror::Error;
5
6use ironflow_core::error::OperationError;
7use ironflow_store::error::StoreError;
8
9/// Business error code carried by [`EngineError::RunBudgetExceeded`].
10pub const RUN_BUDGET_EXCEEDED_CODE: &str = "RUN_BUDGET_EXCEEDED";
11
12/// Business error code carried by [`EngineError::MonthlyBudgetExceeded`].
13pub const MONTHLY_BUDGET_EXCEEDED_CODE: &str = "MONTHLY_BUDGET_EXCEEDED";
14
15/// Errors produced by the workflow engine.
16#[derive(Debug, Error)]
17pub enum EngineError {
18    /// An operation (Shell, Http, Agent) failed during step execution.
19    #[error("operation failed: {0}")]
20    Operation(#[from] OperationError),
21
22    /// The backing store returned an error.
23    #[error("store error: {0}")]
24    Store(#[from] StoreError),
25
26    /// The workflow definition is invalid.
27    #[error("invalid workflow: {0}")]
28    InvalidWorkflow(String),
29
30    /// A step configuration could not be deserialized for execution.
31    #[error("step config error: {0}")]
32    StepConfig(String),
33
34    /// JSON serialization error.
35    #[error("serialization error: {0}")]
36    Serialization(#[from] serde_json::Error),
37
38    /// The run reached its cumulative cost cap before launching an agent step.
39    ///
40    /// Raised *before* the step is created, so no work and no spend happen.
41    /// The engine transitions the run to
42    /// [`Cancelled`](ironflow_store::entities::RunStatus::Cancelled).
43    #[error(
44        "{RUN_BUDGET_EXCEEDED_CODE}: run {run_id} would exceed its cost cap \
45         (spent {spent_usd} USD + next step {step_budget_usd} USD > cap {limit_usd} USD)"
46    )]
47    RunBudgetExceeded {
48        /// The run that hit its cap.
49        run_id: uuid::Uuid,
50        /// The configured cap, in USD.
51        limit_usd: Decimal,
52        /// Cost already accumulated by this run and its ancestors, in USD.
53        spent_usd: Decimal,
54        /// Declared budget of the step that was about to run, in USD.
55        step_budget_usd: Decimal,
56    },
57
58    /// The global monthly cost quota is exhausted; no new run may be created.
59    ///
60    /// Runs already in flight are never interrupted by this error.
61    #[error(
62        "{MONTHLY_BUDGET_EXCEEDED_CODE}: monthly cost quota exhausted \
63         ({spent_usd} USD spent of {limit_usd} USD)"
64    )]
65    MonthlyBudgetExceeded {
66        /// The configured monthly quota, in USD.
67        limit_usd: Decimal,
68        /// Cost already spent during the current calendar month, in USD.
69        spent_usd: Decimal,
70    },
71
72    /// The run requires human approval before continuing.
73    #[error("approval required for run {run_id}, step {step_id}: {message}")]
74    ApprovalRequired {
75        /// The run that is awaiting approval.
76        run_id: uuid::Uuid,
77        /// The approval step that triggered the pause.
78        step_id: uuid::Uuid,
79        /// The approval message.
80        message: String,
81    },
82}
83
84#[cfg(test)]
85mod tests {
86    use super::*;
87
88    #[test]
89    fn invalid_workflow_display() {
90        let err = EngineError::InvalidWorkflow("unknown-handler".to_string());
91        assert!(err.to_string().contains("invalid workflow"));
92        assert!(err.to_string().contains("unknown-handler"));
93    }
94
95    #[test]
96    fn step_config_display() {
97        let err = EngineError::StepConfig("bad shell config".to_string());
98        assert!(err.to_string().contains("step config error"));
99        assert!(err.to_string().contains("bad shell config"));
100    }
101
102    #[test]
103    fn store_error_from_conversion() {
104        let store_err = StoreError::RunNotFound(uuid::Uuid::nil());
105        let engine_err = EngineError::from(store_err);
106        assert!(engine_err.to_string().contains("store error"));
107    }
108
109    #[test]
110    fn run_budget_exceeded_display_carries_code_and_amounts() {
111        let err = EngineError::RunBudgetExceeded {
112            run_id: uuid::Uuid::nil(),
113            limit_usd: Decimal::new(200, 2),
114            spent_usd: Decimal::new(180, 2),
115            step_budget_usd: Decimal::new(50, 2),
116        };
117
118        let msg = err.to_string();
119        assert!(msg.contains(RUN_BUDGET_EXCEEDED_CODE));
120        assert!(msg.contains("2.00"));
121        assert!(msg.contains("1.80"));
122        assert!(msg.contains("0.50"));
123    }
124
125    #[test]
126    fn monthly_budget_exceeded_display_carries_code_and_amounts() {
127        let err = EngineError::MonthlyBudgetExceeded {
128            limit_usd: Decimal::new(10000, 2),
129            spent_usd: Decimal::new(10500, 2),
130        };
131
132        let msg = err.to_string();
133        assert!(msg.contains(MONTHLY_BUDGET_EXCEEDED_CODE));
134        assert!(msg.contains("100.00"));
135        assert!(msg.contains("105.00"));
136    }
137
138    #[test]
139    fn serialization_error_from_conversion() {
140        let serde_err = serde_json::from_str::<String>("not json").unwrap_err();
141        let engine_err = EngineError::from(serde_err);
142        assert!(engine_err.to_string().contains("serialization error"));
143    }
144}