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("serialization error: {0}")]
42 Serialization(#[from] serde_json::Error),
43
44 #[error(
50 "{RUN_BUDGET_EXCEEDED_CODE}: run {run_id} would exceed its cost cap \
51 (spent {spent_usd} USD + next step {step_budget_usd} USD > cap {limit_usd} USD)"
52 )]
53 RunBudgetExceeded {
54 run_id: uuid::Uuid,
56 limit_usd: Decimal,
58 spent_usd: Decimal,
60 step_budget_usd: Decimal,
62 },
63
64 #[error(
68 "{MONTHLY_BUDGET_EXCEEDED_CODE}: monthly cost quota exhausted \
69 ({spent_usd} USD spent of {limit_usd} USD)"
70 )]
71 MonthlyBudgetExceeded {
72 limit_usd: Decimal,
74 spent_usd: Decimal,
76 },
77
78 #[error("step {step:?} declared output {pattern:?} but no file matched")]
84 MissingArtifact {
85 step: String,
87 pattern: String,
89 },
90
91 #[error("no artifact {name:?} produced by step {step:?} before this point")]
93 ArtifactNotFound {
94 step: String,
96 name: String,
98 },
99
100 #[error("artifact storage is not configured: {0}")]
102 ArtifactsUnavailable(String),
103
104 #[error("artifact storage error: {0}")]
106 Artifact(#[from] ArtifactError),
107
108 #[error("approval required for run {run_id}, step {step_id}: {message}")]
110 ApprovalRequired {
111 run_id: uuid::Uuid,
113 step_id: uuid::Uuid,
115 message: String,
117 },
118
119 #[error("delay sleeping for run {run_id}, step {step_id}: wake at {wake_at}")]
125 DelaySleeping {
126 run_id: uuid::Uuid,
128 step_id: uuid::Uuid,
130 wake_at: chrono::DateTime<chrono::Utc>,
132 },
133
134 #[error("{WORKFLOW_GUARD_REJECTED_CODE}: {0}")]
140 WorkflowGuardRejected(#[from] WorkflowRejection),
141}
142
143#[cfg(test)]
144mod tests {
145 use super::*;
146
147 #[test]
148 fn invalid_workflow_display() {
149 let err = EngineError::InvalidWorkflow("unknown-handler".to_string());
150 assert!(err.to_string().contains("invalid workflow"));
151 assert!(err.to_string().contains("unknown-handler"));
152 }
153
154 #[test]
155 fn step_config_display() {
156 let err = EngineError::StepConfig("bad shell config".to_string());
157 assert!(err.to_string().contains("step config error"));
158 assert!(err.to_string().contains("bad shell config"));
159 }
160
161 #[test]
162 fn store_error_from_conversion() {
163 let store_err = StoreError::RunNotFound(uuid::Uuid::nil());
164 let engine_err = EngineError::from(store_err);
165 assert!(engine_err.to_string().contains("store error"));
166 }
167
168 #[test]
169 fn run_budget_exceeded_display_carries_code_and_amounts() {
170 let err = EngineError::RunBudgetExceeded {
171 run_id: uuid::Uuid::nil(),
172 limit_usd: Decimal::new(200, 2),
173 spent_usd: Decimal::new(180, 2),
174 step_budget_usd: Decimal::new(50, 2),
175 };
176
177 let msg = err.to_string();
178 assert!(msg.contains(RUN_BUDGET_EXCEEDED_CODE));
179 assert!(msg.contains("2.00"));
180 assert!(msg.contains("1.80"));
181 assert!(msg.contains("0.50"));
182 }
183
184 #[test]
185 fn monthly_budget_exceeded_display_carries_code_and_amounts() {
186 let err = EngineError::MonthlyBudgetExceeded {
187 limit_usd: Decimal::new(10000, 2),
188 spent_usd: Decimal::new(10500, 2),
189 };
190
191 let msg = err.to_string();
192 assert!(msg.contains(MONTHLY_BUDGET_EXCEEDED_CODE));
193 assert!(msg.contains("100.00"));
194 assert!(msg.contains("105.00"));
195 }
196
197 #[test]
198 fn missing_artifact_display_names_the_step_and_pattern() {
199 let err = EngineError::MissingArtifact {
200 step: "build".to_string(),
201 pattern: "target/report.html".to_string(),
202 };
203
204 let msg = err.to_string();
205 assert!(msg.contains("\"build\""));
206 assert!(msg.contains("target/report.html"));
207 }
208
209 #[test]
210 fn artifact_not_found_display_names_the_producer() {
211 let err = EngineError::ArtifactNotFound {
212 step: "build".to_string(),
213 name: "report.html".to_string(),
214 };
215
216 let msg = err.to_string();
217 assert!(msg.contains("\"build\""));
218 assert!(msg.contains("report.html"));
219 }
220
221 #[test]
222 fn artifacts_unavailable_display() {
223 let err = EngineError::ArtifactsUnavailable("no blob store".to_string());
224 assert!(err.to_string().contains("not configured"));
225 }
226
227 #[test]
228 fn artifact_error_from_conversion() {
229 let engine_err = EngineError::from(ArtifactError::NotFound("a/b".to_string()));
230 assert!(engine_err.to_string().contains("artifact storage error"));
231 }
232
233 #[test]
234 fn serialization_error_from_conversion() {
235 let serde_err = serde_json::from_str::<String>("not json").unwrap_err();
236 let engine_err = EngineError::from(serde_err);
237 assert!(engine_err.to_string().contains("serialization error"));
238 }
239
240 #[test]
241 fn workflow_guard_rejected_display_carries_code_and_detail() {
242 use crate::guard::WorkflowRejection;
243
244 let rejection = WorkflowRejection::MaxDepthExceeded { depth: 6, max: 5 };
245 let err = EngineError::from(rejection);
246
247 let msg = err.to_string();
248 assert!(msg.contains(WORKFLOW_GUARD_REJECTED_CODE));
249 assert!(msg.contains("max call depth exceeded"));
250 assert!(msg.contains("6/5"));
251 }
252
253 #[test]
254 fn workflow_guard_rejected_from_conversion() {
255 use crate::guard::WorkflowRejection;
256
257 let rejection = WorkflowRejection::CycleDetected {
258 target: "wf-b".to_string(),
259 chain: vec!["wf-a".to_string(), "wf-b".to_string()],
260 };
261 let engine_err = EngineError::from(rejection);
262 assert!(engine_err.to_string().contains("cycle detected"));
263 }
264}