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}
42
43impl ErrorCode {
44    /// Map to JSON-RPC error code.
45    pub const fn jsonrpc_code(self) -> i32 {
46        match self {
47            Self::SessionNotFound => -32001,
48            Self::ScheduleNotFound => -32023,
49            Self::SessionBusy => -32002,
50            Self::SessionNotRunning => -32003,
51            Self::RequestCancelled => -32005,
52            Self::ProviderError => -32010,
53            Self::BudgetExhausted => -32011,
54            Self::HookDenied => -32012,
55            Self::AgentError => -32013,
56            Self::CapabilityUnavailable => -32020,
57            Self::SkillNotFound => -32021,
58            Self::SkillResolutionFailed => -32022,
59            Self::InvalidParams => -32602,
60            Self::InternalError => -32603,
61            Self::DuplicateInput => -32004,
62        }
63    }
64
65    /// Map to HTTP status code.
66    pub const fn http_status(self) -> u16 {
67        match self {
68            Self::SessionNotFound | Self::ScheduleNotFound | Self::SkillNotFound => 404,
69            Self::SessionBusy | Self::SessionNotRunning | Self::DuplicateInput => 409,
70            Self::RequestCancelled => 499,
71            Self::ProviderError => 502,
72            Self::BudgetExhausted => 429,
73            Self::HookDenied => 403,
74            Self::AgentError | Self::InternalError => 500,
75            Self::CapabilityUnavailable => 501,
76            Self::SkillResolutionFailed => 422,
77            Self::InvalidParams => 400,
78        }
79    }
80
81    /// Map to CLI exit code.
82    pub const fn cli_exit_code(self) -> i32 {
83        match self {
84            Self::SessionNotFound => 10,
85            Self::ScheduleNotFound => 43,
86            Self::SessionBusy => 11,
87            Self::SessionNotRunning => 12,
88            Self::RequestCancelled => 14,
89            Self::ProviderError => 20,
90            Self::BudgetExhausted => 21,
91            Self::HookDenied => 22,
92            Self::AgentError => 30,
93            Self::CapabilityUnavailable => 40,
94            Self::SkillNotFound => 41,
95            Self::SkillResolutionFailed => 42,
96            Self::InvalidParams => 2,
97            Self::InternalError => 1,
98            Self::DuplicateInput => 13,
99        }
100    }
101}
102
103/// Error category for grouping.
104#[derive(
105    Debug,
106    Clone,
107    Copy,
108    PartialEq,
109    Eq,
110    Hash,
111    Serialize,
112    Deserialize,
113    strum::EnumString,
114    strum::Display,
115)]
116#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
117#[serde(rename_all = "snake_case")]
118#[strum(serialize_all = "snake_case")]
119pub enum ErrorCategory {
120    Session,
121    Request,
122    Provider,
123    Budget,
124    Hook,
125    Agent,
126    Capability,
127    Skill,
128    Validation,
129    Internal,
130}
131
132impl ErrorCode {
133    /// Get the category for this error code.
134    pub fn category(self) -> ErrorCategory {
135        match self {
136            Self::SessionNotFound
137            | Self::ScheduleNotFound
138            | Self::SessionBusy
139            | Self::SessionNotRunning
140            | Self::DuplicateInput => ErrorCategory::Session,
141            Self::RequestCancelled => ErrorCategory::Request,
142            Self::ProviderError => ErrorCategory::Provider,
143            Self::BudgetExhausted => ErrorCategory::Budget,
144            Self::HookDenied => ErrorCategory::Hook,
145            Self::AgentError => ErrorCategory::Agent,
146            Self::CapabilityUnavailable => ErrorCategory::Capability,
147            Self::SkillNotFound | Self::SkillResolutionFailed => ErrorCategory::Skill,
148            Self::InvalidParams => ErrorCategory::Validation,
149            Self::InternalError => ErrorCategory::Internal,
150        }
151    }
152}
153
154/// Hint about which capability is needed.
155#[derive(Debug, Clone, Serialize, Deserialize)]
156#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
157pub struct CapabilityHint {
158    pub capability_id: CapabilityId,
159    pub message: Cow<'static, str>,
160}
161
162/// Canonical wire error envelope.
163///
164/// Surfaces map this to their native format (RPC error, HTTP response, CLI exit).
165#[derive(Debug, Clone, Serialize, Deserialize)]
166#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
167pub struct WireError {
168    pub code: ErrorCode,
169    pub category: ErrorCategory,
170    pub message: Cow<'static, str>,
171    #[serde(skip_serializing_if = "Option::is_none")]
172    pub details: Option<serde_json::Value>,
173    #[serde(skip_serializing_if = "Option::is_none")]
174    pub capability_hint: Option<CapabilityHint>,
175}
176
177impl WireError {
178    /// Create a simple error with just a code and message.
179    pub fn new(code: ErrorCode, message: impl Into<Cow<'static, str>>) -> Self {
180        Self {
181            category: code.category(),
182            code,
183            message: message.into(),
184            details: None,
185            capability_hint: None,
186        }
187    }
188
189    /// Add a capability hint to this error.
190    pub fn with_capability_hint(mut self, hint: CapabilityHint) -> Self {
191        self.capability_hint = Some(hint);
192        self
193    }
194
195    /// Add details to this error.
196    pub fn with_details(mut self, details: serde_json::Value) -> Self {
197        self.details = Some(details);
198        self
199    }
200}
201
202/// Convert from [`SessionError`] to [`WireError`].
203impl From<meerkat_core::SessionError> for WireError {
204    fn from(err: meerkat_core::SessionError) -> Self {
205        let code = match &err {
206            meerkat_core::SessionError::NotFound { .. } => ErrorCode::SessionNotFound,
207            meerkat_core::SessionError::Busy { .. } => ErrorCode::SessionBusy,
208            meerkat_core::SessionError::NotRunning { .. } => ErrorCode::SessionNotRunning,
209            meerkat_core::SessionError::Agent(_) => ErrorCode::AgentError,
210            meerkat_core::SessionError::PersistenceDisabled
211            | meerkat_core::SessionError::CompactionDisabled
212            | meerkat_core::SessionError::Unsupported(_) => ErrorCode::CapabilityUnavailable,
213            meerkat_core::SessionError::Store(_) => ErrorCode::InternalError,
214        };
215        WireError::new(code, err.to_string())
216    }
217}
218
219#[cfg(test)]
220mod tests {
221    use super::*;
222
223    #[test]
224    fn test_error_code_roundtrip() {
225        let codes = [
226            ErrorCode::SessionNotFound,
227            ErrorCode::ScheduleNotFound,
228            ErrorCode::SessionBusy,
229            ErrorCode::ProviderError,
230            ErrorCode::InternalError,
231            ErrorCode::SkillNotFound,
232            ErrorCode::RequestCancelled,
233        ];
234        for code in codes {
235            let json = serde_json::to_string(&code).unwrap_or_default();
236            let parsed: ErrorCode = serde_json::from_str(&json).unwrap_or(ErrorCode::InternalError);
237            assert_eq!(code, parsed);
238        }
239    }
240
241    #[test]
242    fn test_wire_error_serialization() {
243        let err = WireError::new(ErrorCode::SessionNotFound, "session not found");
244        let json = serde_json::to_value(&err).unwrap_or_default();
245        assert_eq!(json["code"], "SESSION_NOT_FOUND");
246        assert_eq!(json["category"], "session");
247    }
248
249    #[test]
250    fn test_error_code_projections() {
251        // Every code should have valid projections
252        for code in [
253            ErrorCode::SessionNotFound,
254            ErrorCode::ScheduleNotFound,
255            ErrorCode::SessionBusy,
256            ErrorCode::SessionNotRunning,
257            ErrorCode::RequestCancelled,
258            ErrorCode::ProviderError,
259            ErrorCode::BudgetExhausted,
260            ErrorCode::HookDenied,
261            ErrorCode::AgentError,
262            ErrorCode::CapabilityUnavailable,
263            ErrorCode::SkillNotFound,
264            ErrorCode::SkillResolutionFailed,
265            ErrorCode::InvalidParams,
266            ErrorCode::InternalError,
267            ErrorCode::DuplicateInput,
268        ] {
269            let _rpc = code.jsonrpc_code();
270            let http = code.http_status();
271            let cli = code.cli_exit_code();
272            assert!(
273                (400..600).contains(&http),
274                "HTTP status should be 4xx or 5xx"
275            );
276            assert!(cli > 0, "CLI exit code should be positive");
277        }
278    }
279}