1use std::path::{Path, PathBuf};
5
6use crate::created_set::{CreatedSet, SWEEP_MAX_AGE_MS};
7use crate::liveness::{self, sentinel_alive, side_liveness, Liveness, ProcessProbe, Side};
8use crate::pairing::{self, valid_pair_id};
9
10pub const STALE_FILE_MS: i64 = 24 * 60 * 60 * 1000;
12
13#[derive(Debug, Clone, Default)]
14pub struct SweepReport {
15 pub removed: Vec<PathBuf>,
16 pub errors: Vec<String>,
17}
18
19impl SweepReport {
20 fn remove_file(&mut self, path: &Path) {
21 match std::fs::remove_file(path) {
22 Ok(()) => self.removed.push(path.to_path_buf()),
23 Err(e) if e.kind() == std::io::ErrorKind::NotFound => {}
24 Err(e) => self.errors.push(format!("{}: {e}", path.display())),
25 }
26 }
27}
28
29fn age_ms(path: &Path, now_ms: i64) -> Option<i64> {
30 let t = std::fs::metadata(path).ok()?.modified().ok()?;
31 let ms = t.duration_since(std::time::UNIX_EPOCH).ok()?.as_millis() as i64;
32 Some(now_ms - ms)
33}
34
35pub fn pair_is_dead(home: &Path, id: &str, now_ms: i64, probe: &dyn ProcessProbe) -> bool {
39 match pairing::read_record(home, id) {
40 Ok(None) => !pairing::record_path(home, id).exists(),
41 Ok(Some(mut r)) => {
42 r.pair_id = id.to_string();
43 [Side::Tui, Side::Controller].into_iter().all(|side| {
44 matches!(
45 side_liveness(side, &r, home, probe, now_ms),
46 Liveness::Dead | Liveness::None
47 )
48 })
49 }
50 Err(_) => false,
51 }
52}
53
54fn pair_file_parts(name: &str) -> Option<(&str, &str)> {
56 let rest = name.strip_prefix("pair.")?;
57 let (id, rest) = rest.split_once('.')?;
58 valid_pair_id(id).then_some((id, rest))
59}
60
61fn is_sentinel_name(rest: &str) -> bool {
62 rest == "claude-pid"
63 || rest == "claude-exited"
64 || (rest.starts_with('g') && (rest.ends_with(".tui-pid") || rest.ends_with(".tui-exited")))
65}
66
67pub fn sweep_old_launcher(dir: &Path, report: &mut SweepReport) {
69 let old = dir.join("opencode-pair.exe.old");
70 if old.exists() {
71 report.remove_file(&old);
72 }
73}
74
75pub fn startup_sweep(home: &Path, now_ms: i64, probe: &dyn ProcessProbe) -> SweepReport {
79 let mut report = SweepReport::default();
80 report.removed.extend(crate::logging::retain(
81 &home.join("logs"),
82 now_ms,
83 probe,
84 &|id| pair_is_dead(home, id, now_ms, probe),
85 ));
86 report
87 .removed
88 .extend(liveness::sweep_stdout_dirs(home, probe));
89 report
90 .removed
91 .extend(CreatedSet::new(home.to_path_buf()).sweep(now_ms, SWEEP_MAX_AGE_MS));
92
93 if let Ok(entries) = std::fs::read_dir(home) {
94 for entry in entries.flatten() {
95 let path = entry.path();
96 let name = entry.file_name();
97 let Some(name) = name.to_str() else { continue };
98 if !path.is_file() {
99 continue;
100 }
101 if name.contains(".tmp-") {
102 if age_ms(&path, now_ms).is_some_and(|a| a > STALE_FILE_MS) {
103 report.remove_file(&path);
104 }
105 continue;
106 }
107 let Some((id, rest)) = pair_file_parts(name) else {
108 continue;
109 };
110 if rest == "resume-lock" {
111 if pairing::read_sentinel(&path).is_some_and(|s| !sentinel_alive(&s, probe)) {
113 report.remove_file(&path);
114 }
115 } else if is_sentinel_name(rest)
116 && !pairing::record_path(home, id).exists()
117 && age_ms(&path, now_ms).is_some_and(|a| a > STALE_FILE_MS)
118 {
119 report.remove_file(&path);
120 }
121 }
122 }
123
124 #[cfg(windows)]
125 if let Some(local) = std::env::var_os("LOCALAPPDATA").filter(|v| !v.is_empty()) {
126 sweep_old_launcher(&PathBuf::from(local).join("opencode-ctl"), &mut report);
127 }
128 report
129}