1use amont_agent_guard::journal::{self, Entry};
32use amont_agent_guard::ui;
33use std::collections::BTreeMap;
34use std::ffi::OsString;
35use std::fs;
36use std::io::{self, IsTerminal, Write};
37use std::path::{Path, PathBuf};
38use std::process::ExitCode;
39use std::time::{Duration, UNIX_EPOCH};
40
41pub const DAY: u64 = 86_400;
42const KEEP_FOR: u64 = 30 * DAY;
43const TRASH_FOR: u64 = 7 * DAY;
44const LOCK_STALE: u64 = 3600;
45const GIT_ROOTS_KEY: &str = r"^amont\.agent\.plan-review\..*\.path$";
46
47#[derive(Debug, Clone, PartialEq, Eq)]
49pub struct Failure {
50 pub path: String,
51 pub err: String,
52}
53
54fn fail(path: &Path, e: &io::Error) -> Failure {
55 Failure {
56 path: path.display().to_string(),
57 err: e.to_string(),
58 }
59}
60
61pub struct Env {
63 pub store: PathBuf,
65 pub state: PathBuf,
67 pub plan_root: Result<PathBuf, String>,
69 pub claude_plans: PathBuf,
71 pub git: Option<Result<Vec<PathBuf>, String>>,
74}
75
76impl Env {
77 pub fn live() -> Option<Env> {
78 let state = journal::dir()?;
79 Some(Env {
80 store: state.join("attestations"),
81 plan_root: crate::plan_review::root().ok_or_else(|| "no home directory".to_string()),
82 claude_plans: amont_agent_guard::settings::config_dir()?.join("plans"),
83 state,
84 git: None,
85 })
86 }
87 fn stamp(&self) -> PathBuf {
88 self.state.join("attestations.swept")
89 }
90 fn lock(&self) -> PathBuf {
91 self.state.join("attestations.sweeping")
92 }
93}
94
95pub fn now() -> u64 {
96 std::time::SystemTime::now()
97 .duration_since(UNIX_EPOCH)
98 .map(|d| d.as_secs())
99 .unwrap_or(0)
100}
101
102fn citations(text: &str, mut found: impl FnMut(&str)) {
109 const KEY: &str = "attestations/";
110 let mut rest = text;
111 while let Some(i) = rest.find(KEY) {
112 let after = &rest[i + KEY.len()..];
113 let end = after
114 .find(|c: char| !(c.is_ascii_alphanumeric() || matches!(c, '.' | '_' | '-')))
115 .unwrap_or(after.len());
116 let name = after[..end].trim_end_matches('.');
117 if !name.is_empty() {
118 found(name);
119 }
120 rest = after;
121 }
122}
123
124fn is_hex(s: &str) -> bool {
125 !s.is_empty() && s.bytes().all(|b| b.is_ascii_hexdigit())
126}
127
128fn names_match(cited: &str, dir: &str) -> bool {
132 cited == dir
133 || (cited.len() >= 7
134 && is_hex(cited)
135 && dir.len() == 40
136 && is_hex(dir)
137 && dir.starts_with(cited))
138}
139
140type Cited = BTreeMap<String, String>;
141
142fn sorted_entries(dir: &Path) -> Result<Vec<fs::DirEntry>, Failure> {
143 let mut v = fs::read_dir(dir)
144 .map_err(|e| fail(dir, &e))?
145 .collect::<Result<Vec<_>, _>>()
146 .map_err(|e| fail(dir, &e))?;
147 v.sort_by_key(|e| e.file_name());
148 Ok(v)
149}
150
151fn scan_file(path: &Path, base: &Path, out: &mut Cited) -> Result<(), Failure> {
152 let bytes = fs::read(path).map_err(|e| fail(path, &e))?;
153 let text = String::from_utf8_lossy(&bytes);
154 let shown = path
155 .strip_prefix(base)
156 .unwrap_or(path)
157 .display()
158 .to_string();
159 citations(&text, |name| {
160 out.entry(name.to_string()).or_insert_with(|| shown.clone());
161 });
162 Ok(())
163}
164
165fn scan_docs(dir: &Path, base: &Path, out: &mut Cited) -> Result<(), Failure> {
167 for e in sorted_entries(dir)? {
168 let path = e.path();
169 let ft = e.file_type().map_err(|err| fail(&path, &err))?;
170 if ft.is_dir() {
171 scan_docs(&path, base, out)?;
172 } else if ft.is_file() && path.extension().is_some_and(|x| x == "md") {
173 scan_file(&path, base, out)?;
174 }
175 }
176 Ok(())
177}
178
179fn scan_root(root: &Path, base: &Path, out: &mut Cited) -> Result<(), Failure> {
181 let docs = root.join("docs");
182 match fs::metadata(&docs) {
183 Ok(m) if m.is_dir() => scan_docs(&docs, base, out),
184 Ok(_) => Ok(()),
185 Err(e)
186 if matches!(
187 e.kind(),
188 io::ErrorKind::NotFound | io::ErrorKind::NotADirectory
189 ) =>
190 {
191 Ok(())
192 }
193 Err(e) => Err(fail(&docs, &e)),
194 }
195}
196
197fn git_roots() -> Result<Vec<PathBuf>, Failure> {
201 let args = [
202 "config",
203 "--global",
204 "--type=path",
205 "--get-regexp",
206 GIT_ROOTS_KEY,
207 ];
208 let bad = |err: String| Failure {
209 path: "git config --global".to_string(),
210 err,
211 };
212 let out = amont_agent_guard::git::output(&args).ok_or_else(|| bad("git did not run".into()))?;
213 roots_from(out.code, &out.stdout, &out.stderr).map_err(bad)
214}
215
216fn roots_from(code: i32, stdout: &str, stderr: &str) -> Result<Vec<PathBuf>, String> {
220 match code {
221 0 => Ok(stdout
222 .lines()
223 .filter_map(|l| l.split_once(' ').map(|(_, v)| PathBuf::from(v.trim())))
224 .collect()),
225 1 if stdout.trim().is_empty() => Ok(Vec::new()),
226 c => Err(format!("exit {c}: {}", stderr.trim())),
227 }
228}
229
230fn scan(env: &Env) -> Result<Cited, Failure> {
231 let mut out = Cited::new();
232 let mut seen: Vec<PathBuf> = Vec::new();
233 match &env.plan_root {
234 Err(why) => {
235 return Err(Failure {
236 path: "plan root".into(),
237 err: why.clone(),
238 })
239 }
240 Ok(root) => {
241 for e in sorted_entries(root)? {
242 let p = e.path();
243 scan_root(&p, root, &mut out)?;
244 seen.push(p);
245 }
246 }
247 }
248 let base = env.plan_root.as_deref().unwrap_or(Path::new(""));
249 let configured = match &env.git {
250 Some(Ok(v)) => v.clone(),
251 Some(Err(why)) => {
252 return Err(Failure {
253 path: "git config --global".into(),
254 err: why.clone(),
255 })
256 }
257 None => git_roots()?,
258 };
259 for p in configured {
260 if seen.contains(&p) {
261 continue;
262 }
263 if let Err(e) = fs::metadata(&p) {
264 return Err(fail(&p, &e));
265 }
266 scan_root(&p, base, &mut out)?;
267 seen.push(p);
268 }
269 match fs::read_dir(&env.claude_plans) {
270 Ok(rd) => {
271 let mut files = rd
272 .collect::<Result<Vec<_>, _>>()
273 .map_err(|e| fail(&env.claude_plans, &e))?;
274 files.sort_by_key(|e| e.file_name());
275 for e in files {
276 let p = e.path();
277 let ft = e.file_type().map_err(|err| fail(&p, &err))?;
278 if ft.is_file() && p.extension().is_some_and(|x| x == "md") {
279 scan_file(&p, &env.claude_plans, &mut out)?;
280 }
281 }
282 }
283 Err(e) if e.kind() == io::ErrorKind::NotFound => {}
285 Err(e) => return Err(fail(&env.claude_plans, &e)),
286 }
287 Ok(out)
288}
289
290struct Probe {
294 newest: u64,
295 bytes: u64,
296 doubtful: bool,
298}
299
300fn walk(path: &Path, now: u64, p: &mut Probe) {
301 let Ok(rd) = fs::read_dir(path) else {
302 p.doubtful = true;
303 return;
304 };
305 for e in rd {
306 let Ok(e) = e else {
307 p.doubtful = true;
308 continue;
309 };
310 let child = e.path();
311 let Ok(meta) = fs::symlink_metadata(&child) else {
312 p.doubtful = true;
313 continue;
314 };
315 note(&meta, now, p);
316 if meta.is_dir() {
317 walk(&child, now, p);
318 } else {
319 p.bytes += meta.len();
320 }
321 }
322}
323
324fn note(meta: &fs::Metadata, now: u64, p: &mut Probe) {
325 match meta
326 .modified()
327 .ok()
328 .and_then(|t| t.duration_since(UNIX_EPOCH).ok())
329 {
330 Some(d) => {
331 p.newest = p.newest.max(d.as_secs());
332 if d.as_secs() > now {
333 p.doubtful = true;
334 }
335 }
336 None => p.doubtful = true,
337 }
338}
339
340pub struct Row {
341 pub name: String,
342 pub age: u64,
343 pub bytes: u64,
344 pub remove: bool,
345 pub reason: String,
346 pub cited: bool,
347}
348
349fn rows(env: &Env, now: u64, cited: Result<&Cited, &Failure>) -> Result<Vec<Row>, Failure> {
350 let entries = match sorted_entries(&env.store) {
351 Ok(v) => v,
352 Err(_) if !env.store.exists() => return Ok(Vec::new()),
353 Err(f) => return Err(f),
354 };
355 let mut out = Vec::new();
356 for e in entries {
357 let name = e.file_name().to_string_lossy().into_owned();
358 if name.starts_with('.') {
359 continue;
360 }
361 if !e.file_type().is_ok_and(|t| t.is_dir()) {
363 continue;
364 }
365 let path = e.path();
366 let mut probe = Probe {
367 newest: 0,
368 bytes: 0,
369 doubtful: false,
370 };
371 match fs::symlink_metadata(&path) {
372 Ok(m) => note(&m, now, &mut probe),
373 Err(_) => probe.doubtful = true,
374 }
375 walk(&path, now, &mut probe);
376 let age = if probe.doubtful {
377 0
378 } else {
379 now.saturating_sub(probe.newest)
380 };
381 let cite = cited.ok().and_then(|c| {
382 c.iter()
383 .find(|(k, _)| names_match(k, &name))
384 .map(|(_, v)| v)
385 });
386 let keep_file = fs::symlink_metadata(path.join(".keep")).is_ok();
387 let (remove, reason) = if probe.doubtful || age < KEEP_FOR {
388 (
389 false,
390 format!("recent {}", amont_agent_guard::doctor::ago(age)),
391 )
392 } else if let Some(by) = cite {
393 (false, format!("cited by {by}"))
394 } else if keep_file {
395 (false, ".keep".to_string())
396 } else if let Err(f) = cited {
397 (false, format!("held (scan failed: {})", f.path))
398 } else {
399 (
400 true,
401 format!("older than {}", amont_agent_guard::doctor::ago(KEEP_FOR)),
402 )
403 };
404 out.push(Row {
405 name,
406 age,
407 bytes: probe.bytes,
408 remove,
409 reason,
410 cited: cite.is_some(),
411 });
412 }
413 Ok(out)
414}
415
416pub struct Report {
417 pub rows: Vec<Row>,
418 pub failure: Option<Failure>,
419 pub removed: Vec<(String, u64)>,
421 pub errors: Vec<(String, String)>,
423 pub purged: usize,
424}
425
426fn purge(store: &Path, now: u64) -> (usize, Vec<(String, String)>) {
431 let mut errors = Vec::new();
432 let rd = match fs::read_dir(store) {
433 Ok(rd) => rd,
434 Err(e) if e.kind() == io::ErrorKind::NotFound => return (0, errors),
435 Err(e) => {
436 errors.push((store.display().to_string(), e.to_string()));
437 return (0, errors);
438 }
439 };
440 let mut n = 0;
441 for e in rd {
442 let e = match e {
443 Ok(e) => e,
444 Err(err) => {
445 errors.push((store.display().to_string(), err.to_string()));
446 continue;
447 }
448 };
449 let name = e.file_name().to_string_lossy().into_owned();
450 let Some(rest) = name.strip_prefix(".trash-") else {
451 continue;
452 };
453 let Some((secs, _)) = rest.split_once('-') else {
454 continue;
455 };
456 let Ok(secs) = secs.parse::<u64>() else {
457 continue;
458 };
459 if now.saturating_sub(secs) < TRASH_FOR {
460 continue;
461 }
462 let path = e.path();
463 let gone = match fs::symlink_metadata(&path) {
464 Ok(m) if m.is_dir() => fs::remove_dir_all(&path),
465 Ok(_) => fs::remove_file(&path),
466 Err(e) => Err(e),
467 };
468 match gone {
469 Ok(()) => n += 1,
470 Err(err) if err.kind() == io::ErrorKind::NotFound => {}
471 Err(err) => errors.push((name, err.to_string())),
472 }
473 }
474 (n, errors)
475}
476
477pub fn run(env: &Env, now: u64, apply: bool) -> Report {
479 let scanned = scan(env);
480 let (cited, failure) = match &scanned {
481 Ok(c) => (Ok(c), None),
482 Err(f) => (Err(f), Some(f.clone())),
483 };
484 let mut report = Report {
485 rows: Vec::new(),
486 failure,
487 removed: Vec::new(),
488 errors: Vec::new(),
489 purged: 0,
490 };
491 match rows(env, now, cited) {
492 Ok(r) => report.rows = r,
493 Err(f) => {
494 report.failure.get_or_insert(f);
495 }
496 }
497 if !apply {
498 return report;
499 }
500 let (purged, failed) = purge(&env.store, now);
501 report.purged = purged;
502 report.errors.extend(failed);
503 if report.failure.is_some() {
504 return report;
505 }
506 for row in report.rows.iter_mut().filter(|r| r.remove) {
507 let to = env.store.join(format!(".trash-{now}-{}", row.name));
508 match fs::rename(env.store.join(&row.name), to) {
509 Ok(()) => report.removed.push((row.name.clone(), row.bytes)),
510 Err(e) if e.kind() == io::ErrorKind::NotFound => row.remove = false,
512 Err(e) => {
513 row.remove = false;
514 report.errors.push((row.name.clone(), e.to_string()));
515 }
516 }
517 }
518 report
519}
520
521fn record(outcome: &str, excerpt: &str) {
522 journal::record(&Entry {
523 rule: "-",
524 stance: "-",
525 outcome,
526 session: "-",
527 repo: "-",
528 mode: "-",
529 excerpt,
530 });
531}
532
533fn journal_report(r: &Report) {
536 for (name, bytes) in &r.removed {
537 record("attestation-swept", &format!("{name} {bytes}"));
538 }
539 let gone: Vec<&str> = r.removed.iter().map(|(n, _)| n.as_str()).collect();
540 let kept: Vec<&Row> = r
541 .rows
542 .iter()
543 .filter(|x| !gone.contains(&x.name.as_str()))
544 .collect();
545 let bytes: u64 = kept.iter().map(|x| x.bytes).sum();
546 record("attestations-kept", &format!("{} {bytes}", kept.len()));
547 if let Some(f) = &r.failure {
548 record("attestations-scan-failed", &f.path);
549 }
550}
551
552pub struct Lock(PathBuf);
555
556impl Drop for Lock {
557 fn drop(&mut self) {
558 let _ = fs::remove_file(&self.0);
559 }
560}
561
562fn age_of(path: &Path, now: u64) -> Option<u64> {
563 let m = fs::symlink_metadata(path).ok()?.modified().ok()?;
564 let secs = m.duration_since(UNIX_EPOCH).ok()?.as_secs();
565 Some(now.saturating_sub(secs))
566}
567
568fn create_lock(path: &Path) -> io::Result<Lock> {
569 create_lock_file(path).map(|_| Lock(path.to_path_buf()))
570}
571
572pub fn acquire(lock: &Path, now: u64) -> Option<Lock> {
574 if let Some(dir) = lock.parent() {
575 let _ = fs::create_dir_all(dir);
576 }
577 for _ in 0..2 {
578 match create_lock(lock) {
579 Ok(l) => return Some(l),
580 Err(e) if e.kind() == io::ErrorKind::AlreadyExists => {}
581 Err(_) => return None,
582 }
583 match age_of(lock, now) {
584 None if !lock.exists() => continue,
586 Some(age) if age >= LOCK_STALE => {}
587 _ => return None,
588 }
589 return take_over(lock, now);
590 }
591 None
592}
593
594fn take_over(lock: &Path, now: u64) -> Option<Lock> {
595 let mut name = lock.as_os_str().to_os_string();
596 name.push(format!(".stale-{}", std::process::id()));
597 let stale = PathBuf::from(name);
598 fs::rename(lock, &stale).ok()?;
600 if age_of(&stale, now).is_some_and(|a| a < LOCK_STALE) {
601 let _ = fs::remove_file(&stale);
611 return None;
612 }
613 let got = create_lock(lock).ok();
614 let _ = fs::remove_file(&stale);
615 got
616}
617
618fn touch(path: &Path, now: u64) -> io::Result<()> {
619 let f = fs::OpenOptions::new()
620 .write(true)
621 .create(true)
622 .truncate(false)
623 .open(path)?;
624 f.set_modified(UNIX_EPOCH + Duration::from_secs(now))
625}
626
627fn stamp_fresh(env: &Env, now: u64) -> bool {
628 age_of(&env.stamp(), now).is_some_and(|a| a < DAY)
629}
630
631fn claim_today(env: &Env, now: u64) -> bool {
638 let day = now / DAY;
639 let mine = format!("attestations.swept.{day}");
640 if create_lock_file(&env.state.join(&mine)).is_err() {
641 return false;
642 }
643 if let Ok(rd) = fs::read_dir(&env.state) {
644 for e in rd.flatten() {
645 let name = e.file_name().to_string_lossy().into_owned();
646 if name.starts_with("attestations.swept.") && name != mine {
647 let _ = fs::remove_file(e.path());
648 }
649 }
650 }
651 true
652}
653
654fn create_lock_file(path: &Path) -> io::Result<fs::File> {
655 fs::OpenOptions::new()
656 .write(true)
657 .create_new(true)
658 .open(path)
659}
660
661pub fn sweep_at(env: &Env, now: u64) -> Option<Report> {
663 if !env.store.is_dir() {
664 return None;
665 }
666 if stamp_fresh(env, now) {
669 return None;
670 }
671 let _lock = acquire(&env.lock(), now)?;
672 if stamp_fresh(env, now) || !claim_today(env, now) {
673 return None;
674 }
675 touch(&env.stamp(), now).ok()?;
677 let report = run(env, now, true);
678 journal_report(&report);
679 Some(report)
680}
681
682pub fn sweep() {
690 let Some(env) = Env::live() else { return };
691 if !env.store.is_dir() || stamp_fresh(&env, now()) {
692 return;
693 }
694 let Ok(exe) = std::env::current_exe() else {
695 return;
696 };
697 let mut cmd = std::process::Command::new(exe);
698 cmd.args(["attestations", "--daily"])
699 .stdin(std::process::Stdio::null())
700 .stdout(std::process::Stdio::null())
701 .stderr(std::process::Stdio::null());
702 #[cfg(unix)]
704 {
705 use std::os::unix::process::CommandExt;
706 cmd.process_group(0);
707 }
708 let _ = cmd.spawn();
709}
710
711const USAGE: &str = "\
714usage: amont-agent attestations [--apply]
715
716 lists each directory under ~/.claude/amont-agent/attestations, why it is
717 kept or would be removed; --apply moves the removable ones to
718 .trash-<secs>-<name>, deleted after 7 days
719
720 --daily internal: the once-a-day run SessionStart starts in the background
721";
722
723fn mb(bytes: u64) -> String {
724 format!("{:.1} MB", bytes as f64 / 1e6)
725}
726
727fn render(rows: &[Row], tty: bool) -> String {
728 let mut ordered: Vec<&Row> = rows.iter().filter(|r| !r.remove).collect();
729 ordered.extend(rows.iter().filter(|r| r.remove));
730 let cells: Vec<[String; 5]> = ordered
731 .iter()
732 .map(|r| {
733 [
734 if r.remove { "remove" } else { "keep" }.to_string(),
735 ui::sanitize(&r.name),
736 amont_agent_guard::doctor::ago(r.age),
737 mb(r.bytes),
738 ui::sanitize(&r.reason),
739 ]
740 })
741 .collect();
742 let mut out = String::new();
743 if !tty {
744 for c in &cells {
745 out.push_str(&c.join("\t"));
746 out.push('\n');
747 }
748 return out;
749 }
750 let width = |i: usize| {
751 cells
752 .iter()
753 .map(|c| c[i].chars().count())
754 .max()
755 .unwrap_or(0)
756 };
757 let (w0, w1, w2, w3) = (width(0), width(1), width(2), width(3));
758 let fixed = w0 + w1 + w2 + w3 + 8;
759 let room = 80usize.saturating_sub(fixed).max(12);
760 for c in &cells {
761 let mut reason = c[4].clone();
762 if reason.chars().count() > room {
763 reason = reason.chars().take(room - 1).collect::<String>() + "…";
764 }
765 out.push_str(&format!(
766 "{:<w0$} {:<w1$} {:>w2$} {:>w3$} {reason}\n",
767 c[0], c[1], c[2], c[3]
768 ));
769 }
770 out
771}
772
773pub fn run_cli(args: &[OsString]) -> ExitCode {
774 if args.iter().any(|a| a == "--help" || a == "-h") {
775 let _ = io::stdout().write_all(USAGE.as_bytes());
776 return ExitCode::SUCCESS;
777 }
778 let mut apply = false;
779 for a in args {
780 match a.to_string_lossy().as_ref() {
781 "--apply" => apply = true,
782 "--daily" => {
784 if let Some(env) = Env::live() {
785 let _ = sweep_at(&env, now());
786 }
787 return ExitCode::SUCCESS;
788 }
789 "--help" | "-h" => {
790 let _ = io::stdout().write_all(USAGE.as_bytes());
791 return ExitCode::SUCCESS;
792 }
793 other => {
794 eprintln!(
795 "amont-agent: unknown argument `{}`; try `amont-agent attestations --help`",
796 ui::sanitize(other)
797 );
798 return ExitCode::from(2);
799 }
800 }
801 }
802 let Some(env) = Env::live() else {
803 eprintln!("amont-agent: no home directory");
804 return ExitCode::FAILURE;
805 };
806 let now = now();
807 let _lock = if apply {
808 match acquire(&env.lock(), now) {
809 Some(l) => Some(l),
810 None => {
811 eprintln!("amont-agent: a sweep is running");
812 return ExitCode::FAILURE;
813 }
814 }
815 } else {
816 None
817 };
818 let report = run(&env, now, apply);
819 if apply {
820 journal_report(&report);
821 }
822 let table = render(&report.rows, io::stdout().is_terminal());
824 let _ = io::stdout().write_all(table.as_bytes());
825
826 let mut code = ExitCode::SUCCESS;
827 if let Some(f) = &report.failure {
828 eprintln!(
829 "amont-agent: {}: {}; no directory removed",
830 ui::sanitize(&f.path),
831 ui::sanitize(&f.err)
832 );
833 code = ExitCode::FAILURE;
834 }
835 for (name, err) in &report.errors {
836 eprintln!("{} {}: {}", ui::error_sign(), ui::sanitize(name), err);
837 code = ExitCode::FAILURE;
838 }
839 let total: u64 = report.rows.iter().map(|r| r.bytes).sum();
840 let (n, bytes) = if apply {
841 (
842 report.removed.len(),
843 report.removed.iter().map(|(_, b)| b).sum::<u64>(),
844 )
845 } else {
846 (
847 report.rows.iter().filter(|r| r.remove).count(),
848 report
849 .rows
850 .iter()
851 .filter(|r| r.remove)
852 .map(|r| r.bytes)
853 .sum(),
854 )
855 };
856 eprintln!("{} directories, {}", report.rows.len(), mb(total));
857 if apply {
858 eprintln!("removed {n} ({}) to .trash-<secs>-<name>", mb(bytes));
859 } else {
860 eprintln!(
861 "nothing removed; amont-agent attestations --apply removes {n} ({})",
862 mb(bytes)
863 );
864 }
865 code
866}
867
868pub struct Summary {
871 pub dirs: usize,
872 pub bytes: u64,
873 pub oldest: u64,
874 pub cited: Option<usize>,
876 pub swept_ago: Option<u64>,
878}
879
880pub fn summary(env: &Env, now: u64) -> Option<Summary> {
881 if !env.store.is_dir() {
882 return None;
883 }
884 let scanned = scan(env);
885 let rows = rows(env, now, scanned.as_ref()).ok()?;
886 Some(Summary {
887 dirs: rows.len(),
888 bytes: rows.iter().map(|r| r.bytes).sum(),
889 oldest: rows.iter().map(|r| r.age).max().unwrap_or(0),
890 cited: scanned
891 .is_ok()
892 .then(|| rows.iter().filter(|r| r.cited).count()),
893 swept_ago: age_of(&env.stamp(), now),
894 })
895}
896
897pub fn last_scan_failure() -> Option<String> {
899 for p in journal::paths().into_iter().rev() {
900 let Ok(text) = fs::read_to_string(&p) else {
901 continue;
902 };
903 for line in text.lines().rev() {
904 let mut f = line.splitn(9, ' ');
905 let outcome = f.nth(4);
906 match outcome {
907 Some("attestations-scan-failed") => {
908 return Some(f.nth(3).unwrap_or("").to_string());
909 }
910 Some("attestations-kept") => return None,
911 _ => {}
912 }
913 }
914 }
915 None
916}
917
918pub fn mb_text(bytes: u64) -> String {
919 mb(bytes)
920}
921
922pub fn doctor_finding() -> Option<amont_agent_guard::doctor::Finding> {
926 use amont_agent_guard::doctor::{ago, now, Finding};
927 let env = Env::live()?;
928 let t = now();
929 let s = summary(&env, t)?;
930 let swept = match s.swept_ago {
931 Some(a) => format!("swept {} ago", ago(a)),
932 None => "never swept".to_string(),
933 };
934 let cited = match s.cited {
935 Some(k) => format!("{k} cited"),
936 None => "cited unknown (scan failed)".to_string(),
937 };
938 let line = format!(
939 "attestations: {} directories, {}, oldest {}; {cited}; {swept}",
940 s.dirs,
941 mb_text(s.bytes),
942 ago(s.oldest),
943 );
944 if let Some(path) = last_scan_failure() {
945 return Some(Finding::warn(
946 line,
947 format!(
948 "the last sweep could not read {path} and removed nothing; \
949 run `amont-agent attestations` to see why"
950 ),
951 ));
952 }
953 if s.swept_ago.is_some_and(|a| a > 2 * DAY) {
954 return Some(Finding::warn(
955 line,
956 "no SessionStart has swept for over 2 days; \
957 run `amont-agent attestations` to see what it would do"
958 .to_string(),
959 ));
960 }
961 Some(Finding::good(line))
962}
963
964#[cfg(test)]
965mod tests {
966 use super::*;
967 use std::sync::atomic::{AtomicU32, Ordering};
968
969 const NOW: u64 = 1_800_000_000;
970
971 struct Scratch(PathBuf);
972 impl Scratch {
973 fn new() -> Scratch {
974 static N: AtomicU32 = AtomicU32::new(0);
975 let d = std::env::temp_dir().join(format!(
976 "amont-agent-att-{}-{}",
977 std::process::id(),
978 N.fetch_add(1, Ordering::SeqCst)
979 ));
980 let _ = fs::remove_dir_all(&d);
981 fs::create_dir_all(d.join("state/attestations")).unwrap();
982 fs::create_dir_all(d.join("root")).unwrap();
983 fs::create_dir_all(d.join("claude/plans")).unwrap();
984 Scratch(d)
985 }
986 fn env(&self) -> Env {
987 Env {
988 store: self.0.join("state/attestations"),
989 state: self.0.join("state"),
990 plan_root: Ok(self.0.join("root")),
991 claude_plans: self.0.join("claude/plans"),
992 git: Some(Ok(Vec::new())),
993 }
994 }
995 fn store(&self) -> PathBuf {
996 self.0.join("state/attestations")
997 }
998 fn dir(&self, name: &str, days: u64) -> PathBuf {
1000 let d = self.store().join(name);
1001 fs::create_dir_all(&d).unwrap();
1002 fs::write(d.join("a.txt"), "12345").unwrap();
1003 set(&d.join("a.txt"), NOW - days * DAY);
1004 set(&d, NOW - days * DAY);
1005 d
1006 }
1007 fn plan(&self, repo: &str, rel: &str, text: &str) {
1008 let f = self.0.join("root").join(repo).join(rel);
1009 fs::create_dir_all(f.parent().unwrap()).unwrap();
1010 fs::write(f, text).unwrap();
1011 }
1012 fn names(&self) -> Vec<String> {
1013 let mut v: Vec<String> = fs::read_dir(self.store())
1014 .unwrap()
1015 .map(|e| e.unwrap().file_name().to_string_lossy().into_owned())
1016 .collect();
1017 v.sort();
1018 v
1019 }
1020 }
1021 impl Drop for Scratch {
1022 fn drop(&mut self) {
1023 #[cfg(unix)]
1024 {
1025 use std::os::unix::fs::PermissionsExt;
1026 let _ = fs::set_permissions(&self.0, fs::Permissions::from_mode(0o755));
1027 }
1028 let _ = fs::remove_dir_all(&self.0);
1029 }
1030 }
1031
1032 fn open_dir(path: &Path) -> fs::File {
1035 #[cfg(windows)]
1036 {
1037 use std::os::windows::fs::OpenOptionsExt;
1038 const FILE_FLAG_BACKUP_SEMANTICS: u32 = 0x0200_0000;
1039 fs::OpenOptions::new()
1040 .write(true)
1041 .custom_flags(FILE_FLAG_BACKUP_SEMANTICS)
1042 .open(path)
1043 .unwrap()
1044 }
1045 #[cfg(not(windows))]
1046 fs::File::open(path).unwrap()
1047 }
1048
1049 fn set(path: &Path, secs: u64) {
1050 let t = UNIX_EPOCH + Duration::from_secs(secs);
1051 if path.is_dir() {
1052 open_dir(path).set_modified(t).unwrap();
1053 } else {
1054 fs::OpenOptions::new()
1055 .write(true)
1056 .open(path)
1057 .unwrap()
1058 .set_modified(t)
1059 .unwrap();
1060 }
1061 }
1062
1063 fn removed(r: &Report) -> Vec<&str> {
1064 let mut v: Vec<&str> = r.removed.iter().map(|(n, _)| n.as_str()).collect();
1065 v.sort();
1066 v
1067 }
1068
1069 #[test]
1070 fn the_keep_rules_remove_only_what_nothing_keeps() {
1071 let s = Scratch::new();
1072 s.dir("old", 40);
1073 s.dir("fresh", 2);
1074 s.dir("cited", 40);
1075 let pinned = s.dir("pinned", 40);
1076 fs::write(pinned.join(".keep"), "").unwrap();
1077 set(&pinned, NOW - 40 * DAY);
1078 let deep = s.dir("deep", 40);
1079 fs::write(deep.join("new.png"), "x").unwrap();
1080 set(&deep.join("new.png"), NOW - 2 * DAY);
1081 fs::write(s.store().join("loose.txt"), "x").unwrap();
1082 set(&s.store().join("loose.txt"), NOW - 40 * DAY);
1083 let outside = s.0.join("outside");
1084 fs::create_dir_all(&outside).unwrap();
1085 #[cfg(unix)]
1086 std::os::unix::fs::symlink(&outside, s.store().join("link")).unwrap();
1087 s.plan("repo", "docs/plans/p.md", "see attestations/cited/guide.md");
1088
1089 let r = run(&s.env(), NOW, true);
1090 assert_eq!(r.failure, None);
1091 assert_eq!(removed(&r), ["old"]);
1092 let mut want = vec![
1093 ".trash-1800000000-old",
1094 "cited",
1095 "deep",
1096 "fresh",
1097 "loose.txt",
1098 "pinned",
1099 ];
1100 if cfg!(unix) {
1101 want.push("link");
1102 }
1103 want.sort();
1104 assert_eq!(s.names(), want);
1105 assert!(outside.is_dir());
1106 }
1107
1108 #[test]
1109 fn a_missing_plan_root_removes_nothing() {
1110 let s = Scratch::new();
1111 s.dir("old", 40);
1112 let mut env = s.env();
1113 env.plan_root = Ok(s.0.join("nowhere"));
1114 let r = run(&env, NOW, true);
1115 let f = r.failure.expect("scan failed");
1116 assert!(f.path.ends_with("nowhere"), "{f:?}");
1117 assert!(r.removed.is_empty());
1118 assert!(s.store().join("old").is_dir());
1119 assert!(r.rows[0].reason.starts_with("held (scan failed:"));
1120 assert!(!r.rows[0].remove);
1121 }
1122
1123 #[cfg(unix)]
1124 #[test]
1125 fn an_unreadable_plan_removes_nothing() {
1126 use std::os::unix::fs::PermissionsExt;
1127 let s = Scratch::new();
1128 s.dir("old", 40);
1129 s.plan("repo", "docs/plans/p.md", "x");
1130 let p = s.0.join("root/repo/docs/plans/p.md");
1131 fs::set_permissions(&p, fs::Permissions::from_mode(0o000)).unwrap();
1132 if fs::read(&p).is_ok() {
1133 return; }
1135 let r = run(&s.env(), NOW, true);
1136 fs::set_permissions(&p, fs::Permissions::from_mode(0o644)).unwrap();
1137 assert!(r.failure.is_some());
1138 assert!(r.removed.is_empty());
1139 assert!(s.store().join("old").is_dir());
1140 }
1141
1142 #[test]
1143 fn a_missing_configured_root_holds_everything_and_a_present_one_is_scanned() {
1144 let s = Scratch::new();
1145 s.dir("by-dots", 40);
1146 s.dir("old", 40);
1147 let dots = s.0.join("dots");
1148 fs::create_dir_all(dots.join("docs/plans")).unwrap();
1149 fs::write(dots.join("docs/plans/x.md"), "attestations/by-dots").unwrap();
1150
1151 let mut env = s.env();
1152 env.git = Some(Ok(vec![dots.clone()]));
1153 let r = run(&env, NOW, false);
1154 assert_eq!(r.failure, None);
1155 let by: Vec<_> = r.rows.iter().map(|x| (x.name.as_str(), x.remove)).collect();
1156 assert_eq!(by, [("by-dots", false), ("old", true)]);
1157
1158 env.git = Some(Ok(vec![s.0.join("missing-dots")]));
1159 let r = run(&env, NOW, true);
1160 assert!(r.failure.unwrap().path.ends_with("missing-dots"));
1161 assert!(r.removed.is_empty());
1162 }
1163
1164 #[test]
1165 fn citations_keep_the_exact_name() {
1166 let s = Scratch::new();
1167 for n in ["x", "v1.2", "m0-duro-web", "y", "z"] {
1168 s.dir(n, 40);
1169 }
1170 s.dir("unrelated", 40);
1171 s.plan(
1172 "r",
1173 "docs/a.md",
1174 "see attestations/x/guide.md and attestations/y.",
1175 );
1176 s.plan(
1177 "r",
1178 "docs/b.md",
1179 "v attestations/v1.2 then ~/.claude/amont-agent/attestations/z\nlast attestations/m0-duro-web",
1180 );
1181 let r = run(&s.env(), NOW, false);
1182 let gone: Vec<_> = r
1183 .rows
1184 .iter()
1185 .filter(|x| x.remove)
1186 .map(|x| &x.name[..])
1187 .collect();
1188 assert_eq!(gone, ["unrelated"]);
1189 }
1190
1191 #[test]
1192 fn a_short_sha_keeps_the_long_one_and_a_slug_is_exact() {
1193 let s = Scratch::new();
1194 let long = format!("d78826f{}", "0".repeat(33));
1195 s.dir(&long, 40);
1196 s.dir("abc1234", 40);
1197 s.plan(
1198 "r",
1199 "docs/a.md",
1200 "attestations/d78826f and attestations/abc",
1201 );
1202 let r = run(&s.env(), NOW, false);
1203 let verdict: Vec<_> = r.rows.iter().map(|x| (x.name.clone(), x.remove)).collect();
1204 assert!(verdict.contains(&(long, false)), "{verdict:?}");
1205 assert!(
1206 verdict.contains(&("abc1234".to_string(), true)),
1207 "{verdict:?}"
1208 );
1209 }
1210
1211 #[test]
1212 fn claude_plans_and_other_docs_directories_count() {
1213 let s = Scratch::new();
1214 s.dir("new-thing", 40);
1215 s.dir("noted", 40);
1216 fs::write(s.0.join("claude/plans/x.md"), "attestations/new-thing").unwrap();
1217 s.plan("r", "docs/notes/y.md", "attestations/noted");
1218 let r = run(&s.env(), NOW, false);
1219 assert!(r.rows.iter().all(|x| !x.remove));
1220 }
1221
1222 #[cfg(unix)]
1223 #[test]
1224 fn a_symlink_inside_a_stale_directory_is_not_followed() {
1225 let s = Scratch::new();
1226 let d = s.dir("old", 40);
1227 let outside = s.0.join("outside");
1228 fs::create_dir_all(&outside).unwrap();
1229 fs::write(outside.join("keep.txt"), "x").unwrap();
1230 std::os::unix::fs::symlink(&outside, d.join("l")).unwrap();
1231 set(&d, NOW - 40 * DAY);
1232 let later = NOW.max(now()) + 40 * DAY;
1236 let r = run(&s.env(), later, true);
1237 assert_eq!(removed(&r), ["old"]);
1238 assert!(outside.join("keep.txt").is_file());
1239 }
1240
1241 #[test]
1242 fn git_exit_codes_map_to_roots_or_a_failure() {
1243 assert_eq!(
1244 roots_from(0, "amont.agent.plan-review.dots.path /h/dots\n", ""),
1245 Ok(vec![PathBuf::from("/h/dots")])
1246 );
1247 assert_eq!(roots_from(1, "", ""), Ok(vec![]), "no key matched");
1248 assert!(roots_from(1, "x y\n", "").is_err(), "exit 1 with output");
1249 assert!(roots_from(128, "", "fatal: bad config").is_err());
1250 }
1251
1252 #[cfg(unix)]
1253 #[test]
1254 fn a_trash_entry_that_cannot_be_deleted_is_reported() {
1255 use std::os::unix::fs::PermissionsExt;
1256 let s = Scratch::new();
1257 fs::create_dir_all(s.store()).unwrap();
1258 let t = s.store().join(format!(".trash-{}-x", NOW - 8 * DAY));
1259 fs::create_dir_all(t.join("inner")).unwrap();
1260 fs::write(t.join("inner/f"), "x").unwrap();
1261 fs::set_permissions(t.join("inner"), fs::Permissions::from_mode(0o500)).unwrap();
1262 if fs::write(t.join("inner/probe"), "x").is_ok() {
1264 fs::set_permissions(t.join("inner"), fs::Permissions::from_mode(0o700)).unwrap();
1265 return;
1266 }
1267 let r = run(&s.env(), NOW, true);
1268 fs::set_permissions(t.join("inner"), fs::Permissions::from_mode(0o700)).unwrap();
1269 assert!(
1270 r.errors.iter().any(|(n, _)| n.starts_with(".trash-")),
1271 "{:?}",
1272 r.errors
1273 );
1274 }
1275
1276 #[test]
1277 fn a_future_mtime_counts_as_recent() {
1278 let s = Scratch::new();
1279 s.dir("odd", 0);
1280 set(&s.store().join("odd"), NOW + 10 * DAY);
1281 set(&s.store().join("odd/a.txt"), NOW + 10 * DAY);
1282 let r = run(&s.env(), NOW, true);
1283 assert!(r.removed.is_empty());
1284 assert!(r.rows[0].reason.starts_with("recent"));
1285 }
1286
1287 #[test]
1288 fn the_trash_clock_is_in_the_name() {
1289 let s = Scratch::new();
1290 s.dir("old", 40);
1291 let t1 = run(&s.env(), NOW, true);
1292 assert_eq!(removed(&t1), ["old"]);
1293 let trash = s.store().join(format!(".trash-{NOW}-old"));
1294 assert!(trash.is_dir());
1295 fs::create_dir_all(s.store().join(".trash-x")).unwrap();
1296 fs::create_dir_all(s.store().join(".trash-abc-y")).unwrap();
1297
1298 run(&s.env(), NOW + DAY, true);
1299 assert!(trash.is_dir(), "still there after one day");
1300 let r = run(&s.env(), NOW + 8 * DAY, true);
1301 assert!(!trash.exists(), "gone after eight");
1302 assert_eq!(r.purged, 1);
1303 assert!(s.store().join(".trash-x").is_dir());
1304 assert!(s.store().join(".trash-abc-y").is_dir());
1305 }
1306
1307 #[test]
1308 fn a_failed_scan_still_purges_the_trash() {
1309 let s = Scratch::new();
1310 s.dir("old", 40);
1311 let t = s.store().join(format!(".trash-{}-x", NOW - 8 * DAY));
1312 fs::create_dir_all(&t).unwrap();
1313 let mut env = s.env();
1314 env.plan_root = Ok(s.0.join("nowhere"));
1315 let r = run(&env, NOW, true);
1316 assert!(r.failure.is_some());
1317 assert!(!t.exists());
1318 assert!(s.store().join("old").is_dir());
1319 }
1320
1321 #[test]
1322 fn a_dry_run_changes_nothing() {
1323 let s = Scratch::new();
1324 s.dir("old", 40);
1325 let t = s.store().join(format!(".trash-{}-x", NOW - 8 * DAY));
1326 fs::create_dir_all(&t).unwrap();
1327 let r = run(&s.env(), NOW, false);
1328 assert!(r.rows[0].remove);
1329 assert!(t.exists());
1330 assert!(s.store().join("old").is_dir());
1331 }
1332
1333 #[test]
1334 fn a_fresh_lock_blocks_the_sweep_and_a_two_hour_one_is_taken_over() {
1335 let s = Scratch::new();
1336 s.dir("old", 40);
1337 let lock = s.0.join("state/attestations.sweeping");
1338 fs::write(&lock, "").unwrap();
1339 set(&lock, NOW - 600);
1340 assert!(sweep_at(&s.env(), NOW).is_none());
1341 assert!(s.store().join("old").is_dir());
1342 assert!(lock.exists());
1343
1344 set(&lock, NOW - 7200);
1345 let r = sweep_at(&s.env(), NOW).expect("took over");
1346 assert_eq!(removed(&r), ["old"]);
1347 assert!(!lock.exists(), "the lock is removed at the end");
1348 let leftovers: Vec<_> = fs::read_dir(s.0.join("state"))
1349 .unwrap()
1350 .flatten()
1351 .filter(|e| e.file_name().to_string_lossy().contains(".stale-"))
1352 .collect();
1353 assert!(leftovers.is_empty());
1354 }
1355
1356 #[test]
1357 fn a_stale_rename_of_a_live_lock_backs_off_and_leaves_no_file() {
1358 let s = Scratch::new();
1359 let lock = s.0.join("state/attestations.sweeping");
1360 fs::write(&lock, "").unwrap();
1361 set(&lock, NOW - 60);
1362 assert!(take_over(&lock, NOW).is_none());
1365 assert!(!lock.exists(), "not put back: its holder may be gone");
1366 let stale = format!("{}.stale-{}", lock.display(), std::process::id());
1367 assert!(!Path::new(&stale).exists());
1368 }
1369
1370 #[test]
1371 fn the_stamp_limits_the_sweep_to_once_a_day() {
1372 let s = Scratch::new();
1373 s.dir("old", 40);
1374 assert!(sweep_at(&s.env(), NOW).is_some());
1375 s.dir("old2", 40);
1376 assert!(sweep_at(&s.env(), NOW + 3600).is_none());
1377 assert!(s.store().join("old2").is_dir());
1378 assert!(sweep_at(&s.env(), NOW + DAY + 1).is_some());
1379 }
1380
1381 #[test]
1382 fn a_scan_failure_still_advances_the_stamp() {
1383 let s = Scratch::new();
1384 s.dir("old", 40);
1385 let mut env = s.env();
1386 env.plan_root = Ok(s.0.join("nowhere"));
1387 let r = sweep_at(&env, NOW).unwrap();
1388 assert!(r.failure.is_some());
1389 assert!(sweep_at(&env, NOW + 60).is_none());
1390 }
1391
1392 #[test]
1393 fn rendering_is_tab_separated_when_piped_and_sanitised() {
1394 let row = |name: &str, remove: bool, reason: &str| Row {
1397 name: name.to_string(),
1398 age: if remove { 40 * DAY } else { DAY },
1399 bytes: 5,
1400 remove,
1401 reason: reason.to_string(),
1402 cited: false,
1403 };
1404 let rows = [
1405 row("bad\u{1b}[8mname", true, "older than 30d"),
1406 row("fresh", false, "recent 1d"),
1407 ];
1408 let r = Report {
1409 rows: rows.into(),
1410 failure: None,
1411 removed: Vec::new(),
1412 errors: Vec::new(),
1413 purged: 0,
1414 };
1415 let text = render(&r.rows, false);
1416 let lines: Vec<&str> = text.lines().collect();
1417 assert!(lines[0].starts_with("keep\tfresh\t"), "{text}");
1418 assert!(lines[1].starts_with("remove\tbad\\x1b[8mname\t"), "{text}");
1419 assert_eq!(lines[1].split('\t').count(), 5);
1420 let tty = render(&r.rows, true);
1421 assert!(tty.lines().all(|l| l.chars().count() <= 80), "{tty}");
1422 }
1423}