Skip to main content

atm_core/clear/
mod.rs

1use std::path::PathBuf;
2use std::time::Duration;
3
4use chrono::{DateTime, TimeDelta, Utc};
5use serde::{Deserialize, Serialize};
6use serde_json::Value;
7use tracing::debug;
8
9use crate::boundary;
10use crate::error::AtmError;
11use crate::mailbox::source::ResolvedTarget;
12use crate::mailbox::source::resolve_target;
13use crate::observability::{CommandEvent, ObservabilityPort, action_name, outcome_label};
14use crate::read::state;
15use crate::schema::InboxMessage;
16use crate::service_runtime::{LocalServiceRuntime, RetainedServiceRuntime};
17use crate::service_runtime_store::{RetainedMailboxRuntime, default_runtime};
18use crate::types::{AgentName, CommandAction, IsoTimestamp, MessageClass, TeamName};
19
20/// Parameters for clearing read or acknowledged mailbox messages.
21#[derive(Debug, Clone, Serialize, Deserialize)]
22pub struct ClearQuery {
23    pub home_dir: PathBuf,
24    pub current_dir: PathBuf,
25    pub caller_identity: AgentName,
26    pub caller_team: TeamName,
27    pub older_than: Option<Duration>,
28    pub idle_only: bool,
29    pub dry_run: bool,
30}
31
32/// Counts of removed mailbox messages by ATM display class.
33#[derive(Debug, Clone, Default, Serialize, Deserialize)]
34pub struct RemovedByClass {
35    pub acknowledged: usize,
36    pub read: usize,
37}
38
39/// Result of one mailbox cleanup command.
40#[derive(Debug, Clone, Serialize, Deserialize)]
41pub struct ClearOutcome {
42    pub action: CommandAction,
43    pub team: TeamName,
44    pub agent: AgentName,
45    pub removed_total: usize,
46    pub remaining_total: usize,
47    pub removed_by_class: RemovedByClass,
48}
49
50/// Remove read or acknowledged messages from one mailbox surface.
51///
52/// # Errors
53///
54/// Returns [`AtmError`] with
55/// [`crate::error_codes::AtmErrorCode::IdentityUnavailable`],
56/// [`crate::error_codes::AtmErrorCode::TeamUnavailable`],
57/// [`crate::error_codes::AtmErrorCode::TeamNotFound`],
58/// [`crate::error_codes::AtmErrorCode::AgentNotFound`],
59/// [`crate::error_codes::AtmErrorCode::AddressParseFailed`],
60/// [`crate::error_codes::AtmErrorCode::MailboxReadFailed`],
61/// [`crate::error_codes::AtmErrorCode::MailboxWriteFailed`],
62/// [`crate::error_codes::AtmErrorCode::MailboxLockFailed`],
63/// [`crate::error_codes::AtmErrorCode::MailboxLockTimeout`], or
64/// [`crate::error_codes::AtmErrorCode::MessageValidationFailed`] when actor or
65/// target resolution fails, the team or agent cannot be validated, shared
66/// mailbox locks cannot be acquired, or the selected source files cannot be
67/// persisted safely.
68pub fn clear_mail(
69    query: ClearQuery,
70    observability: &dyn ObservabilityPort,
71) -> Result<ClearOutcome, AtmError> {
72    let runtime = default_runtime()?;
73    clear_mail_with_runtime(query, observability, &runtime)
74}
75
76pub fn clear_mail_with_runtime(
77    query: ClearQuery,
78    observability: &dyn ObservabilityPort,
79    runtime: &LocalServiceRuntime,
80) -> Result<ClearOutcome, AtmError> {
81    clear_mail_with_runtime_impl(query, observability, runtime)
82}
83
84struct ClearRuntimeContext {
85    actor: AgentName,
86    target: ResolvedTarget,
87    metadata_rows: Vec<boundary::MailStoreMailboxMetadataRow>,
88    removable: Vec<(boundary::MessageKey, InboxMessage, MessageClass)>,
89}
90
91fn clear_mail_with_runtime_impl<R: RetainedServiceRuntime + RetainedMailboxRuntime>(
92    query: ClearQuery,
93    observability: &dyn ObservabilityPort,
94    runtime: &R,
95) -> Result<ClearOutcome, AtmError> {
96    let context = load_clear_runtime_context(runtime, &query)?;
97    if !query.dry_run {
98        persist_deleted_messages(runtime, &context.target, &context.removable)?;
99    }
100    let mut removed_by_class = RemovedByClass::default();
101    for (_, _, class) in &context.removable {
102        count_removed(&mut removed_by_class, *class);
103    }
104    let removed_total = context.removable.len();
105    let remaining_total = context.metadata_rows.len().saturating_sub(removed_total);
106
107    let outcome = ClearOutcome {
108        action: CommandAction::Clear,
109        team: context.target.team.clone(),
110        agent: context.target.agent.clone(),
111        removed_total,
112        remaining_total,
113        removed_by_class,
114    };
115
116    if let Err(error) = observability.emit(CommandEvent {
117        command: "clear",
118        action: action_name("clear"),
119        outcome: outcome_label(if query.dry_run { "dry_run" } else { "ok" }),
120        team: outcome.team.clone(),
121        agent: outcome.agent.clone(),
122        sender: context.actor,
123        message_id: None,
124        requires_ack: false,
125        dry_run: query.dry_run,
126        task_id: None,
127        error_code: None,
128        error_message: None,
129    }) {
130        tracing::warn!(%error, command = "clear", action = "clear", "failed to emit clear command event");
131    }
132
133    Ok(outcome)
134}
135
136fn load_clear_runtime_context<R: RetainedServiceRuntime + RetainedMailboxRuntime>(
137    runtime: &R,
138    query: &ClearQuery,
139) -> Result<ClearRuntimeContext, AtmError> {
140    let config = runtime.load_config(&query.current_dir)?;
141    let actor = query.caller_identity.clone();
142    let target = resolve_target(None, &actor, &query.caller_team, config.as_ref())?;
143
144    validate_clear_target(runtime, &query.home_dir, &target)?;
145
146    let cutoff = cutoff_timestamp(query.older_than)?;
147    let metadata_rows =
148        runtime.query_mailbox_metadata_rows(&query.home_dir, &target.team, &target.agent, None)?;
149    let removable = sqlite_removable_messages(
150        runtime,
151        &query.home_dir,
152        &target.team,
153        &target.agent,
154        &metadata_rows,
155        cutoff,
156        query.idle_only,
157    )?;
158    Ok(ClearRuntimeContext {
159        actor,
160        target,
161        metadata_rows,
162        removable,
163    })
164}
165
166fn validate_clear_target<R: RetainedServiceRuntime>(
167    runtime: &R,
168    home_dir: &std::path::Path,
169    target: &ResolvedTarget,
170) -> Result<(), AtmError> {
171    let team_dir = runtime.team_dir(home_dir, &target.team)?;
172    if !team_dir.exists() {
173        return Err(AtmError::team_not_found(&target.team).with_recovery(
174            "Create the team config for the requested team or target a different team before retrying `atm clear`.",
175        ));
176    }
177
178    Ok(())
179}
180
181fn persist_deleted_messages<R: RetainedMailboxRuntime>(
182    runtime: &R,
183    target: &ResolvedTarget,
184    removable: &[(boundary::MessageKey, InboxMessage, MessageClass)],
185) -> Result<(), AtmError> {
186    let deleted_at = IsoTimestamp::now();
187    for (message_key, envelope, _) in removable {
188        runtime.persist_message_state(boundary::MailMessageState {
189            team: target.team.clone(),
190            agent: target.agent.clone(),
191            actor: target.agent.clone(),
192            message_key: message_key.clone(),
193            read: envelope.read,
194            pending_ack_at: envelope.pending_ack_at,
195            acknowledged_at: envelope.acknowledged_at,
196            expires_at: envelope.expires_at,
197            deleted_at: Some(deleted_at),
198            updated_at: Some(deleted_at),
199        })?;
200    }
201    Ok(())
202}
203
204fn sqlite_removable_messages<R: RetainedMailboxRuntime>(
205    runtime: &R,
206    home_dir: &std::path::Path,
207    team: &TeamName,
208    agent: &AgentName,
209    metadata_rows: &[boundary::MailStoreMailboxMetadataRow],
210    cutoff: Option<DateTime<Utc>>,
211    idle_only: bool,
212) -> Result<Vec<(boundary::MessageKey, InboxMessage, MessageClass)>, AtmError> {
213    let mut removable = Vec::new();
214
215    for row in metadata_rows {
216        if cutoff
217            .map(|cutoff| row.message_at.into_inner() > cutoff)
218            .unwrap_or(false)
219        {
220            continue;
221        }
222
223        let Some(record) = runtime.load_message_record(home_dir, team, agent, &row.message_key)?
224        else {
225            return Err(AtmError::validation(format!(
226                "sqlite mailbox metadata row {} could not be reloaded for clear",
227                row.message_key
228            ))
229            .with_recovery(
230                "Repair or remove the malformed sqlite mailbox row before retrying `atm clear`.",
231            ));
232        };
233
234        let class = state::classify_message(&record.envelope);
235        if !matches!(class, MessageClass::Read | MessageClass::Acknowledged) {
236            continue;
237        }
238        if idle_only && !is_idle_notification(&record.envelope) {
239            continue;
240        }
241        removable.push((row.message_key.clone(), record.envelope, class));
242    }
243
244    Ok(removable)
245}
246
247fn cutoff_timestamp(
248    older_than: Option<Duration>,
249) -> Result<Option<chrono::DateTime<Utc>>, AtmError> {
250    older_than
251        .map(|duration| {
252            TimeDelta::from_std(duration).map_err(|error| {
253                AtmError::validation(format!("invalid duration filter: {error}")).with_recovery(
254                    "Use --older-than with a positive duration like 30s, 10m, 2h, or 7d.",
255                )
256            })
257        })
258        .transpose()
259        .map(|delta| delta.map(|delta| Utc::now() - delta))
260}
261
262fn is_idle_notification(message: &InboxMessage) -> bool {
263    // Claude Code currently defines idle notifications as JSON encoded in the
264    // native `text` field. Do not replace this with an ATM-local schema here;
265    // any ownership change must be documented in docs/claude-code-message-schema.md.
266    match serde_json::from_str::<Value>(&message.text) {
267        Ok(value) => value.get("type").and_then(Value::as_str) == Some("idle_notification"),
268        Err(error) => {
269            if message.text.contains("idle_notification") {
270                debug!(
271                    %error,
272                    recovery = "Repair or remove the malformed Claude idle-notification JSON. ATM clear will continue treating the record as a normal mailbox message.",
273                    message_text = %message.text,
274                    "ignoring malformed idle-notification JSON while classifying clear surface"
275                );
276            }
277            false
278        }
279    }
280}
281
282fn count_removed(counts: &mut RemovedByClass, class: MessageClass) {
283    match class {
284        MessageClass::Unread => unreachable!("unread messages are never clearable"),
285        MessageClass::PendingAck => unreachable!("pending-ack messages are never clearable"),
286        MessageClass::Acknowledged => counts.acknowledged += 1,
287        MessageClass::Read => counts.read += 1,
288    }
289}
290
291#[cfg(test)]
292mod tests {
293    use std::{
294        ffi::OsString,
295        panic,
296        panic::AssertUnwindSafe,
297        path::{Path, PathBuf},
298    };
299
300    use crate::test_support::{EnvGuard, lock_env, remove_env_var, set_env_var};
301    use serde_json::Map;
302    use tempfile::tempdir;
303
304    use super::{ClearQuery, clear_mail_with_runtime_impl};
305    use crate::boundary::{self, RosterHarness, RosterMemberKind};
306    use crate::error::AtmError;
307    use crate::observability::NullObservability;
308    use crate::schema::InboxMessage;
309    use crate::service_runtime::RetainedServiceRuntime;
310    use crate::service_runtime_store::RetainedMailboxRuntime;
311    use crate::test_support::{TEST_SENDER, TEST_TEAM};
312    use crate::types::{AgentName, TeamName};
313    #[test]
314    #[serial_test::serial(env)]
315    fn env_guard_restores_original_value_after_panic() {
316        {
317            let _env_lock = lock_env();
318            set_env_var("ATM_TEST_REMOVE_LOCKED_INBOX_BEFORE_LOAD", "original");
319        }
320
321        let result = panic::catch_unwind(AssertUnwindSafe(|| {
322            let _guard = EnvGuard::set_raw("ATM_TEST_REMOVE_LOCKED_INBOX_BEFORE_LOAD", "1");
323            panic!("boom");
324        }));
325
326        assert!(
327            result.is_err(),
328            "panic should propagate through catch_unwind"
329        );
330        assert_eq!(
331            std::env::var_os("ATM_TEST_REMOVE_LOCKED_INBOX_BEFORE_LOAD"),
332            Some(OsString::from("original"))
333        );
334        {
335            let _env_lock = lock_env();
336            remove_env_var("ATM_TEST_REMOVE_LOCKED_INBOX_BEFORE_LOAD");
337        }
338    }
339
340    struct ClearRuntime {
341        team_dir: PathBuf,
342        roster_present: bool,
343    }
344
345    impl crate::boundary::sealed::Sealed for ClearRuntime {}
346
347    impl RetainedServiceRuntime for ClearRuntime {
348        fn load_config(
349            &self,
350            _current_dir: &Path,
351        ) -> Result<Option<crate::config::AtmConfig>, AtmError> {
352            Ok(None)
353        }
354
355        fn load_team_config_for_doctor_compare(
356            &self,
357            _team_dir: &Path,
358        ) -> Result<crate::schema::TeamConfig, AtmError> {
359            unreachable!("clear roster-truth tests must not load team config")
360        }
361
362        fn team_dir(&self, _home_dir: &Path, _team: &TeamName) -> Result<PathBuf, AtmError> {
363            Ok(self.team_dir.clone())
364        }
365
366        fn inbox_path(
367            &self,
368            _home_dir: &Path,
369            _team: &TeamName,
370            _agent: &AgentName,
371        ) -> Result<PathBuf, AtmError> {
372            unreachable!("clear roster-truth tests do not resolve inbox paths")
373        }
374
375        fn load_seen_watermark(
376            &self,
377            _home_dir: &Path,
378            _team: &TeamName,
379            _agent: &AgentName,
380        ) -> Result<Option<crate::types::IsoTimestamp>, AtmError> {
381            Ok(None)
382        }
383
384        fn save_seen_watermark(
385            &self,
386            _home_dir: &Path,
387            _team: &TeamName,
388            _agent: &AgentName,
389            _timestamp: crate::types::IsoTimestamp,
390        ) -> Result<(), AtmError> {
391            Ok(())
392        }
393
394        fn rebuild_compat_inbox_projection(
395            &self,
396            _inbox_path: &Path,
397            _team: &TeamName,
398            _agent: &AgentName,
399        ) -> Result<(), AtmError> {
400            unreachable!("clear roster-truth tests do not rebuild projections")
401        }
402
403        fn deliver_non_claude_payloads(
404            &self,
405            _recipient: &crate::delivery_policy::DeliveryRecipientSnapshot,
406            _messages: &[InboxMessage],
407        ) -> Result<(), AtmError> {
408            unreachable!("clear roster-truth tests do not route outbound payloads")
409        }
410
411        fn load_roster_member(
412            &self,
413            team: &TeamName,
414            agent: &AgentName,
415        ) -> Result<Option<boundary::RosterEntry>, AtmError> {
416            Ok(self.roster_present.then(|| boundary::RosterEntry {
417                team_name: team.clone(),
418                agent_name: agent.clone(),
419                member_kind: RosterMemberKind::Permanent,
420                harness: RosterHarness::ClaudeCode,
421                agent_type: crate::schema::AgentType::default(),
422                model: crate::types::ModelName::default(),
423                recipient_pane_id: None,
424                metadata_json: Map::new(),
425            }))
426        }
427
428        fn load_team_roster(
429            &self,
430            _team: &TeamName,
431        ) -> Result<Vec<boundary::RosterEntry>, AtmError> {
432            Ok(Vec::new())
433        }
434    }
435
436    impl RetainedMailboxRuntime for ClearRuntime {
437        fn query_mailbox_metadata_rows(
438            &self,
439            _home_dir: &Path,
440            _team: &TeamName,
441            _agent: &AgentName,
442            _limit: Option<usize>,
443        ) -> Result<Vec<boundary::MailStoreMailboxMetadataRow>, AtmError> {
444            Ok(Vec::new())
445        }
446
447        fn load_message_record(
448            &self,
449            _home_dir: &Path,
450            _team: &TeamName,
451            _agent: &AgentName,
452            _message_key: &boundary::MessageKey,
453        ) -> Result<Option<boundary::Message>, AtmError> {
454            unreachable!("clear roster-truth tests do not load message records")
455        }
456
457        fn persist_message_record(&self, _record: boundary::Message) -> Result<(), AtmError> {
458            unreachable!("clear roster-truth tests do not persist message records")
459        }
460
461        fn persist_message_state(
462            &self,
463            _state: boundary::MailMessageState,
464        ) -> Result<(), AtmError> {
465            unreachable!("clear roster-truth tests do not persist message state")
466        }
467    }
468
469    fn clear_query(home_dir: PathBuf, current_dir: PathBuf) -> ClearQuery {
470        ClearQuery {
471            home_dir,
472            current_dir,
473            caller_identity: AgentName::from_validated(TEST_SENDER),
474            caller_team: TeamName::from_validated(TEST_TEAM),
475            older_than: None,
476            idle_only: false,
477            dry_run: true,
478        }
479    }
480
481    #[test]
482    fn clear_mail_targets_only_the_owner_mailbox() {
483        let tempdir = tempdir().expect("tempdir");
484        let team_dir = tempdir.path().join(".claude").join("teams").join(TEST_TEAM);
485        std::fs::create_dir_all(&team_dir).expect("team dir");
486        let runtime = ClearRuntime {
487            team_dir,
488            roster_present: true,
489        };
490
491        let outcome = clear_mail_with_runtime_impl(
492            clear_query(tempdir.path().to_path_buf(), tempdir.path().to_path_buf()),
493            &NullObservability,
494            &runtime,
495        )
496        .expect("clear outcome");
497
498        assert_eq!(outcome.team, TeamName::from_validated(TEST_TEAM));
499        assert_eq!(outcome.agent, AgentName::from_validated(TEST_SENDER));
500        assert_eq!(outcome.removed_total, 0);
501    }
502}