orcs_component/error.rs
1//! Component layer errors.
2//!
3//! Errors that can occur during component operations.
4//! All errors implement [`ErrorCode`] for unified handling.
5//!
6//! # Error Code Convention
7//!
8//! All component errors use the `COMPONENT_` prefix:
9//!
10//! | Error | Code | Recoverable |
11//! |-------|------|-------------|
12//! | [`NotSupported`](ComponentError::NotSupported) | `COMPONENT_NOT_SUPPORTED` | No |
13//! | [`ExecutionFailed`](ComponentError::ExecutionFailed) | `COMPONENT_EXECUTION_FAILED` | Yes |
14//! | [`InvalidPayload`](ComponentError::InvalidPayload) | `COMPONENT_INVALID_PAYLOAD` | No |
15//! | [`Aborted`](ComponentError::Aborted) | `COMPONENT_ABORTED` | No |
16//! | [`Suspended`](ComponentError::Suspended) | `COMPONENT_SUSPENDED` | Yes |
17//!
18//! # Recoverability
19//!
20//! - **Recoverable**: Retry may succeed (transient failures)
21//! - **Not Recoverable**: Retry won't help (logic errors)
22//!
23//! # Example
24//!
25//! ```
26//! use orcs_component::ComponentError;
27//! use orcs_types::ErrorCode;
28//!
29//! let err = ComponentError::NotSupported("unknown".into());
30//! assert_eq!(err.code(), "COMPONENT_NOT_SUPPORTED");
31//! assert!(!err.is_recoverable());
32//!
33//! let err = ComponentError::ExecutionFailed("timeout".into());
34//! assert_eq!(err.code(), "COMPONENT_EXECUTION_FAILED");
35//! assert!(err.is_recoverable());
36//! ```
37
38use orcs_types::ErrorCode;
39use serde::{Deserialize, Serialize};
40use thiserror::Error;
41
42/// Component layer error.
43///
44/// # Variants
45///
46/// | Variant | When | Recovery |
47/// |---------|------|----------|
48/// | `NotSupported` | Unknown operation | Fix request |
49/// | `ExecutionFailed` | Operation failed | May retry |
50/// | `InvalidPayload` | Bad request data | Fix payload |
51/// | `Aborted` | Signal-triggered abort | Intentional |
52/// | `Suspended` | Awaiting approval | Approval flow |
53///
54/// # Example
55///
56/// ```
57/// use orcs_component::ComponentError;
58/// use orcs_types::ErrorCode;
59///
60/// fn handle_error(err: ComponentError) {
61/// eprintln!("[{}] {}", err.code(), err);
62/// if err.is_recoverable() {
63/// eprintln!("May retry");
64/// }
65/// }
66/// ```
67#[derive(Debug, Clone, Serialize, Deserialize, Error)]
68pub enum ComponentError {
69 /// Operation not supported by this component.
70 ///
71 /// The requested operation is not recognized.
72 /// Check the component's supported operations.
73 ///
74 /// **Not recoverable** - the operation will never work.
75 #[error("operation not supported: {0}")]
76 NotSupported(String),
77
78 /// Execution failed during operation.
79 ///
80 /// The operation was recognized but failed during execution.
81 /// Common causes: timeout, resource unavailable, external service failure.
82 ///
83 /// **Recoverable** - retry may succeed.
84 #[error("execution failed: {0}")]
85 ExecutionFailed(String),
86
87 /// Invalid payload in request.
88 ///
89 /// The request payload doesn't match expected format.
90 /// Check payload structure and required fields.
91 ///
92 /// **Not recoverable** - fix the payload.
93 #[error("invalid payload: {0}")]
94 InvalidPayload(String),
95
96 /// Component was aborted.
97 ///
98 /// The component received an abort signal (Veto/Cancel).
99 /// This is intentional termination, not an error condition.
100 ///
101 /// **Not recoverable** - intentional stop.
102 #[error("component aborted")]
103 Aborted,
104
105 /// Initialization failed.
106 ///
107 /// The component failed to initialize.
108 /// Check configuration and dependencies.
109 ///
110 /// **Recoverable** - may succeed with different config.
111 #[error("initialization failed: {0}")]
112 InitFailed(String),
113
114 /// Execution suspended pending human approval.
115 ///
116 /// Returned by `on_request()` when a command requires permission
117 /// that hasn't been granted yet. The ChannelRunner intercepts this
118 /// error and transitions to `AwaitingApproval` state, allowing the
119 /// event loop to continue processing signals (Approve/Reject).
120 ///
121 /// On approval, the ChannelRunner grants the pattern and re-dispatches
122 /// the pending request. On rejection, the request is discarded.
123 ///
124 /// **Recoverable** - approval resolves this.
125 ///
126 /// # Fields
127 ///
128 /// - `approval_id`: Unique ID for this approval request (matches Signal routing).
129 /// - `grant_pattern`: Permission pattern to grant on approval (e.g. `"shell:*"`).
130 /// - `pending_request`: Serialized original request for re-dispatch after approval.
131 #[error("suspended pending approval: {approval_id}")]
132 Suspended {
133 /// Unique ID for this approval request.
134 approval_id: String,
135 /// Permission pattern to grant on approval.
136 grant_pattern: String,
137 /// Serialized original request for re-dispatch.
138 pending_request: serde_json::Value,
139 },
140}
141
142impl ErrorCode for ComponentError {
143 /// Returns a machine-readable error code.
144 ///
145 /// All component errors use the `COMPONENT_` prefix.
146 fn code(&self) -> &'static str {
147 match self {
148 Self::NotSupported(_) => "COMPONENT_NOT_SUPPORTED",
149 Self::ExecutionFailed(_) => "COMPONENT_EXECUTION_FAILED",
150 Self::InvalidPayload(_) => "COMPONENT_INVALID_PAYLOAD",
151 Self::Aborted => "COMPONENT_ABORTED",
152 Self::InitFailed(_) => "COMPONENT_INIT_FAILED",
153 Self::Suspended { .. } => "COMPONENT_SUSPENDED",
154 }
155 }
156
157 /// Returns whether the error is recoverable.
158 ///
159 /// # Returns
160 ///
161 /// - `true`: Retry may succeed
162 /// - `false`: Retry will not help
163 fn is_recoverable(&self) -> bool {
164 match self {
165 Self::ExecutionFailed(_) => true,
166 Self::InitFailed(_) => true,
167 Self::Suspended { .. } => true,
168 Self::NotSupported(_) => false,
169 Self::InvalidPayload(_) => false,
170 Self::Aborted => false,
171 }
172 }
173}
174
175#[cfg(test)]
176mod tests {
177 use super::*;
178 use orcs_types::assert_error_codes;
179
180 /// All variants for exhaustive testing
181 fn all_variants() -> Vec<ComponentError> {
182 vec![
183 ComponentError::NotSupported("x".into()),
184 ComponentError::ExecutionFailed("x".into()),
185 ComponentError::InvalidPayload("x".into()),
186 ComponentError::Aborted,
187 ComponentError::InitFailed("x".into()),
188 ComponentError::Suspended {
189 approval_id: "test-001".into(),
190 grant_pattern: "shell:*".into(),
191 pending_request: serde_json::Value::Null,
192 },
193 ]
194 }
195
196 #[test]
197 fn all_error_codes_valid() {
198 // This test ensures ALL variants have correct prefix and format
199 assert_error_codes(&all_variants(), "COMPONENT_");
200 }
201
202 #[test]
203 fn not_supported_error() {
204 let err = ComponentError::NotSupported("unknown".into());
205 assert_eq!(err.code(), "COMPONENT_NOT_SUPPORTED");
206 assert!(!err.is_recoverable());
207 assert!(err.to_string().contains("not supported"));
208 }
209
210 #[test]
211 fn execution_failed_error() {
212 let err = ComponentError::ExecutionFailed("timeout".into());
213 assert_eq!(err.code(), "COMPONENT_EXECUTION_FAILED");
214 assert!(err.is_recoverable());
215 assert!(err.to_string().contains("execution failed"));
216 }
217
218 #[test]
219 fn invalid_payload_error() {
220 let err = ComponentError::InvalidPayload("missing field".into());
221 assert_eq!(err.code(), "COMPONENT_INVALID_PAYLOAD");
222 assert!(!err.is_recoverable());
223 assert!(err.to_string().contains("invalid payload"));
224 }
225
226 #[test]
227 fn aborted_error() {
228 let err = ComponentError::Aborted;
229 assert_eq!(err.code(), "COMPONENT_ABORTED");
230 assert!(!err.is_recoverable());
231 assert!(err.to_string().contains("aborted"));
232 }
233
234 #[test]
235 fn init_failed_error() {
236 let err = ComponentError::InitFailed("missing config".into());
237 assert_eq!(err.code(), "COMPONENT_INIT_FAILED");
238 assert!(err.is_recoverable());
239 assert!(err.to_string().contains("initialization failed"));
240 }
241
242 #[test]
243 fn suspended_error() {
244 let err = ComponentError::Suspended {
245 approval_id: "ap-42".into(),
246 grant_pattern: "shell:ls".into(),
247 pending_request: serde_json::json!({"op": "exec", "cmd": "ls"}),
248 };
249 assert_eq!(err.code(), "COMPONENT_SUSPENDED");
250 assert!(err.is_recoverable());
251 assert!(err.to_string().contains("suspended pending approval"));
252 assert!(err.to_string().contains("ap-42"));
253 }
254
255 #[test]
256 fn suspended_preserves_fields() {
257 let payload = serde_json::json!({"command": "rm -rf /", "args": []});
258 let err = ComponentError::Suspended {
259 approval_id: "ap-99".into(),
260 grant_pattern: "shell:rm".into(),
261 pending_request: payload.clone(),
262 };
263 match err {
264 ComponentError::Suspended {
265 approval_id,
266 grant_pattern,
267 pending_request,
268 } => {
269 assert_eq!(approval_id, "ap-99");
270 assert_eq!(grant_pattern, "shell:rm");
271 assert_eq!(pending_request, payload);
272 }
273 _ => panic!("Expected Suspended variant"),
274 }
275 }
276
277 #[test]
278 fn suspended_serialization_roundtrip() {
279 let err = ComponentError::Suspended {
280 approval_id: "ap-rt".into(),
281 grant_pattern: "tool:*".into(),
282 pending_request: serde_json::json!({"test": true}),
283 };
284 let serialized = serde_json::to_string(&err).expect("Suspended should serialize to JSON");
285 let deserialized: ComponentError =
286 serde_json::from_str(&serialized).expect("Suspended should deserialize from JSON");
287 assert_eq!(deserialized.code(), "COMPONENT_SUSPENDED");
288 match deserialized {
289 ComponentError::Suspended {
290 approval_id,
291 grant_pattern,
292 pending_request,
293 } => {
294 assert_eq!(approval_id, "ap-rt");
295 assert_eq!(grant_pattern, "tool:*");
296 assert_eq!(pending_request, serde_json::json!({"test": true}));
297 }
298 _ => panic!("Expected Suspended after roundtrip"),
299 }
300 }
301
302 #[test]
303 fn error_code_prefix() {
304 // All component errors should have COMPONENT_ prefix
305 let errors: Vec<ComponentError> = vec![
306 ComponentError::NotSupported("x".into()),
307 ComponentError::ExecutionFailed("x".into()),
308 ComponentError::InvalidPayload("x".into()),
309 ComponentError::Aborted,
310 ComponentError::InitFailed("x".into()),
311 ComponentError::Suspended {
312 approval_id: "test-001".into(),
313 grant_pattern: "shell:*".into(),
314 pending_request: serde_json::Value::Null,
315 },
316 ];
317
318 for err in errors {
319 assert!(err.code().starts_with("COMPONENT_"));
320 }
321 }
322}