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: crate::TaskId,
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: crate::TaskId,
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: crate::DefinitionHash,
88        /// The hash found in the serialized state.
89        found: crate::DefinitionHash,
90    },
91
92    /// A `#[task]` requires a dependency that is missing from the `Deps`
93    /// container passed to `workflow! { deps: … }`.
94    ///
95    /// Emitted by `verify_deps` codegen at workflow construction time, so the
96    /// failure surfaces as a `BuildErrors` rather than panicking at first task
97    /// invocation.
98    #[error("Task '{task_id}': missing dependency `{type_name}` in Deps container")]
99    MissingDep {
100        /// The task that requires the dependency.
101        task_id: &'static str,
102        /// The Rust type name of the missing dependency (via `std::any::type_name`).
103        type_name: &'static str,
104    },
105
106    /// A task auto-registered via `workflow! { deps: … }` is already present in
107    /// the pre-built `TaskRegistry` passed via `workflow! { registry: … }`.
108    ///
109    /// Without this check the duplicate registration would be silently deduped,
110    /// and the resulting task instance would depend on registration order
111    /// rather than the user's expressed intent. Surfacing the conflict forces
112    /// an explicit choice between the two sources.
113    #[error(
114        "Task '{task_id}' is present in both the pre-built `registry:` and would \
115         be auto-registered via `deps:` — drop one to resolve the conflict"
116    )]
117    RegistryDepsConflict {
118        /// The task whose registration source is ambiguous.
119        task_id: &'static str,
120    },
121}
122
123/// A collection of [`BuildError`]s accumulated during workflow construction.
124///
125/// Builder `build()` methods return this type so that all validation errors
126/// can be reported at once rather than failing on the first one.
127#[derive(Debug, Clone)]
128pub struct BuildErrors(Vec<BuildError>);
129
130impl std::fmt::Display for BuildErrors {
131    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
132        if self.0.len() == 1
133            && let Some(single) = self.0.first()
134        {
135            return write!(f, "{single}");
136        }
137        writeln!(f, "{} build errors:", self.0.len())?;
138        for error in &self.0 {
139            writeln!(f, "  - {error}")?;
140        }
141        Ok(())
142    }
143}
144
145impl std::error::Error for BuildErrors {}
146
147impl BuildErrors {
148    /// Create an empty error collection.
149    #[must_use]
150    pub fn new() -> Self {
151        Self(Vec::new())
152    }
153
154    /// Append a single error.
155    pub fn push(&mut self, error: BuildError) {
156        self.0.push(error);
157    }
158
159    /// Returns `true` if no errors have been collected.
160    #[must_use]
161    pub fn is_empty(&self) -> bool {
162        self.0.is_empty()
163    }
164
165    /// Returns the number of collected errors.
166    #[must_use]
167    pub fn len(&self) -> usize {
168        self.0.len()
169    }
170
171    /// Iterate over the individual errors.
172    pub fn iter(&self) -> std::slice::Iter<'_, BuildError> {
173        self.0.iter()
174    }
175
176    /// Consume the wrapper and return the inner vector.
177    #[must_use]
178    pub fn into_vec(self) -> Vec<BuildError> {
179        self.0
180    }
181
182    /// Extend with errors from another collection.
183    pub fn extend(&mut self, other: Self) {
184        self.0.extend(other.0);
185    }
186}
187
188impl Default for BuildErrors {
189    fn default() -> Self {
190        Self::new()
191    }
192}
193
194impl From<BuildError> for BuildErrors {
195    fn from(error: BuildError) -> Self {
196        Self(vec![error])
197    }
198}
199
200impl IntoIterator for BuildErrors {
201    type Item = BuildError;
202    type IntoIter = std::vec::IntoIter<BuildError>;
203
204    fn into_iter(self) -> Self::IntoIter {
205        self.0.into_iter()
206    }
207}
208
209impl<'a> IntoIterator for &'a BuildErrors {
210    type Item = &'a BuildError;
211    type IntoIter = std::slice::Iter<'a, BuildError>;
212
213    fn into_iter(self) -> Self::IntoIter {
214        self.0.iter()
215    }
216}
217
218/// Errors produced during workflow execution (runtime).
219#[derive(Debug, Clone, thiserror::Error)]
220pub enum WorkflowError {
221    /// A referenced task ID was not found at runtime.
222    #[error("Task '{0}' not found in registry")]
223    TaskNotFound(String),
224
225    /// The task has no implementation (function body).
226    ///
227    /// Unreachable for pure-Rust workflows (the builder always fills `func`).
228    /// Exists for Node.js/Python bindings which build `func: None` trees and
229    /// rely on `ExternalTaskExecutor` to dispatch to the host language.
230    #[error("Task '{0}' has no implementation")]
231    TaskNotImplemented(String),
232
233    /// The workflow definition hash doesn't match.
234    /// This indicates the serialized state was created with a different workflow definition.
235    #[error("Workflow definition mismatch: expected hash '{expected}', found '{found}'")]
236    DefinitionMismatch {
237        /// The expected hash (from current workflow).
238        expected: crate::DefinitionHash,
239        /// The hash found in the serialized state.
240        found: crate::DefinitionHash,
241    },
242
243    /// The workflow was cancelled.
244    #[error("Workflow cancelled{}", reason.as_ref().map(|r| format!(": {r}")).unwrap_or_default())]
245    Cancelled {
246        /// Optional reason for the cancellation.
247        reason: Option<String>,
248        /// Optional identifier of who cancelled the workflow.
249        cancelled_by: Option<String>,
250    },
251
252    /// The workflow was paused.
253    #[error("Workflow paused{}", reason.as_ref().map(|r| format!(": {r}")).unwrap_or_default())]
254    Paused {
255        /// Optional reason for the pause.
256        reason: Option<String>,
257        /// Optional identifier of who paused the workflow.
258        paused_by: Option<String>,
259    },
260
261    /// A fork has no branches.
262    #[error("Fork must have at least one branch")]
263    EmptyFork,
264
265    /// A task panicked during execution.
266    #[error("Task panicked: {0}")]
267    TaskPanicked(String),
268
269    /// Cannot resume workflow from saved state.
270    #[error("Cannot resume workflow: {0}")]
271    ResumeError(String),
272
273    /// A named branch was not found in the outputs.
274    #[error("Branch '{0}' not found")]
275    BranchNotFound(String),
276
277    /// A routing key did not match any branch in a `route` node.
278    #[error("Branch node '{branch_id}': no branch matches key '{key}'")]
279    BranchKeyNotFound {
280        /// The `route` node ID.
281        branch_id: String,
282        /// The routing key that was produced.
283        key: String,
284    },
285
286    /// The workflow is waiting for a delay to expire.
287    #[error("Workflow waiting until {wake_at}")]
288    Waiting {
289        /// When the delay expires.
290        wake_at: chrono::DateTime<chrono::Utc>,
291    },
292
293    /// Task exceeded its configured timeout duration.
294    ///
295    /// This marks the entire workflow as `Failed`. The task future is actively
296    /// dropped (cancelled mid-flight) via `tokio::select!` in all runners.
297    #[error("Task '{task_id}' timed out after {timeout:?}")]
298    TaskTimedOut {
299        /// The task that timed out.
300        task_id: crate::TaskId,
301        /// The configured timeout duration.
302        timeout: std::time::Duration,
303    },
304
305    /// The workflow is waiting for an external signal.
306    #[error("Workflow awaiting signal '{signal_name}' at node '{signal_id}'")]
307    AwaitingSignal {
308        /// The signal node ID.
309        signal_id: crate::TaskId,
310        /// The named signal being waited on.
311        signal_name: String,
312        /// Optional timeout deadline.
313        wake_at: Option<chrono::DateTime<chrono::Utc>>,
314    },
315
316    /// A loop exceeded its maximum iteration count with `MaxIterationsPolicy::Fail`.
317    #[error("Loop '{loop_id}' exceeded max iterations ({max_iterations})")]
318    MaxIterationsExceeded {
319        /// The loop node ID.
320        loop_id: crate::TaskId,
321        /// The configured maximum.
322        max_iterations: u32,
323    },
324}
325
326impl WorkflowError {
327    /// Create a new `Cancelled` error with no reason or source.
328    #[must_use]
329    pub fn cancelled() -> Self {
330        Self::Cancelled {
331            reason: None,
332            cancelled_by: None,
333        }
334    }
335
336    /// Create a new `Paused` error with no reason or source.
337    #[must_use]
338    pub fn paused() -> Self {
339        Self::Paused {
340            reason: None,
341            paused_by: None,
342        }
343    }
344}