1use serde::Serialize;
16use thiserror::Error;
17
18#[derive(Debug, Clone, Copy, PartialEq, Eq)]
23pub enum AmxCode {
24 OutOfSpace,
26 PermissionDenied,
27 PathNotFound,
28 PathTraversal,
29 MigrationFailed,
31 VersionMismatch,
32 CliNotInstalled,
34 NpmInstallFailed,
35 CliShimMissing,
36 CliMissingOnPath,
40 AuthRequiresTty,
42 AuthTimeout,
43 HttpError,
45 SidecarBindFailed,
47 AlreadyRunning,
48 Legacy,
51}
52
53impl AmxCode {
54 pub const fn as_str(self) -> &'static str {
55 match self {
56 AmxCode::OutOfSpace => "AMX-IO-001",
57 AmxCode::PermissionDenied => "AMX-IO-002",
58 AmxCode::PathNotFound => "AMX-IO-003",
59 AmxCode::PathTraversal => "AMX-IO-004",
60 AmxCode::MigrationFailed => "AMX-STORE-001",
61 AmxCode::VersionMismatch => "AMX-STORE-002",
62 AmxCode::CliNotInstalled => "AMX-CLI-001",
63 AmxCode::NpmInstallFailed => "AMX-CLI-002",
64 AmxCode::CliShimMissing => "AMX-CLI-003",
65 AmxCode::CliMissingOnPath => "AMX-CLI-004",
66 AmxCode::AuthRequiresTty => "AMX-AUTH-001",
67 AmxCode::AuthTimeout => "AMX-AUTH-002",
68 AmxCode::HttpError => "AMX-NET-001",
69 AmxCode::SidecarBindFailed => "AMX-LIFECYCLE-001",
70 AmxCode::AlreadyRunning => "AMX-LIFECYCLE-002",
71 AmxCode::Legacy => "AMX-LEGACY",
72 }
73 }
74}
75
76impl std::fmt::Display for AmxCode {
77 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
78 f.write_str(self.as_str())
79 }
80}
81
82impl serde::Serialize for AmxCode {
83 fn serialize<S: serde::Serializer>(&self, ser: S) -> Result<S::Ok, S::Error> {
84 ser.serialize_str(self.as_str())
85 }
86}
87
88#[derive(Debug, Error)]
91pub enum AgentMuxError {
92 #[error("device out of space writing {path}")]
94 OutOfSpace { path: String, source_msg: String },
95
96 #[error("permission denied accessing {path}")]
97 PermissionDenied { path: String, source_msg: String },
98
99 #[error("path not found: {path}")]
100 PathNotFound { path: String },
101
102 #[error("path traversal blocked: {path}")]
103 PathTraversal { path: String },
104
105 #[error("schema migration {from}→{to} failed: {message}")]
107 MigrationFailed { from: u32, to: u32, message: String },
108
109 #[error("optimistic-lock version mismatch on {oid} (expected {expected}, actual {actual})")]
110 VersionMismatch { oid: String, expected: u64, actual: u64 },
111
112 #[error("CLI {cli} not installed for provider {provider}")]
114 CliNotInstalled { provider: String, cli: String },
115
116 #[error("npm install failed for {package}: {message}")]
117 NpmInstallFailed { package: String, message: String },
118
119 #[error("installed CLI shim missing: {expected_path}")]
120 CliShimMissing { provider: String, expected_path: String },
121
122 #[error("{cli} not found on PATH for {provider}")]
123 CliMissingOnPath {
124 provider: String,
125 cli: String,
126 install_hint: String,
127 },
128
129 #[error("OAuth subprocess requires an interactive TTY: {provider}")]
131 AuthRequiresTty { provider: String },
132
133 #[error("OAuth login timed out after {seconds}s: {provider}")]
134 AuthTimeout { provider: String, seconds: u64 },
135
136 #[error("HTTP request failed ({status:?}) for {url}: {message}")]
138 HttpError {
139 url: String,
140 status: Option<u16>,
141 message: String,
142 },
143
144 #[error("sidecar bind failed on port {port}: {message}")]
146 SidecarBindFailed { port: u16, message: String },
147
148 #[error("single-instance lock held by pid {pid}")]
149 AlreadyRunning { pid: u32 },
150
151 #[error("{0}")]
153 Legacy(String),
154}
155
156impl AgentMuxError {
157 pub fn code(&self) -> AmxCode {
160 match self {
161 AgentMuxError::OutOfSpace { .. } => AmxCode::OutOfSpace,
162 AgentMuxError::PermissionDenied { .. } => AmxCode::PermissionDenied,
163 AgentMuxError::PathNotFound { .. } => AmxCode::PathNotFound,
164 AgentMuxError::PathTraversal { .. } => AmxCode::PathTraversal,
165 AgentMuxError::MigrationFailed { .. } => AmxCode::MigrationFailed,
166 AgentMuxError::VersionMismatch { .. } => AmxCode::VersionMismatch,
167 AgentMuxError::CliNotInstalled { .. } => AmxCode::CliNotInstalled,
168 AgentMuxError::NpmInstallFailed { .. } => AmxCode::NpmInstallFailed,
169 AgentMuxError::CliShimMissing { .. } => AmxCode::CliShimMissing,
170 AgentMuxError::CliMissingOnPath { .. } => AmxCode::CliMissingOnPath,
171 AgentMuxError::AuthRequiresTty { .. } => AmxCode::AuthRequiresTty,
172 AgentMuxError::AuthTimeout { .. } => AmxCode::AuthTimeout,
173 AgentMuxError::HttpError { .. } => AmxCode::HttpError,
174 AgentMuxError::SidecarBindFailed { .. } => AmxCode::SidecarBindFailed,
175 AgentMuxError::AlreadyRunning { .. } => AmxCode::AlreadyRunning,
176 AgentMuxError::Legacy(_) => AmxCode::Legacy,
177 }
178 }
179
180 pub fn from_io_with_path(path: impl Into<String>, err: std::io::Error) -> Self {
185 let path = path.into();
186 let source_msg = err.to_string();
187 match Self::classify_io(&err) {
188 AmxCode::OutOfSpace => AgentMuxError::OutOfSpace { path, source_msg },
189 AmxCode::PermissionDenied => AgentMuxError::PermissionDenied { path, source_msg },
190 AmxCode::PathNotFound => AgentMuxError::PathNotFound { path },
191 _ => AgentMuxError::Legacy(format!("{path}: {source_msg}")),
192 }
193 }
194
195 fn classify_io(err: &std::io::Error) -> AmxCode {
196 if err.raw_os_error() == Some(28) {
205 return AmxCode::OutOfSpace;
206 }
207 #[cfg(windows)]
208 if matches!(err.raw_os_error(), Some(39) | Some(112)) {
209 return AmxCode::OutOfSpace;
212 }
213 match err.kind() {
214 std::io::ErrorKind::PermissionDenied => AmxCode::PermissionDenied,
215 std::io::ErrorKind::NotFound => AmxCode::PathNotFound,
216 _ => AmxCode::Legacy,
217 }
218 }
219
220 pub fn to_wire(&self) -> serde_json::Value {
223 let mut details = serde_json::Map::new();
224 match self {
225 AgentMuxError::OutOfSpace { path, source_msg } => {
226 details.insert("path".into(), path.clone().into());
227 details.insert("source_msg".into(), source_msg.clone().into());
228 }
229 AgentMuxError::PermissionDenied { path, source_msg } => {
230 details.insert("path".into(), path.clone().into());
231 details.insert("source_msg".into(), source_msg.clone().into());
232 }
233 AgentMuxError::PathNotFound { path } | AgentMuxError::PathTraversal { path } => {
234 details.insert("path".into(), path.clone().into());
235 }
236 AgentMuxError::MigrationFailed { from, to, message } => {
237 details.insert("from".into(), (*from).into());
238 details.insert("to".into(), (*to).into());
239 details.insert("message".into(), message.clone().into());
240 }
241 AgentMuxError::VersionMismatch { oid, expected, actual } => {
242 details.insert("oid".into(), oid.clone().into());
243 details.insert("expected".into(), (*expected).into());
244 details.insert("actual".into(), (*actual).into());
245 }
246 AgentMuxError::CliNotInstalled { provider, cli } => {
247 details.insert("provider".into(), provider.clone().into());
248 details.insert("cli".into(), cli.clone().into());
249 }
250 AgentMuxError::NpmInstallFailed { package, message } => {
251 details.insert("package".into(), package.clone().into());
252 details.insert("message".into(), message.clone().into());
253 }
254 AgentMuxError::CliShimMissing { provider, expected_path } => {
255 details.insert("provider".into(), provider.clone().into());
256 details.insert("expected_path".into(), expected_path.clone().into());
257 }
258 AgentMuxError::CliMissingOnPath { provider, cli, install_hint } => {
259 details.insert("provider".into(), provider.clone().into());
260 details.insert("cli".into(), cli.clone().into());
261 details.insert("install_hint".into(), install_hint.clone().into());
262 }
263 AgentMuxError::AuthRequiresTty { provider } => {
264 details.insert("provider".into(), provider.clone().into());
265 }
266 AgentMuxError::AuthTimeout { provider, seconds } => {
267 details.insert("provider".into(), provider.clone().into());
268 details.insert("seconds".into(), (*seconds).into());
269 }
270 AgentMuxError::HttpError { url, status, message } => {
271 details.insert("url".into(), url.clone().into());
272 if let Some(s) = status {
273 details.insert("status".into(), (*s).into());
274 }
275 details.insert("message".into(), message.clone().into());
276 }
277 AgentMuxError::SidecarBindFailed { port, message } => {
278 details.insert("port".into(), (*port).into());
279 details.insert("message".into(), message.clone().into());
280 }
281 AgentMuxError::AlreadyRunning { pid } => {
282 details.insert("pid".into(), (*pid).into());
283 }
284 AgentMuxError::Legacy(_) => {}
285 }
286 serde_json::json!({
287 "code": self.code().as_str(),
288 "message": self.to_string(),
289 "details": serde_json::Value::Object(details),
290 })
291 }
292}
293
294impl Serialize for AgentMuxError {
295 fn serialize<S: serde::Serializer>(&self, ser: S) -> Result<S::Ok, S::Error> {
296 self.to_wire().serialize(ser)
297 }
298}
299
300impl From<std::io::Error> for AgentMuxError {
301 fn from(err: std::io::Error) -> Self {
308 let source_msg = err.to_string();
309 let unknown = || UNKNOWN_PATH.to_string();
310 match Self::classify_io(&err) {
311 AmxCode::OutOfSpace => AgentMuxError::OutOfSpace {
312 path: unknown(),
313 source_msg,
314 },
315 AmxCode::PermissionDenied => AgentMuxError::PermissionDenied {
316 path: unknown(),
317 source_msg,
318 },
319 AmxCode::PathNotFound => AgentMuxError::PathNotFound { path: unknown() },
320 _ => AgentMuxError::Legacy(source_msg),
321 }
322 }
323}
324
325const UNKNOWN_PATH: &str = "(unknown path)";
329
330impl From<String> for AgentMuxError {
334 fn from(s: String) -> Self {
335 AgentMuxError::Legacy(s)
336 }
337}
338
339impl From<&str> for AgentMuxError {
340 fn from(s: &str) -> Self {
341 AgentMuxError::Legacy(s.to_string())
342 }
343}
344
345#[cfg(test)]
346mod tests {
347 use super::*;
348
349 #[test]
350 fn code_strs_unique_and_stable() {
351 let all = [
352 AmxCode::OutOfSpace,
353 AmxCode::PermissionDenied,
354 AmxCode::PathNotFound,
355 AmxCode::PathTraversal,
356 AmxCode::MigrationFailed,
357 AmxCode::VersionMismatch,
358 AmxCode::CliNotInstalled,
359 AmxCode::NpmInstallFailed,
360 AmxCode::CliShimMissing,
361 AmxCode::CliMissingOnPath,
362 AmxCode::AuthRequiresTty,
363 AmxCode::AuthTimeout,
364 AmxCode::HttpError,
365 AmxCode::SidecarBindFailed,
366 AmxCode::AlreadyRunning,
367 AmxCode::Legacy,
368 ];
369 let mut seen: Vec<&str> = Vec::new();
370 for c in all {
371 let s = c.as_str();
372 assert!(s.starts_with("AMX-"), "{s} missing AMX- prefix");
373 assert!(!seen.contains(&s), "duplicate code {s}");
374 seen.push(s);
375 }
376 }
377
378 #[test]
379 fn io_error_routes_enospc_to_out_of_space() {
380 let err = std::io::Error::from_raw_os_error(28);
382 let mux: AgentMuxError = err.into();
383 assert_eq!(mux.code(), AmxCode::OutOfSpace);
384 }
385
386 #[cfg(windows)]
387 #[test]
388 fn io_error_routes_windows_disk_full_to_out_of_space() {
389 let err = std::io::Error::from_raw_os_error(112);
390 let mux: AgentMuxError = err.into();
391 assert_eq!(mux.code(), AmxCode::OutOfSpace);
392 }
393
394 #[cfg(windows)]
395 #[test]
396 fn io_error_routes_windows_handle_disk_full_to_out_of_space() {
397 let err = std::io::Error::from_raw_os_error(39);
400 let mux: AgentMuxError = err.into();
401 assert_eq!(mux.code(), AmxCode::OutOfSpace);
402 }
403
404 #[cfg(not(windows))]
405 #[test]
406 fn io_error_unix_ehostdown_does_not_route_to_out_of_space() {
407 let err = std::io::Error::from_raw_os_error(112);
411 let mux: AgentMuxError = err.into();
412 assert_ne!(mux.code(), AmxCode::OutOfSpace);
413 }
414
415 #[test]
416 fn io_error_permission_denied_routes() {
417 let err = std::io::Error::new(std::io::ErrorKind::PermissionDenied, "denied");
418 let mux: AgentMuxError = err.into();
419 assert_eq!(mux.code(), AmxCode::PermissionDenied);
420 }
421
422 #[test]
423 fn io_error_not_found_routes() {
424 let err = std::io::Error::new(std::io::ErrorKind::NotFound, "nope");
425 let mux: AgentMuxError = err.into();
426 assert_eq!(mux.code(), AmxCode::PathNotFound);
427 }
428
429 #[test]
430 fn io_error_unclassified_routes_to_legacy() {
431 let err = std::io::Error::new(std::io::ErrorKind::InvalidData, "bad bytes");
432 let mux: AgentMuxError = err.into();
433 assert_eq!(mux.code(), AmxCode::Legacy);
434 }
435
436 #[test]
437 fn from_io_with_path_preserves_path() {
438 let err = std::io::Error::from_raw_os_error(28);
439 let mux = AgentMuxError::from_io_with_path("/tmp/foo.db", err);
440 assert_eq!(mux.code(), AmxCode::OutOfSpace);
441 match mux {
442 AgentMuxError::OutOfSpace { path, .. } => assert_eq!(path, "/tmp/foo.db"),
443 _ => panic!("expected OutOfSpace"),
444 }
445 }
446
447 #[test]
448 fn wire_format_has_code_message_details() {
449 let mux = AgentMuxError::OutOfSpace {
450 path: "/tmp/x".into(),
451 source_msg: "ENOSPC".into(),
452 };
453 let wire = mux.to_wire();
454 assert_eq!(wire["code"], "AMX-IO-001");
455 assert!(wire["message"]
456 .as_str()
457 .unwrap()
458 .contains("/tmp/x"));
459 assert_eq!(wire["details"]["path"], "/tmp/x");
460 assert_eq!(wire["details"]["source_msg"], "ENOSPC");
461 }
462
463 #[test]
464 fn wire_format_round_trip_via_serde() {
465 let mux = AgentMuxError::CliNotInstalled {
466 provider: "claude".into(),
467 cli: "claude".into(),
468 };
469 let json = serde_json::to_value(&mux).unwrap();
470 assert_eq!(json["code"], "AMX-CLI-001");
471 assert_eq!(json["details"]["provider"], "claude");
472 }
473
474 #[test]
475 fn legacy_string_wraps_unchanged() {
476 let mux: AgentMuxError = "legacy raw message".into();
477 let wire = mux.to_wire();
478 assert_eq!(wire["code"], "AMX-LEGACY");
479 assert_eq!(wire["message"], "legacy raw message");
480 }
481}