Skip to main content

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}