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#[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#[derive(Debug, Clone, Default, Serialize, Deserialize)]
34pub struct RemovedByClass {
35 pub acknowledged: usize,
36 pub read: usize,
37}
38
39#[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
50pub 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 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}