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 during workflow construction (builder / hydration).
7#[derive(Debug, Clone, thiserror::Error)]
8pub enum BuildError {
9 /// A duplicate task ID was found during workflow building.
10 #[error("Duplicate task id: '{0}'")]
11 DuplicateTaskId(String),
12
13 /// A referenced task ID was not found in the registry.
14 #[error("Task '{0}' not found in registry")]
15 TaskNotFound(String),
16
17 /// A branch closure produced an empty sub-builder (no steps added).
18 #[error("Branch must have at least one step")]
19 EmptyBranch,
20
21 /// A fork has no branches and no join task.
22 #[error("Fork has no branches and no join task")]
23 EmptyFork,
24
25 /// One or more declared branch keys have no corresponding `.branch()` call
26 /// and no default branch was provided.
27 #[error("Branch node '{branch_id}': missing branches for keys: {}", missing_keys.join(", "))]
28 MissingBranches {
29 /// The `route` node ID.
30 branch_id: String,
31 /// Keys declared in `BranchKey::all_keys()` with no matching branch.
32 missing_keys: Vec<String>,
33 },
34
35 /// One or more `.branch()` calls use keys not declared in the `BranchKey` enum.
36 #[error("Branch node '{branch_id}': orphan branches for keys: {}", orphan_keys.join(", "))]
37 OrphanBranches {
38 /// The `route` node ID.
39 branch_id: String,
40 /// Keys passed to `.branch()` that are not in `BranchKey::all_keys()`.
41 orphan_keys: Vec<String>,
42 },
43
44 /// The workflow definition hash doesn't match during hydration.
45 #[error("Workflow definition mismatch: expected hash '{expected}', found '{found}'")]
46 DefinitionMismatch {
47 /// The expected hash (from current workflow).
48 expected: String,
49 /// The hash found in the serialized state.
50 found: String,
51 },
52}
53
54/// Errors produced during workflow execution (runtime).
55#[derive(Debug, Clone, thiserror::Error)]
56pub enum WorkflowError {
57 /// A referenced task ID was not found at runtime.
58 #[error("Task '{0}' not found in registry")]
59 TaskNotFound(String),
60
61 /// The task has no implementation (function body).
62 ///
63 /// Unreachable for pure-Rust workflows (the builder always fills `func`).
64 /// Exists for Node.js/Python bindings which build `func: None` trees and
65 /// rely on `ExternalTaskExecutor` to dispatch to the host language.
66 #[error("Task '{0}' has no implementation")]
67 TaskNotImplemented(String),
68
69 /// The workflow definition hash doesn't match.
70 /// This indicates the serialized state was created with a different workflow definition.
71 #[error("Workflow definition mismatch: expected hash '{expected}', found '{found}'")]
72 DefinitionMismatch {
73 /// The expected hash (from current workflow).
74 expected: String,
75 /// The hash found in the serialized state.
76 found: String,
77 },
78
79 /// The workflow was cancelled.
80 #[error("Workflow cancelled{}", reason.as_ref().map(|r| format!(": {r}")).unwrap_or_default())]
81 Cancelled {
82 /// Optional reason for the cancellation.
83 reason: Option<String>,
84 /// Optional identifier of who cancelled the workflow.
85 cancelled_by: Option<String>,
86 },
87
88 /// The workflow was paused.
89 #[error("Workflow paused{}", reason.as_ref().map(|r| format!(": {r}")).unwrap_or_default())]
90 Paused {
91 /// Optional reason for the pause.
92 reason: Option<String>,
93 /// Optional identifier of who paused the workflow.
94 paused_by: Option<String>,
95 },
96
97 /// A fork has no branches and no join task.
98 #[error("Fork has no branches and no join task")]
99 EmptyFork,
100
101 /// A task panicked during execution.
102 #[error("Task panicked: {0}")]
103 TaskPanicked(String),
104
105 /// Cannot resume workflow from saved state.
106 #[error("Cannot resume workflow: {0}")]
107 ResumeError(String),
108
109 /// Deserialization of binary data failed.
110 #[error("Deserialization error: {0}")]
111 Deserialization(String),
112
113 /// A named branch was not found in the outputs.
114 #[error("Branch '{0}' not found")]
115 BranchNotFound(String),
116
117 /// A routing key did not match any branch in a `route` node.
118 #[error("Branch node '{branch_id}': no branch matches key '{key}'")]
119 BranchKeyNotFound {
120 /// The `route` node ID.
121 branch_id: String,
122 /// The routing key that was produced.
123 key: String,
124 },
125
126 /// The workflow is waiting for a delay to expire.
127 #[error("Workflow waiting until {wake_at}")]
128 Waiting {
129 /// When the delay expires.
130 wake_at: chrono::DateTime<chrono::Utc>,
131 },
132
133 /// Task exceeded its configured timeout duration.
134 ///
135 /// This marks the entire workflow as `Failed`. The task future is actively
136 /// dropped (cancelled mid-flight) via `tokio::select!` in all runners.
137 #[error("Task '{task_id}' timed out after {timeout:?}")]
138 TaskTimedOut {
139 /// The task that timed out.
140 task_id: String,
141 /// The configured timeout duration.
142 timeout: std::time::Duration,
143 },
144
145 /// The workflow is waiting for an external signal.
146 #[error("Workflow awaiting signal '{signal_name}' at node '{signal_id}'")]
147 AwaitingSignal {
148 /// The signal node ID.
149 signal_id: String,
150 /// The named signal being waited on.
151 signal_name: String,
152 /// Optional timeout deadline.
153 wake_at: Option<chrono::DateTime<chrono::Utc>>,
154 },
155
156 /// A buffered signal was consumed during park — execution should continue.
157 ///
158 /// This is an internal sentinel used by `park_at_signal` when a signal is
159 /// already buffered. The executor should re-enter the loop.
160 #[error("Signal consumed (internal)")]
161 SignalConsumed,
162}
163
164impl WorkflowError {
165 /// Create a new `Cancelled` error with no reason or source.
166 #[must_use]
167 pub fn cancelled() -> Self {
168 Self::Cancelled {
169 reason: None,
170 cancelled_by: None,
171 }
172 }
173
174 /// Create a new `Paused` error with no reason or source.
175 #[must_use]
176 pub fn paused() -> Self {
177 Self::Paused {
178 reason: None,
179 paused_by: None,
180 }
181 }
182}