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 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}