1use std::backtrace::{Backtrace, BacktraceStatus};
2use std::error::Error as StdError;
3use std::fmt;
4
5pub use crate::error_codes::AtmErrorCode;
6
7#[derive(Debug, Clone, Copy, PartialEq, Eq)]
8pub enum AtmErrorKind {
9 Config,
10 MissingDocument,
11 Address,
12 Identity,
13 DaemonUnavailable,
14 TeamNotFound,
15 AgentNotFound,
16 MailboxLock,
17 MailboxRead,
18 MailboxWrite,
19 FilePolicy,
20 Internal,
21 Validation,
22 Serialization,
23 Timeout,
24 ObservabilityEmit,
25 ObservabilityBootstrap,
26 ObservabilityQuery,
27 ObservabilityFollow,
28 ObservabilityHealth,
29}
30
31#[derive(Debug)]
32pub struct AtmError {
33 pub code: AtmErrorCode,
34 pub kind: AtmErrorKind,
35 pub message: String,
36 pub recovery: Vec<String>,
37 pub source: Option<Box<dyn StdError + Send + Sync>>,
38 pub backtrace: Backtrace,
39}
40
41impl AtmError {
42 pub fn new(kind: AtmErrorKind, message: impl Into<String>) -> Self {
43 Self::new_with_code(kind.default_code(), kind, message)
44 }
45
46 pub fn new_with_code(
47 code: AtmErrorCode,
48 kind: AtmErrorKind,
49 message: impl Into<String>,
50 ) -> Self {
51 Self {
52 code,
53 kind,
54 message: message.into(),
55 recovery: Vec::new(),
56 source: None,
57 backtrace: Backtrace::capture(),
58 }
59 }
60
61 pub fn is_config(&self) -> bool {
62 self.kind == AtmErrorKind::Config
63 }
64 pub fn is_address(&self) -> bool {
65 self.kind == AtmErrorKind::Address
66 }
67 pub fn is_missing_document(&self) -> bool {
68 self.kind == AtmErrorKind::MissingDocument
69 }
70 pub fn is_identity(&self) -> bool {
71 self.kind == AtmErrorKind::Identity
72 }
73 pub fn is_team_not_found(&self) -> bool {
74 self.kind == AtmErrorKind::TeamNotFound
75 }
76 pub fn is_daemon_unavailable(&self) -> bool {
77 self.kind == AtmErrorKind::DaemonUnavailable
78 }
79 pub fn is_agent_not_found(&self) -> bool {
80 self.kind == AtmErrorKind::AgentNotFound
81 }
82 pub fn is_mailbox_read(&self) -> bool {
83 self.kind == AtmErrorKind::MailboxRead
84 }
85 pub fn is_mailbox_lock(&self) -> bool {
86 self.kind == AtmErrorKind::MailboxLock
87 }
88 pub fn is_mailbox_write(&self) -> bool {
89 self.kind == AtmErrorKind::MailboxWrite
90 }
91 pub fn is_file_policy(&self) -> bool {
92 self.kind == AtmErrorKind::FilePolicy
93 }
94 pub fn is_internal(&self) -> bool {
95 self.kind == AtmErrorKind::Internal
96 }
97 pub fn is_validation(&self) -> bool {
98 self.kind == AtmErrorKind::Validation
99 }
100 pub fn is_serialization(&self) -> bool {
101 self.kind == AtmErrorKind::Serialization
102 }
103 pub fn is_timeout(&self) -> bool {
104 self.kind == AtmErrorKind::Timeout
105 }
106 pub fn is_observability_emit(&self) -> bool {
107 self.kind == AtmErrorKind::ObservabilityEmit
108 }
109 pub fn is_observability_bootstrap(&self) -> bool {
110 self.kind == AtmErrorKind::ObservabilityBootstrap
111 }
112 pub fn is_observability_query(&self) -> bool {
113 self.kind == AtmErrorKind::ObservabilityQuery
114 }
115 pub fn is_observability_follow(&self) -> bool {
116 self.kind == AtmErrorKind::ObservabilityFollow
117 }
118 pub fn is_observability_health(&self) -> bool {
119 self.kind == AtmErrorKind::ObservabilityHealth
120 }
121
122 pub fn with_recovery(mut self, recovery: impl Into<String>) -> Self {
123 self.recovery.push(recovery.into());
124 self
125 }
126
127 pub fn primary_recovery(&self) -> Option<&str> {
128 self.recovery.first().map(String::as_str)
129 }
130
131 pub fn with_source<E>(mut self, source: E) -> Self
132 where
133 E: StdError + Send + Sync + 'static,
134 {
135 self.source = Some(Box::new(source));
136 self
137 }
138
139 pub fn backtrace(&self) -> Option<&Backtrace> {
140 (self.backtrace.status() == BacktraceStatus::Captured).then_some(&self.backtrace)
141 }
142
143 pub fn home_directory_unavailable() -> Self {
144 Self::new_with_code(
145 AtmErrorCode::ConfigHomeUnavailable,
146 AtmErrorKind::Config,
147 "home directory is unavailable",
148 )
149 .with_recovery("Set ATM_HOME or ensure the OS home directory can be resolved.")
150 }
151
152 pub fn config(message: impl Into<String>) -> Self {
153 Self::new(AtmErrorKind::Config, message).with_recovery(
154 "Check the active ATM configuration, runtime wiring, and local path settings before retrying.",
155 )
156 }
157
158 pub fn address_parse(message: impl Into<String>) -> Self {
159 Self::new(
160 AtmErrorKind::Address,
161 format!("address parse failed: {}", message.into()),
162 )
163 .with_recovery(
164 "Correct the ATM address format and retry with a valid <agent> or <agent>@<team> target.",
165 )
166 }
167
168 pub fn identity_unavailable() -> Self {
169 Self::new_with_code(
170 AtmErrorCode::IdentityUnavailable,
171 AtmErrorKind::Identity,
172 "identity is not configured",
173 )
174 .with_recovery("Set ATM_IDENTITY or provide an explicit command identity override when the command supports one.")
175 }
176
177 pub fn identity_conflict(message: impl Into<String>) -> Self {
178 Self::new_with_code(
179 AtmErrorCode::IdentityConflict,
180 AtmErrorKind::Identity,
181 message,
182 )
183 .with_recovery("Stop and report to the user immediately. Resolve the live pid conflict before retrying ATM activity.")
184 }
185
186 pub fn daemon_unavailable(message: impl Into<String>) -> Self {
187 Self::new_with_code(
188 AtmErrorCode::DaemonUnavailable,
189 AtmErrorKind::DaemonUnavailable,
190 message,
191 )
192 .with_recovery(
193 "Ensure the atm-daemon binary is installed, the daemon socket path is reachable, and ATM_DAEMON_BIN/ATM_HOME are set correctly before retrying.",
194 )
195 }
196
197 pub fn daemon_may_have_executed(message: impl Into<String>) -> Self {
198 Self::new_with_code(
199 AtmErrorCode::DaemonMayHaveExecuted,
200 AtmErrorKind::DaemonUnavailable,
201 message,
202 )
203 .with_recovery(
204 "Check the destination mailbox or other service-side effects before retrying this same-host ATM command.",
205 )
206 }
207
208 pub fn daemon_lifecycle_wedge(message: impl Into<String>) -> Self {
209 Self::new_with_code(
210 AtmErrorCode::DaemonLifecycleWedge,
211 AtmErrorKind::DaemonUnavailable,
212 message,
213 )
214 .with_recovery(
215 "Restart the daemon after the local IPC listener and lifecycle-control state fully stop, then inspect daemon logs for the wedged shutdown path.",
216 )
217 }
218
219 pub fn daemon_advisory_session_already_registered(message: impl Into<String>) -> Self {
220 Self::new_with_code(
221 AtmErrorCode::DaemonAdvisorySessionAlreadyRegistered,
222 AtmErrorKind::DaemonUnavailable,
223 message,
224 )
225 .with_recovery(
226 "Unregister the existing advisory session or choose a fresh session id before retrying embedded advisory activation.",
227 )
228 }
229
230 pub fn daemon_advisory_session_not_registered(message: impl Into<String>) -> Self {
231 Self::new_with_code(
232 AtmErrorCode::DaemonAdvisorySessionNotRegistered,
233 AtmErrorKind::DaemonUnavailable,
234 message,
235 )
236 .with_recovery(
237 "Register the advisory session before fetching or draining daemon-owned advisory state.",
238 )
239 }
240
241 pub fn daemon_launch_gate_rejected(message: impl Into<String>) -> Self {
242 Self::new_with_code(
243 AtmErrorCode::DaemonLaunchGateRejected,
244 AtmErrorKind::DaemonUnavailable,
245 message,
246 )
247 .with_recovery(
248 "Connect to the existing daemon if it is healthy, or resolve stale ownership before retrying another daemon launch.",
249 )
250 }
251
252 pub fn daemon_serving_state_rejected(message: impl Into<String>) -> Self {
253 Self::new_with_code(
254 AtmErrorCode::DaemonServingStateRejected,
255 AtmErrorKind::DaemonUnavailable,
256 message,
257 )
258 .with_recovery(
259 "Stop launching duplicate daemons and inspect the existing runtime owner before retrying startup.",
260 )
261 }
262
263 pub fn daemon_stale_owner_recovery_failed(message: impl Into<String>) -> Self {
264 Self::new_with_code(
265 AtmErrorCode::DaemonStaleOwnerRecoveryFailed,
266 AtmErrorKind::DaemonUnavailable,
267 message,
268 )
269 .with_recovery(
270 "Inspect the recorded owner, confirm no live daemon remains, repair ownership metadata, then retry startup.",
271 )
272 }
273
274 pub fn daemon_auto_start_failed(message: impl Into<String>) -> Self {
275 Self::new_with_code(
276 AtmErrorCode::DaemonAutoStartFailed,
277 AtmErrorKind::DaemonUnavailable,
278 message,
279 )
280 .with_recovery(
281 "Inspect daemon stderr/logs, fix the startup fault, and retry only after the daemon can reach serving state.",
282 )
283 }
284
285 pub fn remote_delivery_outcome_unknown(message: impl Into<String>) -> Self {
286 Self::new_with_code(
287 AtmErrorCode::RemoteDeliveryOutcomeUnknown,
288 AtmErrorKind::DaemonUnavailable,
289 message,
290 )
291 .with_recovery(
292 "Check the destination daemon or mailbox before retrying. If local durable replay is enabled, let the daemon resume the pending handoff rather than guessing success.",
293 )
294 }
295
296 pub fn help_topic_not_found(message: impl Into<String>) -> Self {
297 Self::new_with_code(
298 AtmErrorCode::HelpTopicNotFound,
299 AtmErrorKind::Validation,
300 message,
301 )
302 .with_recovery("Use `atm help --list` to inspect available help topics and subcommands.")
303 }
304
305 pub fn test_fake_transport_injection_failed(message: impl Into<String>) -> Self {
306 Self::new_with_code(
307 AtmErrorCode::TestFakeTransportInjectionFailed,
308 AtmErrorKind::Validation,
309 message,
310 )
311 .with_recovery(
312 "Fix the test seam configuration so it uses a valid FakeClientTransport or LoopbackClientTransport instance.",
313 )
314 }
315
316 pub fn team_unavailable() -> Self {
317 Self::new_with_code(
318 AtmErrorCode::TeamUnavailable,
319 AtmErrorKind::TeamNotFound,
320 "team is not configured",
321 )
322 .with_recovery("Pass an explicit team in the address or configure a default team.")
323 }
324
325 pub fn team_not_found(team: &str) -> Self {
326 Self::new(
327 AtmErrorKind::TeamNotFound,
328 format!("team '{team}' was not found"),
329 )
330 .with_recovery("Create the team config or target a different team.")
331 }
332
333 pub fn agent_not_found(agent: &str, team: &str) -> Self {
334 Self::new(
335 AtmErrorKind::AgentNotFound,
336 format!("agent '{agent}' was not found in team '{team}'"),
337 )
338 .with_recovery("Update the team membership or target a different recipient.")
339 }
340
341 pub fn validation(message: impl Into<String>) -> Self {
342 Self::new(AtmErrorKind::Validation, message).with_recovery(
343 "Correct the invalid ATM input or mailbox state, then retry the command with a valid target or argument.",
344 )
345 }
346
347 pub fn missing_document(message: impl Into<String>) -> Self {
348 Self::new(AtmErrorKind::MissingDocument, message).with_recovery(
349 "Restore the missing ATM document or recreate it through the documented team-management workflow before retrying.",
350 )
351 }
352
353 pub fn file_policy(message: impl Into<String>) -> Self {
354 Self::new(AtmErrorKind::FilePolicy, message).with_recovery(
355 "Update the referenced file, path, or policy inputs so they satisfy ATM file-policy rules before retrying the command.",
356 )
357 }
358
359 pub fn mailbox_read(message: impl Into<String>) -> Self {
360 Self::new(AtmErrorKind::MailboxRead, message).with_recovery(
361 "Check ATM_HOME, mailbox file permissions, and mailbox JSON syntax before retrying the ATM command.",
362 )
363 }
364
365 pub fn mailbox_lock(message: impl Into<String>) -> Self {
366 Self::new(AtmErrorKind::MailboxLock, message).with_recovery(
367 "Retry after other ATM mailbox activity completes, or wait for the competing process to release its mailbox lock.",
368 )
369 }
370
371 pub fn mailbox_lock_read_only_filesystem(
372 operation: impl fmt::Display,
373 path: &std::path::Path,
374 ) -> Self {
375 Self::new_with_code(
376 AtmErrorCode::MailboxLockReadOnlyFilesystem,
377 AtmErrorKind::MailboxLock,
378 format!(
379 "mailbox lock {operation} failed for {}: filesystem is read-only",
380 path.display()
381 ),
382 )
383 .with_recovery(
384 "Remount the filesystem read-write or point ATM at a writable home with ATM_HOME or --home, then retry the ATM command.",
385 )
386 }
387
388 pub fn mailbox_lock_timeout(path: &std::path::Path) -> Self {
389 Self::new_with_code(
390 AtmErrorCode::MailboxLockTimeout,
391 AtmErrorKind::MailboxLock,
392 format!("timed out waiting for mailbox lock on {}", path.display()),
393 )
394 .with_recovery(
395 "Retry after the competing ATM process finishes, or investigate whether another process is holding the mailbox lock unexpectedly.",
396 )
397 }
398
399 pub fn mailbox_write(message: impl Into<String>) -> Self {
400 Self::new(AtmErrorKind::MailboxWrite, message).with_recovery(
401 "Check that the mailbox/workflow path is writable, has free space, and was not modified concurrently before retrying the ATM command.",
402 )
403 }
404
405 pub fn observability_emit(message: impl Into<String>) -> Self {
406 Self::new(AtmErrorKind::ObservabilityEmit, message).with_recovery(
407 "Verify the observability sink is writable or temporarily disable retained logging while investigating.",
408 )
409 }
410
411 pub fn observability_bootstrap(message: impl Into<String>) -> Self {
412 Self::new(AtmErrorKind::ObservabilityBootstrap, message).with_recovery(
413 "Check the configured observability backend, log directory permissions, and any local path overrides before retrying ATM commands.",
414 )
415 }
416
417 pub fn observability_query(message: impl Into<String>) -> Self {
418 Self::new(AtmErrorKind::ObservabilityQuery, message).with_recovery(
419 "Confirm retained logs exist and the observability backend supports queries for the selected sink and time range.",
420 )
421 }
422
423 pub fn observability_follow(message: impl Into<String>) -> Self {
424 Self::new(AtmErrorKind::ObservabilityFollow, message).with_recovery(
425 "Check that follow/tail is enabled for the active sink and retry with a narrower query if the stream is unavailable.",
426 )
427 }
428
429 pub fn observability_health(message: impl Into<String>) -> Self {
430 Self::new(AtmErrorKind::ObservabilityHealth, message).with_recovery(
431 "Inspect the observability backend health, file sink path, and query backend status, then rerun `atm doctor`.",
432 )
433 }
434}
435
436impl fmt::Display for AtmError {
437 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
438 write!(f, "{}", self.message)?;
439 for recovery in &self.recovery {
440 write!(f, "\n Recovery: {recovery}")?;
441 }
442 if let Some(source) = self.source() {
443 write!(f, "\n Source: {source}")?;
444 let mut current = source.source();
445 while let Some(next) = current {
446 write!(f, "\n Caused by: {next}")?;
447 current = next.source();
448 }
449 }
450 match self.backtrace() {
451 Some(backtrace) => write!(f, "\n Backtrace:\n{backtrace}")?,
452 None => write!(f, "\n Backtrace: {:?}", self.backtrace.status())?,
453 }
454 Ok(())
455 }
456}
457
458impl StdError for AtmError {
459 fn source(&self) -> Option<&(dyn StdError + 'static)> {
460 self.source
461 .as_deref()
462 .map(|source| source as &(dyn StdError + 'static))
463 }
464}
465
466impl From<serde_json::Error> for AtmError {
467 fn from(source: serde_json::Error) -> Self {
468 Self::new(AtmErrorKind::Serialization, format!("json error: {source}"))
469 .with_recovery(
470 "Inspect the JSON payload for structural errors and verify the schema matches the expected format.",
471 )
472 .with_source(source)
473 }
474}
475
476impl From<toml::de::Error> for AtmError {
477 fn from(source: toml::de::Error) -> Self {
478 Self::new(AtmErrorKind::Config, format!("toml error: {source}"))
479 .with_recovery(
480 "Inspect the TOML file for syntax errors and verify all required fields are present.",
481 )
482 .with_source(source)
483 }
484}
485
486impl AtmErrorKind {
487 const fn default_code(self) -> AtmErrorCode {
488 match self {
489 Self::Config => AtmErrorCode::ConfigParseFailed,
490 Self::MissingDocument => AtmErrorCode::ConfigTeamMissing,
491 Self::Address => AtmErrorCode::AddressParseFailed,
492 Self::Identity => AtmErrorCode::IdentityUnavailable,
493 Self::DaemonUnavailable => AtmErrorCode::DaemonUnavailable,
494 Self::TeamNotFound => AtmErrorCode::TeamNotFound,
495 Self::AgentNotFound => AtmErrorCode::AgentNotFound,
496 Self::MailboxLock => AtmErrorCode::MailboxLockFailed,
497 Self::MailboxRead => AtmErrorCode::MailboxReadFailed,
498 Self::MailboxWrite => AtmErrorCode::MailboxWriteFailed,
499 Self::FilePolicy => AtmErrorCode::FilePolicyRejected,
500 Self::Internal => AtmErrorCode::InternalError,
501 Self::Validation => AtmErrorCode::MessageValidationFailed,
502 Self::Serialization => AtmErrorCode::SerializationFailed,
503 Self::Timeout => AtmErrorCode::WaitTimeout,
504 Self::ObservabilityEmit => AtmErrorCode::ObservabilityEmitFailed,
505 Self::ObservabilityBootstrap => AtmErrorCode::ObservabilityBootstrapFailed,
506 Self::ObservabilityQuery => AtmErrorCode::ObservabilityQueryFailed,
507 Self::ObservabilityFollow => AtmErrorCode::ObservabilityFollowFailed,
508 Self::ObservabilityHealth => AtmErrorCode::ObservabilityHealthFailed,
509 }
510 }
511}
512
513#[cfg(test)]
514mod tests {
515 use super::{AtmError, AtmErrorKind};
516 use std::error::Error;
517 use std::fmt;
518
519 #[derive(Debug)]
520 struct LeafError(&'static str);
521
522 impl fmt::Display for LeafError {
523 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
524 f.write_str(self.0)
525 }
526 }
527
528 impl Error for LeafError {}
529
530 #[derive(Debug)]
531 struct ParentError {
532 message: &'static str,
533 source: LeafError,
534 }
535
536 impl fmt::Display for ParentError {
537 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
538 f.write_str(self.message)
539 }
540 }
541
542 impl Error for ParentError {
543 fn source(&self) -> Option<&(dyn Error + 'static)> {
544 Some(&self.source)
545 }
546 }
547
548 #[test]
549 fn display_includes_recovery_source_chain_and_backtrace_label() {
550 let error = AtmError::new(AtmErrorKind::Validation, "storage contract failed")
551 .with_recovery("Repair the contract fixture.")
552 .with_source(ParentError {
553 message: "parent source",
554 source: LeafError("leaf source"),
555 });
556
557 let rendered = error.to_string();
558
559 assert!(rendered.contains("storage contract failed"));
560 assert!(rendered.contains("Recovery: Repair the contract fixture."));
561 assert!(rendered.contains("Source: parent source"));
562 assert!(rendered.contains("Caused by: leaf source"));
563 assert!(rendered.contains("Backtrace:"));
564 }
565}