1use std::borrow::Cow;
4
5use serde::{Deserialize, Serialize};
6
7use crate::capability::CapabilityId;
8
9#[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 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 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 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#[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 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#[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#[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 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 pub fn with_capability_hint(mut self, hint: CapabilityHint) -> Self {
191 self.capability_hint = Some(hint);
192 self
193 }
194
195 pub fn with_details(mut self, details: serde_json::Value) -> Self {
197 self.details = Some(details);
198 self
199 }
200}
201
202impl 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 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}