Skip to main content

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}