Skip to main content

atm_storage/
error.rs

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}