use std::borrow::Cow;
use serde::{Deserialize, Serialize};
use crate::capability::CapabilityId;
#[derive(
Debug,
Clone,
Copy,
PartialEq,
Eq,
Hash,
Serialize,
Deserialize,
strum::EnumString,
strum::Display,
strum::EnumIter,
)]
#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
#[serde(rename_all = "SCREAMING_SNAKE_CASE")]
#[strum(serialize_all = "SCREAMING_SNAKE_CASE")]
pub enum ErrorCode {
SessionNotFound,
ScheduleNotFound,
SessionBusy,
SessionNotRunning,
RequestCancelled,
ProviderError,
BudgetExhausted,
HookDenied,
AgentError,
CapabilityUnavailable,
SkillNotFound,
SkillResolutionFailed,
InvalidParams,
InternalError,
DuplicateInput,
SupervisorRotationIncomplete,
ScopeDenied,
HostUnavailable,
StaleCursor,
StaleFence,
}
impl ErrorCode {
pub const fn jsonrpc_code(self) -> i32 {
match self {
Self::SessionNotFound => -32001,
Self::ScheduleNotFound => -32023,
Self::SessionBusy => -32002,
Self::SessionNotRunning => -32003,
Self::RequestCancelled => -32005,
Self::ProviderError => -32010,
Self::BudgetExhausted => -32011,
Self::HookDenied => -32012,
Self::AgentError => -32013,
Self::CapabilityUnavailable => -32020,
Self::SkillNotFound => -32021,
Self::SkillResolutionFailed => -32022,
Self::InvalidParams => -32602,
Self::InternalError => -32603,
Self::DuplicateInput => -32004,
Self::SupervisorRotationIncomplete => -32024,
Self::ScopeDenied => -32025,
Self::HostUnavailable => -32026,
Self::StaleCursor => -32027,
Self::StaleFence => -32028,
}
}
pub const fn from_jsonrpc_code(code: i32) -> Option<Self> {
match code {
-32001 => Some(Self::SessionNotFound),
-32023 => Some(Self::ScheduleNotFound),
-32002 => Some(Self::SessionBusy),
-32003 => Some(Self::SessionNotRunning),
-32005 => Some(Self::RequestCancelled),
-32010 => Some(Self::ProviderError),
-32011 => Some(Self::BudgetExhausted),
-32012 => Some(Self::HookDenied),
-32013 => Some(Self::AgentError),
-32020 => Some(Self::CapabilityUnavailable),
-32021 => Some(Self::SkillNotFound),
-32022 => Some(Self::SkillResolutionFailed),
-32602 => Some(Self::InvalidParams),
-32603 => Some(Self::InternalError),
-32004 => Some(Self::DuplicateInput),
-32024 => Some(Self::SupervisorRotationIncomplete),
-32025 => Some(Self::ScopeDenied),
-32026 => Some(Self::HostUnavailable),
-32027 => Some(Self::StaleCursor),
-32028 => Some(Self::StaleFence),
_ => None,
}
}
pub const fn http_status(self) -> u16 {
match self {
Self::SessionNotFound | Self::ScheduleNotFound | Self::SkillNotFound => 404,
Self::SessionBusy
| Self::SessionNotRunning
| Self::DuplicateInput
| Self::SupervisorRotationIncomplete
| Self::StaleFence => 409,
Self::RequestCancelled => 499,
Self::ProviderError => 502,
Self::BudgetExhausted => 429,
Self::HookDenied | Self::ScopeDenied => 403,
Self::AgentError | Self::InternalError => 500,
Self::CapabilityUnavailable => 501,
Self::SkillResolutionFailed => 422,
Self::InvalidParams => 400,
Self::HostUnavailable => 503,
Self::StaleCursor => 410,
}
}
pub const fn cli_exit_code(self) -> i32 {
match self {
Self::SessionNotFound => 10,
Self::ScheduleNotFound => 43,
Self::SessionBusy => 11,
Self::SessionNotRunning => 12,
Self::RequestCancelled => 14,
Self::ProviderError => 20,
Self::BudgetExhausted => 21,
Self::HookDenied => 22,
Self::AgentError => 30,
Self::CapabilityUnavailable => 40,
Self::SkillNotFound => 41,
Self::SkillResolutionFailed => 42,
Self::InvalidParams => 2,
Self::InternalError => 1,
Self::DuplicateInput => 13,
Self::SupervisorRotationIncomplete => 44,
Self::ScopeDenied => 45,
Self::HostUnavailable => 46,
Self::StaleCursor => 47,
Self::StaleFence => 48,
}
}
}
#[derive(
Debug,
Clone,
Copy,
PartialEq,
Eq,
Hash,
Serialize,
Deserialize,
strum::EnumString,
strum::Display,
)]
#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
#[serde(rename_all = "snake_case")]
#[strum(serialize_all = "snake_case")]
pub enum ErrorCategory {
Session,
Request,
Provider,
Budget,
Hook,
Agent,
Capability,
Skill,
Validation,
Internal,
}
impl ErrorCode {
pub fn category(self) -> ErrorCategory {
match self {
Self::SessionNotFound
| Self::ScheduleNotFound
| Self::SessionBusy
| Self::SessionNotRunning
| Self::DuplicateInput
| Self::SupervisorRotationIncomplete
| Self::StaleCursor
| Self::StaleFence => ErrorCategory::Session,
Self::RequestCancelled => ErrorCategory::Request,
Self::ProviderError | Self::HostUnavailable => ErrorCategory::Provider,
Self::BudgetExhausted => ErrorCategory::Budget,
Self::HookDenied | Self::ScopeDenied => ErrorCategory::Hook,
Self::AgentError => ErrorCategory::Agent,
Self::CapabilityUnavailable => ErrorCategory::Capability,
Self::SkillNotFound | Self::SkillResolutionFailed => ErrorCategory::Skill,
Self::InvalidParams => ErrorCategory::Validation,
Self::InternalError => ErrorCategory::Internal,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
pub struct CapabilityHint {
pub capability_id: CapabilityId,
pub message: Cow<'static, str>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
pub struct WireError {
pub code: ErrorCode,
pub category: ErrorCategory,
pub message: Cow<'static, str>,
#[serde(skip_serializing_if = "Option::is_none")]
pub details: Option<serde_json::Value>,
#[serde(skip_serializing_if = "Option::is_none")]
pub capability_hint: Option<CapabilityHint>,
}
impl WireError {
pub fn new(code: ErrorCode, message: impl Into<Cow<'static, str>>) -> Self {
Self {
category: code.category(),
code,
message: message.into(),
details: None,
capability_hint: None,
}
}
pub fn with_capability_hint(mut self, hint: CapabilityHint) -> Self {
self.capability_hint = Some(hint);
self
}
pub fn with_details(mut self, details: serde_json::Value) -> Self {
self.details = Some(details);
self
}
}
impl From<meerkat_core::SessionError> for WireError {
fn from(err: meerkat_core::SessionError) -> Self {
let code = match &err {
meerkat_core::SessionError::NotFound { .. } => ErrorCode::SessionNotFound,
meerkat_core::SessionError::Busy { .. } => ErrorCode::SessionBusy,
meerkat_core::SessionError::NotRunning { .. } => ErrorCode::SessionNotRunning,
meerkat_core::SessionError::Agent(meerkat_core::AgentError::Cancelled) => {
ErrorCode::RequestCancelled
}
meerkat_core::SessionError::Agent(
meerkat_core::AgentError::SkillResolutionFailed { .. },
) => ErrorCode::SkillResolutionFailed,
meerkat_core::SessionError::Agent(_) => ErrorCode::AgentError,
meerkat_core::SessionError::PersistenceDisabled
| meerkat_core::SessionError::CompactionDisabled
| meerkat_core::SessionError::Unsupported(_) => ErrorCode::CapabilityUnavailable,
meerkat_core::SessionError::Store(_)
| meerkat_core::SessionError::FailedWithData { .. } => ErrorCode::InternalError,
};
let details = err.structured_data();
let wire = WireError::new(code, err.to_string());
if let Some(details) = details {
wire.with_details(details)
} else {
wire
}
}
}
#[cfg(test)]
#[allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
mod tests {
use super::*;
#[test]
fn test_error_code_roundtrip() {
let codes = [
ErrorCode::SessionNotFound,
ErrorCode::ScheduleNotFound,
ErrorCode::SessionBusy,
ErrorCode::ProviderError,
ErrorCode::InternalError,
ErrorCode::SkillNotFound,
ErrorCode::RequestCancelled,
];
for code in codes {
let json = serde_json::to_string(&code).unwrap_or_default();
let parsed: ErrorCode = serde_json::from_str(&json).unwrap_or(ErrorCode::InternalError);
assert_eq!(code, parsed);
}
}
#[test]
fn test_wire_error_serialization() {
let err = WireError::new(ErrorCode::SessionNotFound, "session not found");
let json = serde_json::to_value(&err).unwrap_or_default();
assert_eq!(json["code"], "SESSION_NOT_FOUND");
assert_eq!(json["category"], "session");
}
#[test]
fn session_cancelled_wire_error_uses_request_cancelled_code() {
let err = WireError::from(meerkat_core::SessionError::Agent(
meerkat_core::AgentError::Cancelled,
));
assert_eq!(err.code, ErrorCode::RequestCancelled);
assert_eq!(err.category, ErrorCategory::Request);
}
#[test]
fn session_skill_resolution_wire_error_uses_skill_resolution_code()
-> Result<(), Box<dyn std::error::Error>> {
let skill_name = meerkat_core::skills::SkillName::parse("example")?;
let key = meerkat_core::skills::SkillKey::builtin(skill_name);
let err = WireError::from(meerkat_core::SessionError::Agent(
meerkat_core::AgentError::SkillResolutionFailed {
skill_key: Some(key.clone()),
reason: Box::new(
meerkat_core::event::SkillResolutionFailureReason::NotFound { key },
),
},
));
assert_eq!(err.code, ErrorCode::SkillResolutionFailed);
assert_eq!(err.category, ErrorCategory::Skill);
Ok(())
}
#[test]
fn session_failed_with_data_wire_error_preserves_details() {
let details = serde_json::json!({
"code": "mob_destroy_incomplete",
"retryable": true,
"destroy_report": {
"errors": ["forced partial cleanup"]
}
});
let err = WireError::from(meerkat_core::SessionError::FailedWithData {
message: "mob cleanup incomplete".to_string(),
data: details.clone(),
});
assert_eq!(err.code, ErrorCode::InternalError);
assert_eq!(err.details, Some(details));
}
#[test]
fn test_error_code_projections() {
use strum::IntoEnumIterator;
for code in ErrorCode::iter() {
let rpc = code.jsonrpc_code();
let http = code.http_status();
let cli = code.cli_exit_code();
assert_eq!(ErrorCode::from_jsonrpc_code(rpc), Some(code));
assert!(
(400..600).contains(&http),
"HTTP status should be 4xx or 5xx"
);
assert!(cli > 0, "CLI exit code should be positive");
}
}
#[test]
fn multi_host_error_codes_render_exact_values() {
let cases: &[(ErrorCode, i32, u16, i32, ErrorCategory)] = &[
(ErrorCode::ScopeDenied, -32025, 403, 45, ErrorCategory::Hook),
(
ErrorCode::HostUnavailable,
-32026,
503,
46,
ErrorCategory::Provider,
),
(
ErrorCode::StaleCursor,
-32027,
410,
47,
ErrorCategory::Session,
),
(
ErrorCode::StaleFence,
-32028,
409,
48,
ErrorCategory::Session,
),
];
for (code, rpc, http, cli, category) in cases {
assert_eq!(code.jsonrpc_code(), *rpc, "{code:?} jsonrpc pin");
assert_eq!(code.http_status(), *http, "{code:?} http pin");
assert_eq!(code.cli_exit_code(), *cli, "{code:?} cli pin");
assert_eq!(code.category(), *category, "{code:?} category pin");
assert_eq!(
ErrorCode::from_jsonrpc_code(*rpc),
Some(*code),
"{code:?} jsonrpc round-trip pin"
);
}
}
#[test]
fn error_code_projections_are_collision_free() {
use std::collections::HashMap;
use strum::IntoEnumIterator;
let mut jsonrpc: HashMap<i32, ErrorCode> = HashMap::new();
let mut cli: HashMap<i32, ErrorCode> = HashMap::new();
for code in ErrorCode::iter() {
if let Some(previous) = jsonrpc.insert(code.jsonrpc_code(), code) {
panic!(
"JSON-RPC code {} claimed by both {previous:?} and {code:?}",
code.jsonrpc_code()
);
}
if let Some(previous) = cli.insert(code.cli_exit_code(), code) {
panic!(
"CLI exit code {} claimed by both {previous:?} and {code:?}",
code.cli_exit_code()
);
}
}
}
}