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 SupervisorRotationIncomplete,
42}
43
44impl ErrorCode {
45 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 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 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 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#[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 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#[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#[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 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 pub fn with_capability_hint(mut self, hint: CapabilityHint) -> Self {
221 self.capability_hint = Some(hint);
222 self
223 }
224
225 pub fn with_details(mut self, details: serde_json::Value) -> Self {
227 self.details = Some(details);
228 self
229 }
230}
231
232impl 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 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}