Skip to main content

amont_runtime/
active.rs

1//! The active-run entry: while a quiet check runs, one small JSON file says
2//! so, for relais's pane to tail its log (relais plan 2026-10-11 "a quiet run
3//! is live in relais's pane"; the contract is relais `docs/quiet-runs.md`).
4//!
5//! `<dir>/<pid>-<n>.json`, `<dir>` being `$QUIET_ACTIVE_DIR`, else
6//! `${XDG_STATE_HOME:-~/.local/state}/quiet/active` (`%LOCALAPPDATA%` on
7//! Windows). Written when the capture opens; rewritten with `exit` and
8//! `ended` when it ends, and LEFT: relais's watcher emits it and deletes it,
9//! or a later sweep does once it is 60 s old, so a run that ends between two
10//! of the watcher's polls is still seen with its exit.
11//!
12//! Nothing here may change a check's verdict or output: every failure is
13//! silence, and the capture works without an entry.
14
15use std::fs::{self, OpenOptions};
16use std::io::Write;
17use std::path::{Path, PathBuf};
18use std::sync::atomic::{AtomicU32, Ordering};
19use std::time::{SystemTime, UNIX_EPOCH};
20
21use crate::json;
22
23/// An ended entry nobody emitted is removed after this long.
24const ENDED_KEEP_MS: u128 = 60_000;
25
26/// Disambiguates the concurrent pre-commit checks of one process.
27static SEQ: AtomicU32 = AtomicU32::new(0);
28
29/// Where entries live, or `None` when no location can be named.
30pub fn dir() -> Option<PathBuf> {
31    if let Some(d) = std::env::var_os("QUIET_ACTIVE_DIR").filter(|d| !d.is_empty()) {
32        return Some(PathBuf::from(d));
33    }
34    // A unit test never writes into the person's real state directory,
35    // where a live relais pane would draw it.
36    if cfg!(test) {
37        return None;
38    }
39    #[cfg(windows)]
40    {
41        let base = std::env::var_os("LOCALAPPDATA")?;
42        Some(PathBuf::from(base).join("quiet").join("active"))
43    }
44    #[cfg(not(windows))]
45    {
46        let base = match std::env::var_os("XDG_STATE_HOME").filter(|d| !d.is_empty()) {
47            Some(d) => PathBuf::from(d),
48            None => PathBuf::from(std::env::var_os("HOME")?)
49                .join(".local")
50                .join("state"),
51        };
52        Some(base.join("quiet").join("active"))
53    }
54}
55
56fn now_ms() -> u128 {
57    SystemTime::now()
58        .duration_since(UNIX_EPOCH)
59        .map(|d| d.as_millis())
60        .unwrap_or(0)
61}
62
63/// One live run's entry. Ending it twice (`end`, then `Drop`) writes once.
64pub struct Entry {
65    path: PathBuf,
66    /// The fields written at start, `log` apart: the log is renamed when the
67    /// run ends, and the ended entry names where it went.
68    fields: Vec<String>,
69    log: PathBuf,
70    done: bool,
71}
72
73impl Entry {
74    /// Sweep stale entries, then write this run's. `None` on any failure.
75    /// `repo` is asked only when an entry is written: outside a session a
76    /// hook pays nothing, not even the git call behind it.
77    pub fn start<'a>(
78        dir: &Path,
79        check: &str,
80        repo: impl FnOnce() -> &'a str,
81        log: &Path,
82    ) -> Option<Entry> {
83        // Only a run inside a Claude Code session is ever drawn: without
84        // one, an entry would be litter (and a test suite's fixtures).
85        let session = std::env::var("CLAUDE_CODE_SESSION_ID")
86            .ok()
87            .filter(|s| !s.is_empty())?;
88        Entry::start_for(dir, check, repo(), log, &session)
89    }
90
91    fn start_for(dir: &Path, check: &str, repo: &str, log: &Path, session: &str) -> Option<Entry> {
92        create_dir(dir).ok()?;
93        sweep(dir);
94        let pid = std::process::id();
95        let n = SEQ.fetch_add(1, Ordering::Relaxed);
96        let fields = vec![
97            "\"v\":1".to_string(),
98            json::string_field("tool", "amont"),
99            json::string_field("session", session),
100            json::string_field("check", check),
101            json::string_field("repo", repo),
102            json::int_field("pid", i64::from(pid)),
103            format!("\"started\":{}", now_ms()),
104        ];
105        let entry = Entry {
106            path: dir.join(format!("{pid}-{n}.json")),
107            fields,
108            log: log.to_path_buf(),
109            done: false,
110        };
111        write_atomic(&entry.path, &entry.body(None)).ok()?;
112        Some(entry)
113    }
114
115    fn body(&self, end: Option<(Option<i32>, u128)>) -> String {
116        let mut fields = self.fields.clone();
117        fields.push(json::string_field("log", &self.log.to_string_lossy()));
118        if let Some((exit, ended)) = end {
119            fields.push(json::opt_int_field("exit", exit.map(i64::from)));
120            fields.push(format!("\"ended\":{ended}"));
121        }
122        json::object(&fields)
123    }
124
125    /// The run is over: add `exit` (`None`: killed, or no code) and `ended`,
126    /// name the log where it now is (`final_log`, after the rename), and
127    /// leave the file for the watcher or a later sweep.
128    pub fn end(&mut self, exit: Option<i32>, final_log: Option<&Path>) {
129        if self.done {
130            return;
131        }
132        self.done = true;
133        if let Some(p) = final_log {
134            self.log = p.to_path_buf();
135        }
136        // A failed rewrite leaves the entry live-looking; the watcher then
137        // ends it when the pid dies (relais plan: storage never changes a
138        // check's verdict).
139        let _ = write_atomic(&self.path, &self.body(Some((exit, now_ms()))));
140    }
141}
142
143impl Drop for Entry {
144    /// A capture dropped without `end` (an early return, a panic) must not
145    /// leave an entry that looks live: it ends with no exit, which the pane
146    /// draws as failed.
147    fn drop(&mut self) {
148        self.end(None, None);
149    }
150}
151
152/// The entry directory, private. A directory that cannot be made 0700 gets
153/// no entry: its contents would be readable by other users.
154fn create_dir(dir: &Path) -> std::io::Result<()> {
155    fs::create_dir_all(dir)?;
156    #[cfg(unix)]
157    {
158        use std::os::unix::fs::PermissionsExt;
159        fs::set_permissions(dir, fs::Permissions::from_mode(0o700))?;
160    }
161    Ok(())
162}
163
164/// Write `<path>.tmp` (0600) and rename it over `path`: the watcher never
165/// reads half a file.
166fn write_atomic(path: &Path, body: &str) -> std::io::Result<()> {
167    let tmp = path.with_extension("json.tmp");
168    let mut open = OpenOptions::new();
169    open.write(true).create(true).truncate(true);
170    #[cfg(unix)]
171    {
172        use std::os::unix::fs::OpenOptionsExt;
173        open.mode(0o600);
174    }
175    let mut f = open.open(&tmp)?;
176    f.write_all(body.as_bytes())?;
177    drop(f);
178    fs::rename(&tmp, path)
179}
180
181/// Remove this tool's entries whose run is gone without ending, and any
182/// ended entry the watcher has not taken within [`ENDED_KEEP_MS`]. Best-
183/// effort: a file that cannot be read or removed is left.
184fn sweep(dir: &Path) {
185    let Ok(read) = fs::read_dir(dir) else { return };
186    let now = now_ms();
187    for e in read.filter_map(|e| e.ok()) {
188        let path = e.path();
189        if path.extension().and_then(|x| x.to_str()) != Some("json") {
190            continue;
191        }
192        let Some(v) = fs::read_to_string(&path)
193            .ok()
194            .and_then(|t| crate::json_read::parse(&t))
195        else {
196            continue;
197        };
198        let num = |k: &str| match v.get(k) {
199            Some(crate::json_read::Value::Num(n)) => n.parse::<u128>().ok(),
200            _ => None,
201        };
202        let stale = match num("ended") {
203            Some(ended) => now.saturating_sub(ended) > ENDED_KEEP_MS,
204            None => {
205                v.get("tool").and_then(|t| t.as_str()) == Some("amont")
206                    // A pid that does not fit is no process.
207                    && num("pid").is_some_and(|pid| u32::try_from(pid).map_or(true, |p| !alive(p)))
208            }
209        };
210        if stale {
211            // Best-effort, like the rest of the sweep: the next one retries.
212            let _ = fs::remove_file(&path);
213        }
214    }
215}
216
217/// Whether `pid` is a running process. `EPERM` means it exists. Off unix
218/// the answer is "alive": the entry then waits for its own `ended`.
219fn alive(pid: u32) -> bool {
220    #[cfg(unix)]
221    {
222        extern "C" {
223            fn kill(pid: i32, sig: i32) -> i32;
224        }
225        let Ok(pid) = i32::try_from(pid) else {
226            return false;
227        };
228        // SAFETY: signal 0 sends nothing; it only asks whether the pid exists.
229        if unsafe { kill(pid, 0) } == 0 {
230            return true;
231        }
232        std::io::Error::last_os_error().raw_os_error() == Some(1) // EPERM
233    }
234    #[cfg(not(unix))]
235    {
236        // holds-until: this crate takes a Windows process check; until then a
237        // killed run's entry stays here and relais's watcher removes it after
238        // an hour.
239        let _ = pid;
240        true
241    }
242}
243
244#[cfg(test)]
245mod tests {
246    use super::*;
247
248    fn scratch(tag: &str) -> PathBuf {
249        let d = std::env::temp_dir().join(format!("amont-active-{tag}-{}", std::process::id()));
250        let _ = fs::remove_dir_all(&d);
251        d
252    }
253
254    fn read(path: &Path) -> crate::json_read::Value {
255        crate::json_read::parse(&fs::read_to_string(path).unwrap()).unwrap()
256    }
257
258    #[test]
259    fn an_entry_carries_the_documented_fields_and_ends_in_place() {
260        let dir = scratch("fields");
261        let log = dir.join("a \"quoted\" \\ path.log.tmp");
262        let mut e = Entry::start_for(&dir, "clippy", "/r/repo", &log, "s-test").unwrap();
263        let v = read(&e.path);
264        for k in [
265            "v", "tool", "session", "check", "repo", "log", "pid", "started",
266        ] {
267            assert!(v.get(k).is_some(), "missing {k}");
268        }
269        assert_eq!(v.get("tool").and_then(|t| t.as_str()), Some("amont"));
270        assert_eq!(
271            v.get("log").and_then(|t| t.as_str()),
272            Some(&*log.to_string_lossy())
273        );
274        assert!(v.get("ended").is_none());
275        let done = dir.join("final.log");
276        e.end(Some(101), Some(&done));
277        let v = read(&e.path);
278        assert_eq!(
279            v.get("log").and_then(|t| t.as_str()),
280            Some(&*done.to_string_lossy())
281        );
282        assert!(matches!(v.get("exit"), Some(crate::json_read::Value::Num(n)) if n == "101"));
283        assert!(v.get("ended").is_some());
284        assert!(e.path.exists(), "an ended entry is left for the watcher");
285    }
286
287    #[test]
288    fn two_entries_in_one_process_get_two_files() {
289        let dir = scratch("two");
290        let a = Entry::start_for(&dir, "a", "/r", &dir.join("a.log"), "s-test").unwrap();
291        let b = Entry::start_for(&dir, "b", "/r", &dir.join("b.log"), "s-test").unwrap();
292        assert_ne!(a.path, b.path);
293        assert!(a.path.exists() && b.path.exists());
294    }
295
296    #[test]
297    fn dropping_without_end_records_no_exit() {
298        let dir = scratch("drop");
299        let path = {
300            let e = Entry::start_for(&dir, "x", "/r", &dir.join("x.log"), "s-test").unwrap();
301            e.path.clone()
302        };
303        let v = read(&path);
304        assert!(matches!(v.get("exit"), Some(crate::json_read::Value::Null)));
305        assert!(v.get("ended").is_some());
306    }
307
308    #[test]
309    fn the_sweep_takes_dead_and_old_ended_entries_only() {
310        let dir = scratch("sweep");
311        fs::create_dir_all(&dir).unwrap();
312        let old = now_ms() - 120_000;
313        let recent = now_ms() - 1_000;
314        // pid 0x7fff_fff0 is not a process here.
315        fs::write(
316            dir.join("dead.json"),
317            r#"{"tool":"amont","pid":2147483632}"#,
318        )
319        .unwrap();
320        fs::write(
321            dir.join("other-tool-dead.json"),
322            r#"{"tool":"amont-agent","pid":2147483632}"#,
323        )
324        .unwrap();
325        fs::write(
326            dir.join("old.json"),
327            format!(r#"{{"tool":"amont-agent","ended":{old}}}"#),
328        )
329        .unwrap();
330        fs::write(
331            dir.join("recent.json"),
332            format!(r#"{{"tool":"amont","ended":{recent}}}"#),
333        )
334        .unwrap();
335        let _live = Entry::start_for(&dir, "live", "/r", &dir.join("l.log"), "s-test").unwrap();
336        // Off unix every pid counts as alive, so only an ended entry goes.
337        assert_eq!(dir.join("dead.json").exists(), !cfg!(unix));
338        assert!(
339            dir.join("other-tool-dead.json").exists(),
340            "another tool's live-looking entry is its own"
341        );
342        assert!(!dir.join("old.json").exists());
343        assert!(dir.join("recent.json").exists());
344    }
345
346    #[test]
347    fn quiet_entries_never_reach_the_real_state_dir() {
348        // .cargo/config.toml: an agent running this suite must not put its
349        // fixtures in the person's live relais pane.
350        assert_eq!(std::env::var("CLAUDE_CODE_SESSION_ID").as_deref(), Ok(""));
351        assert!(std::env::var("QUIET_ACTIVE_DIR").is_ok_and(|d| d.contains("target")));
352        assert!(Entry::start(&scratch("nosession"), "x", || "/r", Path::new("/l")).is_none());
353    }
354
355    #[cfg(unix)]
356    #[test]
357    fn the_entry_and_its_dir_are_private() {
358        use std::os::unix::fs::PermissionsExt;
359        let dir = scratch("mode");
360        let e = Entry::start_for(&dir, "x", "/r", &dir.join("x.log"), "s-test").unwrap();
361        assert_eq!(
362            fs::metadata(&e.path).unwrap().permissions().mode() & 0o777,
363            0o600
364        );
365        assert_eq!(
366            fs::metadata(&dir).unwrap().permissions().mode() & 0o777,
367            0o700
368        );
369    }
370
371    #[test]
372    fn an_unwritable_dir_gives_no_entry_and_no_error() {
373        let file = scratch("blocked");
374        fs::write(&file, "not a dir").unwrap();
375        assert!(
376            Entry::start_for(&file.join("sub"), "x", "/r", Path::new("/l"), "s-test").is_none()
377        );
378    }
379}