Skip to main content

meerkat_contracts/error/
mod.rs

1//! Typed error envelope for all Meerkat protocol surfaces.
2
3use std::borrow::Cow;
4
5use serde::{Deserialize, Serialize};
6
7use crate::capability::CapabilityId;
8
9/// Stable error codes for wire protocol.
10#[derive(
11    Debug,
12    Clone,
13    Copy,
14    PartialEq,
15    Eq,
16    Hash,
17    Serialize,
18    Deserialize,
19    strum::EnumString,
20    strum::Display,
21)]
22#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
23#[serde(rename_all = "SCREAMING_SNAKE_CASE")]
24#[strum(serialize_all = "SCREAMING_SNAKE_CASE")]
25pub enum ErrorCode {
26    SessionNotFound,
27    ScheduleNotFound,
28    SessionBusy,
29    SessionNotRunning,
30    RequestCancelled,
31    ProviderError,
32    BudgetExhausted,
33    HookDenied,
34    AgentError,
35    CapabilityUnavailable,
36    SkillNotFound,
37    SkillResolutionFailed,
38    InvalidParams,
39    InternalError,
40    DuplicateInput,
41    SupervisorRotationIncomplete,
42}
43
44impl ErrorCode {
45    /// Map to JSON-RPC error code.
46    pub const fn jsonrpc_code(self) -> i32 {
47        match self {
48            Self::SessionNotFound => -32001,
49            Self::ScheduleNotFound => -32023,
50            Self::SessionBusy => -32002,
51            Self::SessionNotRunning => -32003,
52            Self::RequestCancelled => -32005,
53            Self::ProviderError => -32010,
54            Self::BudgetExhausted => -32011,
55            Self::HookDenied => -32012,
56            Self::AgentError => -32013,
57            Self::CapabilityUnavailable => -32020,
58            Self::SkillNotFound => -32021,
59            Self::SkillResolutionFailed => -32022,
60            Self::InvalidParams => -32602,
61            Self::InternalError => -32603,
62            Self::DuplicateInput => -32004,
63            Self::SupervisorRotationIncomplete => -32024,
64        }
65    }
66
67    /// Convert a JSON-RPC error code back to the canonical wire code.
68    pub const fn from_jsonrpc_code(code: i32) -> Option<Self> {
69        match code {
70            -32001 => Some(Self::SessionNotFound),
71            -32023 => Some(Self::ScheduleNotFound),
72            -32002 => Some(Self::SessionBusy),
73            -32003 => Some(Self::SessionNotRunning),
74            -32005 => Some(Self::RequestCancelled),
75            -32010 => Some(Self::ProviderError),
76            -32011 => Some(Self::BudgetExhausted),
77            -32012 => Some(Self::HookDenied),
78            -32013 => Some(Self::AgentError),
79            -32020 => Some(Self::CapabilityUnavailable),
80            -32021 => Some(Self::SkillNotFound),
81            -32022 => Some(Self::SkillResolutionFailed),
82            -32602 => Some(Self::InvalidParams),
83            -32603 => Some(Self::InternalError),
84            -32004 => Some(Self::DuplicateInput),
85            -32024 => Some(Self::SupervisorRotationIncomplete),
86            _ => None,
87        }
88    }
89
90    /// Map to HTTP status code.
91    pub const fn http_status(self) -> u16 {
92        match self {
93            Self::SessionNotFound | Self::ScheduleNotFound | Self::SkillNotFound => 404,
94            Self::SessionBusy
95            | Self::SessionNotRunning
96            | Self::DuplicateInput
97            | Self::SupervisorRotationIncomplete => 409,
98            Self::RequestCancelled => 499,
99            Self::ProviderError => 502,
100            Self::BudgetExhausted => 429,
101            Self::HookDenied => 403,
102            Self::AgentError | Self::InternalError => 500,
103            Self::CapabilityUnavailable => 501,
104            Self::SkillResolutionFailed => 422,
105            Self::InvalidParams => 400,
106        }
107    }
108
109    /// Map to CLI exit code.
110    pub const fn cli_exit_code(self) -> i32 {
111        match self {
112            Self::SessionNotFound => 10,
113            Self::ScheduleNotFound => 43,
114            Self::SessionBusy => 11,
115            Self::SessionNotRunning => 12,
116            Self::RequestCancelled => 14,
117            Self::ProviderError => 20,
118            Self::BudgetExhausted => 21,
119            Self::HookDenied => 22,
120            Self::AgentError => 30,
121            Self::CapabilityUnavailable => 40,
122            Self::SkillNotFound => 41,
123            Self::SkillResolutionFailed => 42,
124            Self::InvalidParams => 2,
125            Self::InternalError => 1,
126            Self::DuplicateInput => 13,
127            Self::SupervisorRotationIncomplete => 44,
128        }
129    }
130}
131
132/// Error category for grouping.
133#[derive(
134    Debug,
135    Clone,
136    Copy,
137    PartialEq,
138    Eq,
139    Hash,
140    Serialize,
141    Deserialize,
142    strum::EnumString,
143    strum::Display,
144)]
145#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
146#[serde(rename_all = "snake_case")]
147#[strum(serialize_all = "snake_case")]
148pub enum ErrorCategory {
149    Session,
150    Request,
151    Provider,
152    Budget,
153    Hook,
154    Agent,
155    Capability,
156    Skill,
157    Validation,
158    Internal,
159}
160
161impl ErrorCode {
162    /// Get the category for this error code.
163    pub fn category(self) -> ErrorCategory {
164        match self {
165            Self::SessionNotFound
166            | Self::ScheduleNotFound
167            | Self::SessionBusy
168            | Self::SessionNotRunning
169            | Self::DuplicateInput
170            | Self::SupervisorRotationIncomplete => ErrorCategory::Session,
171            Self::RequestCancelled => ErrorCategory::Request,
172            Self::ProviderError => ErrorCategory::Provider,
173            Self::BudgetExhausted => ErrorCategory::Budget,
174            Self::HookDenied => ErrorCategory::Hook,
175            Self::AgentError => ErrorCategory::Agent,
176            Self::CapabilityUnavailable => ErrorCategory::Capability,
177            Self::SkillNotFound | Self::SkillResolutionFailed => ErrorCategory::Skill,
178            Self::InvalidParams => ErrorCategory::Validation,
179            Self::InternalError => ErrorCategory::Internal,
180        }
181    }
182}
183
184/// Hint about which capability is needed.
185#[derive(Debug, Clone, Serialize, Deserialize)]
186#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
187pub struct CapabilityHint {
188    pub capability_id: CapabilityId,
189    pub message: Cow<'static, str>,
190}
191
192/// Canonical wire error envelope.
193///
194/// Surfaces map this to their native format (RPC error, HTTP response, CLI exit).
195#[derive(Debug, Clone, Serialize, Deserialize)]
196#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
197pub struct WireError {
198    pub code: ErrorCode,
199    pub category: ErrorCategory,
200    pub message: Cow<'static, str>,
201    #[serde(skip_serializing_if = "Option::is_none")]
202    pub details: Option<serde_json::Value>,
203    #[serde(skip_serializing_if = "Option::is_none")]
204    pub capability_hint: Option<CapabilityHint>,
205}
206
207impl WireError {
208    /// Create a simple error with just a code and message.
209    pub fn new(code: ErrorCode, message: impl Into<Cow<'static, str>>) -> Self {
210        Self {
211            category: code.category(),
212            code,
213            message: message.into(),
214            details: None,
215            capability_hint: None,
216        }
217    }
218
219    /// Add a capability hint to this error.
220    pub fn with_capability_hint(mut self, hint: CapabilityHint) -> Self {
221        self.capability_hint = Some(hint);
222        self
223    }
224
225    /// Add details to this error.
226    pub fn with_details(mut self, details: serde_json::Value) -> Self {
227        self.details = Some(details);
228        self
229    }
230}
231
232/// Convert from [`SessionError`] to [`WireError`].
233impl From<meerkat_core::SessionError> for WireError {
234    fn from(err: meerkat_core::SessionError) -> Self {
235        let code = match &err {
236            meerkat_core::SessionError::NotFound { .. } => ErrorCode::SessionNotFound,
237            meerkat_core::SessionError::Busy { .. } => ErrorCode::SessionBusy,
238            meerkat_core::SessionError::NotRunning { .. } => ErrorCode::SessionNotRunning,
239            meerkat_core::SessionError::Agent(meerkat_core::AgentError::Cancelled) => {
240                ErrorCode::RequestCancelled
241            }
242            meerkat_core::SessionError::Agent(
243                meerkat_core::AgentError::SkillResolutionFailed { .. },
244            ) => ErrorCode::SkillResolutionFailed,
245            meerkat_core::SessionError::Agent(_) => ErrorCode::AgentError,
246            meerkat_core::SessionError::PersistenceDisabled
247            | meerkat_core::SessionError::CompactionDisabled
248            | meerkat_core::SessionError::Unsupported(_) => ErrorCode::CapabilityUnavailable,
249            meerkat_core::SessionError::Store(_)
250            | meerkat_core::SessionError::FailedWithData { .. } => ErrorCode::InternalError,
251        };
252        let details = err.structured_data();
253        let wire = WireError::new(code, err.to_string());
254        if let Some(details) = details {
255            wire.with_details(details)
256        } else {
257            wire
258        }
259    }
260}
261
262#[cfg(test)]
263mod tests {
264    use super::*;
265
266    #[test]
267    fn test_error_code_roundtrip() {
268        let codes = [
269            ErrorCode::SessionNotFound,
270            ErrorCode::ScheduleNotFound,
271            ErrorCode::SessionBusy,
272            ErrorCode::ProviderError,
273            ErrorCode::InternalError,
274            ErrorCode::SkillNotFound,
275            ErrorCode::RequestCancelled,
276        ];
277        for code in codes {
278            let json = serde_json::to_string(&code).unwrap_or_default();
279            let parsed: ErrorCode = serde_json::from_str(&json).unwrap_or(ErrorCode::InternalError);
280            assert_eq!(code, parsed);
281        }
282    }
283
284    #[test]
285    fn test_wire_error_serialization() {
286        let err = WireError::new(ErrorCode::SessionNotFound, "session not found");
287        let json = serde_json::to_value(&err).unwrap_or_default();
288        assert_eq!(json["code"], "SESSION_NOT_FOUND");
289        assert_eq!(json["category"], "session");
290    }
291
292    #[test]
293    fn session_cancelled_wire_error_uses_request_cancelled_code() {
294        let err = WireError::from(meerkat_core::SessionError::Agent(
295            meerkat_core::AgentError::Cancelled,
296        ));
297
298        assert_eq!(err.code, ErrorCode::RequestCancelled);
299        assert_eq!(err.category, ErrorCategory::Request);
300    }
301
302    #[test]
303    fn session_skill_resolution_wire_error_uses_skill_resolution_code()
304    -> Result<(), Box<dyn std::error::Error>> {
305        let skill_name = meerkat_core::skills::SkillName::parse("example")?;
306        let key = meerkat_core::skills::SkillKey::builtin(skill_name);
307        let err = WireError::from(meerkat_core::SessionError::Agent(
308            meerkat_core::AgentError::SkillResolutionFailed {
309                skill_key: Some(key.clone()),
310                reason: Box::new(
311                    meerkat_core::event::SkillResolutionFailureReason::NotFound { key },
312                ),
313            },
314        ));
315
316        assert_eq!(err.code, ErrorCode::SkillResolutionFailed);
317        assert_eq!(err.category, ErrorCategory::Skill);
318        Ok(())
319    }
320
321    #[test]
322    fn session_failed_with_data_wire_error_preserves_details() {
323        let details = serde_json::json!({
324            "code": "mob_destroy_incomplete",
325            "retryable": true,
326            "destroy_report": {
327                "errors": ["forced partial cleanup"]
328            }
329        });
330        let err = WireError::from(meerkat_core::SessionError::FailedWithData {
331            message: "mob cleanup incomplete".to_string(),
332            data: details.clone(),
333        });
334
335        assert_eq!(err.code, ErrorCode::InternalError);
336        assert_eq!(err.details, Some(details));
337    }
338
339    #[test]
340    fn test_error_code_projections() {
341        // Every code should have valid projections
342        for code in [
343            ErrorCode::SessionNotFound,
344            ErrorCode::ScheduleNotFound,
345            ErrorCode::SessionBusy,
346            ErrorCode::SessionNotRunning,
347            ErrorCode::RequestCancelled,
348            ErrorCode::ProviderError,
349            ErrorCode::BudgetExhausted,
350            ErrorCode::HookDenied,
351            ErrorCode::AgentError,
352            ErrorCode::CapabilityUnavailable,
353            ErrorCode::SkillNotFound,
354            ErrorCode::SkillResolutionFailed,
355            ErrorCode::InvalidParams,
356            ErrorCode::InternalError,
357            ErrorCode::DuplicateInput,
358            ErrorCode::SupervisorRotationIncomplete,
359        ] {
360            let rpc = code.jsonrpc_code();
361            let http = code.http_status();
362            let cli = code.cli_exit_code();
363            assert_eq!(ErrorCode::from_jsonrpc_code(rpc), Some(code));
364            assert!(
365                (400..600).contains(&http),
366                "HTTP status should be 4xx or 5xx"
367            );
368            assert!(cli > 0, "CLI exit code should be positive");
369        }
370    }
371}