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}