sayiir_core/error.rs
1//! Error types for sayiir-core.
2
3/// Generic boxed error type used throughout the crate.
4pub type BoxError = Box<dyn std::error::Error + Send + Sync + 'static>;
5
6/// Errors produced when encoding or decoding task inputs/outputs.
7///
8/// These typed errors carry the task ID and expected type, enabling the runtime
9/// (and future "cascade re-execution") to distinguish schema-mismatch failures
10/// from task logic errors.
11#[derive(Debug, thiserror::Error)]
12pub enum CodecError {
13 /// Failed to decode a task's input (or a loop/branch envelope).
14 #[error("Failed to decode input for task '{task_id}' (expected {expected_type}): {source}")]
15 DecodeFailed {
16 /// The task whose input could not be decoded.
17 task_id: String,
18 /// The Rust type name that was expected (via `std::any::type_name`).
19 expected_type: &'static str,
20 /// The underlying deserialization error.
21 source: BoxError,
22 },
23 /// Failed to encode a task's output.
24 #[error("Failed to encode output for task '{task_id}': {source}")]
25 EncodeFailed {
26 /// The task whose output could not be encoded.
27 task_id: String,
28 /// The underlying serialization error.
29 source: BoxError,
30 },
31}
32
33/// Errors produced during workflow construction (builder / hydration).
34#[derive(Debug, Clone, thiserror::Error)]
35pub enum BuildError {
36 /// A duplicate task ID was found during workflow building.
37 #[error("Duplicate task id: '{0}'")]
38 DuplicateTaskId(String),
39
40 /// A referenced task ID was not found in the registry.
41 #[error("Task '{0}' not found in registry")]
42 TaskNotFound(String),
43
44 /// A branch closure produced an empty sub-builder (no steps added).
45 #[error("Branch must have at least one step")]
46 EmptyBranch,
47
48 /// A fork has no branches.
49 #[error("Fork must have at least one branch")]
50 EmptyFork,
51
52 /// One or more declared branch keys have no corresponding `.branch()` call
53 /// and no default branch was provided.
54 #[error("Branch node '{branch_id}': missing branches for keys: {}", missing_keys.join(", "))]
55 MissingBranches {
56 /// The `route` node ID.
57 branch_id: String,
58 /// Keys declared in `BranchKey::all_keys()` with no matching branch.
59 missing_keys: Vec<String>,
60 },
61
62 /// One or more `.branch()` calls use keys not declared in the `BranchKey` enum.
63 #[error("Branch node '{branch_id}': orphan branches for keys: {}", orphan_keys.join(", "))]
64 OrphanBranches {
65 /// The `route` node ID.
66 branch_id: String,
67 /// Keys passed to `.branch()` that are not in `BranchKey::all_keys()`.
68 orphan_keys: Vec<String>,
69 },
70
71 /// A loop's `max_iterations` was set to zero.
72 #[error("Loop '{0}': max_iterations must be at least 1")]
73 InvalidMaxIterations(String),
74
75 /// The workflow has no tasks.
76 #[error("Workflow must have at least one task")]
77 EmptyWorkflow,
78
79 /// A duration value is not finite or is negative.
80 #[error("{0} must be a finite non-negative number")]
81 InvalidDuration(String),
82
83 /// The workflow definition hash doesn't match during hydration.
84 #[error("Workflow definition mismatch: expected hash '{expected}', found '{found}'")]
85 DefinitionMismatch {
86 /// The expected hash (from current workflow).
87 expected: String,
88 /// The hash found in the serialized state.
89 found: String,
90 },
91}
92
93/// A collection of [`BuildError`]s accumulated during workflow construction.
94///
95/// Builder `build()` methods return this type so that all validation errors
96/// can be reported at once rather than failing on the first one.
97#[derive(Debug, Clone)]
98pub struct BuildErrors(Vec<BuildError>);
99
100impl std::fmt::Display for BuildErrors {
101 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
102 if self.0.len() == 1
103 && let Some(single) = self.0.first()
104 {
105 return write!(f, "{single}");
106 }
107 writeln!(f, "{} build errors:", self.0.len())?;
108 for error in &self.0 {
109 writeln!(f, " - {error}")?;
110 }
111 Ok(())
112 }
113}
114
115impl std::error::Error for BuildErrors {}
116
117impl BuildErrors {
118 /// Create an empty error collection.
119 #[must_use]
120 pub fn new() -> Self {
121 Self(Vec::new())
122 }
123
124 /// Append a single error.
125 pub fn push(&mut self, error: BuildError) {
126 self.0.push(error);
127 }
128
129 /// Returns `true` if no errors have been collected.
130 #[must_use]
131 pub fn is_empty(&self) -> bool {
132 self.0.is_empty()
133 }
134
135 /// Returns the number of collected errors.
136 #[must_use]
137 pub fn len(&self) -> usize {
138 self.0.len()
139 }
140
141 /// Iterate over the individual errors.
142 pub fn iter(&self) -> std::slice::Iter<'_, BuildError> {
143 self.0.iter()
144 }
145
146 /// Consume the wrapper and return the inner vector.
147 #[must_use]
148 pub fn into_vec(self) -> Vec<BuildError> {
149 self.0
150 }
151
152 /// Extend with errors from another collection.
153 pub fn extend(&mut self, other: Self) {
154 self.0.extend(other.0);
155 }
156}
157
158impl Default for BuildErrors {
159 fn default() -> Self {
160 Self::new()
161 }
162}
163
164impl From<BuildError> for BuildErrors {
165 fn from(error: BuildError) -> Self {
166 Self(vec![error])
167 }
168}
169
170impl IntoIterator for BuildErrors {
171 type Item = BuildError;
172 type IntoIter = std::vec::IntoIter<BuildError>;
173
174 fn into_iter(self) -> Self::IntoIter {
175 self.0.into_iter()
176 }
177}
178
179impl<'a> IntoIterator for &'a BuildErrors {
180 type Item = &'a BuildError;
181 type IntoIter = std::slice::Iter<'a, BuildError>;
182
183 fn into_iter(self) -> Self::IntoIter {
184 self.0.iter()
185 }
186}
187
188/// Errors produced during workflow execution (runtime).
189#[derive(Debug, Clone, thiserror::Error)]
190pub enum WorkflowError {
191 /// A referenced task ID was not found at runtime.
192 #[error("Task '{0}' not found in registry")]
193 TaskNotFound(String),
194
195 /// The task has no implementation (function body).
196 ///
197 /// Unreachable for pure-Rust workflows (the builder always fills `func`).
198 /// Exists for Node.js/Python bindings which build `func: None` trees and
199 /// rely on `ExternalTaskExecutor` to dispatch to the host language.
200 #[error("Task '{0}' has no implementation")]
201 TaskNotImplemented(String),
202
203 /// The workflow definition hash doesn't match.
204 /// This indicates the serialized state was created with a different workflow definition.
205 #[error("Workflow definition mismatch: expected hash '{expected}', found '{found}'")]
206 DefinitionMismatch {
207 /// The expected hash (from current workflow).
208 expected: String,
209 /// The hash found in the serialized state.
210 found: String,
211 },
212
213 /// The workflow was cancelled.
214 #[error("Workflow cancelled{}", reason.as_ref().map(|r| format!(": {r}")).unwrap_or_default())]
215 Cancelled {
216 /// Optional reason for the cancellation.
217 reason: Option<String>,
218 /// Optional identifier of who cancelled the workflow.
219 cancelled_by: Option<String>,
220 },
221
222 /// The workflow was paused.
223 #[error("Workflow paused{}", reason.as_ref().map(|r| format!(": {r}")).unwrap_or_default())]
224 Paused {
225 /// Optional reason for the pause.
226 reason: Option<String>,
227 /// Optional identifier of who paused the workflow.
228 paused_by: Option<String>,
229 },
230
231 /// A fork has no branches.
232 #[error("Fork must have at least one branch")]
233 EmptyFork,
234
235 /// A task panicked during execution.
236 #[error("Task panicked: {0}")]
237 TaskPanicked(String),
238
239 /// Cannot resume workflow from saved state.
240 #[error("Cannot resume workflow: {0}")]
241 ResumeError(String),
242
243 /// A named branch was not found in the outputs.
244 #[error("Branch '{0}' not found")]
245 BranchNotFound(String),
246
247 /// A routing key did not match any branch in a `route` node.
248 #[error("Branch node '{branch_id}': no branch matches key '{key}'")]
249 BranchKeyNotFound {
250 /// The `route` node ID.
251 branch_id: String,
252 /// The routing key that was produced.
253 key: String,
254 },
255
256 /// The workflow is waiting for a delay to expire.
257 #[error("Workflow waiting until {wake_at}")]
258 Waiting {
259 /// When the delay expires.
260 wake_at: chrono::DateTime<chrono::Utc>,
261 },
262
263 /// Task exceeded its configured timeout duration.
264 ///
265 /// This marks the entire workflow as `Failed`. The task future is actively
266 /// dropped (cancelled mid-flight) via `tokio::select!` in all runners.
267 #[error("Task '{task_id}' timed out after {timeout:?}")]
268 TaskTimedOut {
269 /// The task that timed out.
270 task_id: String,
271 /// The configured timeout duration.
272 timeout: std::time::Duration,
273 },
274
275 /// The workflow is waiting for an external signal.
276 #[error("Workflow awaiting signal '{signal_name}' at node '{signal_id}'")]
277 AwaitingSignal {
278 /// The signal node ID.
279 signal_id: String,
280 /// The named signal being waited on.
281 signal_name: String,
282 /// Optional timeout deadline.
283 wake_at: Option<chrono::DateTime<chrono::Utc>>,
284 },
285
286 /// A loop exceeded its maximum iteration count with `MaxIterationsPolicy::Fail`.
287 #[error("Loop '{loop_id}' exceeded max iterations ({max_iterations})")]
288 MaxIterationsExceeded {
289 /// The loop node ID.
290 loop_id: String,
291 /// The configured maximum.
292 max_iterations: u32,
293 },
294}
295
296impl WorkflowError {
297 /// Create a new `Cancelled` error with no reason or source.
298 #[must_use]
299 pub fn cancelled() -> Self {
300 Self::Cancelled {
301 reason: None,
302 cancelled_by: None,
303 }
304 }
305
306 /// Create a new `Paused` error with no reason or source.
307 #[must_use]
308 pub fn paused() -> Self {
309 Self::Paused {
310 reason: None,
311 paused_by: None,
312 }
313 }
314}