Skip to main content

borg/
presence.rs

1//! Who is working where, right now: one tiny record per drone session, for `borg top` (top.rs) and
2//! for steering (steer.rs) to name a drone.
3//!
4//! `<store>/presence/<session>.json`, rewritten whole by every hook call (one small write to a temp
5//! file, then a rename: a reader never sees half a record). No extra process, no network: the
6//! engineer (git user.email) and the repo are looked up once, on the session's first hook, and then
7//! carried over from the previous record.
8//!
9//! A session is `working` until its turn ends (`stop`: `waiting`), `idle` after 30 minutes without a
10//! hook, `ended` at `end` (or when the harness gave a pid that is gone). Records untouched for a day
11//! are pruned (at a prompt and by `borg top`). Remote drones (other members' sessions, carried by the
12//! mesh daemon when the collective may sync) are in `<store>/mesh/presence.jsonl`, never in lessons.
13
14use std::path::{Path, PathBuf};
15
16use serde::{Deserialize, Serialize};
17use serde_json::Value;
18
19use crate::learn;
20
21/// Without a hook for this long a session shows as idle. Display: it only picks the word `borg top`
22/// and the web page show (`state`); no pacing, pruning or decision may derive from it.
23pub const IDLE_MS: i64 = 30 * 60 * 1000;
24/// Untouched for this long a record is pruned (and the mesh stops carrying it). Guard: bounds the
25/// presence files to the sessions of the last day (`clock::DAY_MS`), one record per session.
26pub const PRUNE_MS: i64 = crate::clock::DAY_MS;
27/// At most this many recently touched files per record. Display: what a drone's row names.
28pub const FILES: usize = 3;
29/// The prompt's first line, at most this many characters. Display: one row of `borg top`.
30const PROMPT_CHARS: usize = 100;
31
32#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
33pub struct Presence {
34    pub session: String,
35    pub harness: String,
36    /// git user.email in the session's repo, empty when unknown.
37    #[serde(default)]
38    pub engineer: String,
39    /// The repo's name (its key's last component).
40    #[serde(default)]
41    pub repo: String,
42    pub collective: String,
43    /// The first line of the engineer's latest prompt.
44    #[serde(default)]
45    pub prompt: String,
46    /// The files the drone touched last, newest first, relative to the session's directory.
47    #[serde(default)]
48    pub files: Vec<String>,
49    /// The canonical hook event last seen, and when (epoch ms).
50    pub event: String,
51    pub ms: i64,
52    /// When this record was first written: the drone's start, for its designation.
53    pub started: i64,
54    /// The harness process, if the harness gave it (`pid` in the hook event).
55    #[serde(default, skip_serializing_if = "Option::is_none")]
56    pub pid: Option<u32>,
57}
58
59#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
60#[serde(rename_all = "lowercase")]
61pub enum State {
62    Working,
63    Waiting,
64    Idle,
65    Ended,
66}
67
68impl State {
69    pub fn name(self) -> &'static str {
70        match self {
71            State::Working => "working",
72            State::Waiting => "waiting",
73            State::Idle => "idle",
74            State::Ended => "ended",
75        }
76    }
77}
78
79pub fn dir(home: &Path) -> PathBuf {
80    home.join("presence")
81}
82
83/// Session ids become file names: only `[A-Za-z0-9-]` survives (as everywhere in the hook's state).
84pub fn safe(session: &str) -> String {
85    session.chars().filter(|c| c.is_ascii_alphanumeric() || *c == '-').collect()
86}
87
88pub fn path(home: &Path, session: &str) -> PathBuf {
89    dir(home).join(format!("{}.json", safe(session)))
90}
91
92/// Remote drones as the mesh daemon last heard of them (one signed record per line).
93pub fn remote_path(home: &Path) -> PathBuf {
94    home.join("mesh").join("presence.jsonl")
95}
96
97pub fn read(home: &Path, session: &str) -> Option<Presence> {
98    serde_json::from_slice(&std::fs::read(path(home, session)).ok()?).ok()
99}
100
101/// What a tool call names as a file, relative to `cwd` when inside it.
102fn file_of(input: &Value, cwd: &str) -> Option<String> {
103    let ti = input.get("tool_input")?;
104    let f = ["file_path", "notebook_path"].iter().find_map(|k| ti.get(*k).and_then(Value::as_str)).filter(|f| !f.trim().is_empty())?;
105    let rel = Path::new(f).strip_prefix(cwd).ok().map(|p| p.display().to_string()).filter(|p| !p.is_empty());
106    Some(rel.unwrap_or_else(|| f.to_string()))
107}
108
109/// The prompt's first non-empty line, cut.
110pub fn first_line(prompt: &str) -> String {
111    let line = prompt.lines().map(str::trim).find(|l| !l.is_empty()).unwrap_or_default();
112    let mut out: String = line.chars().take(PROMPT_CHARS).collect();
113    if line.chars().count() > PROMPT_CHARS {
114        out.push('…');
115    }
116    out
117}
118
119/// The record after `event` (pure: `prev` is the session's last record; `who` looks up the engineer
120/// and repo, called only when there is no previous record).
121pub fn update(
122    prev: Option<Presence>,
123    collective: &str,
124    harness: &str,
125    event: &str,
126    input: &Value,
127    now: i64,
128    who: impl FnOnce(&str) -> (String, String),
129) -> Presence {
130    let cwd = input.get("cwd").and_then(Value::as_str).unwrap_or_default();
131    let session = input.get("session_id").and_then(Value::as_str).unwrap_or("unknown");
132    let mut p = prev.unwrap_or_else(|| {
133        let (engineer, repo) = who(cwd);
134        Presence {
135            session: session.into(),
136            harness: harness.into(),
137            engineer,
138            repo,
139            collective: collective.into(),
140            prompt: String::new(),
141            files: Vec::new(),
142            event: String::new(),
143            ms: now,
144            started: now,
145            pid: None,
146        }
147    });
148    p.event = event.into();
149    p.ms = now;
150    if let Some(pid) = input.get("pid").and_then(Value::as_u64).and_then(|v| u32::try_from(v).ok()) {
151        p.pid = Some(pid);
152    }
153    match event {
154        "prompt" => {
155            let line = first_line(input.get("prompt").and_then(Value::as_str).unwrap_or_default());
156            if !line.is_empty() {
157                p.prompt = line;
158            }
159        }
160        "post_tool" | "post_tool_failure" => {
161            if let Some(f) = file_of(input, cwd) {
162                p.files.retain(|x| *x != f);
163                p.files.insert(0, f);
164                p.files.truncate(FILES);
165            }
166        }
167        _ => {}
168    }
169    p
170}
171
172/// The hook's one call: updates the session's record with one atomic write. Returns the record
173/// (steering needs the session's start); `None` if it could not be written (fail open).
174pub fn touch(home: &Path, collective: &str, harness: &str, event: &str, input: &Value, now: i64) -> Option<Presence> {
175    let session = input.get("session_id").and_then(Value::as_str).unwrap_or("unknown");
176    let p = update(read(home, session), collective, harness, event, input, now, |cwd| {
177        (learn::engineer(cwd), borg_trace::repo_key(cwd).rsplit('/').next().unwrap_or_default().to_string())
178    });
179    write(home, &p).ok()?;
180    if event == "prompt" {
181        prune(home, now);
182    }
183    Some(p)
184}
185
186pub fn write(home: &Path, p: &Presence) -> std::io::Result<()> {
187    let d = dir(home);
188    std::fs::create_dir_all(&d)?;
189    let target = path(home, &p.session);
190    let tmp = d.join(format!(".{}.{}.tmp", safe(&p.session), std::process::id()));
191    std::fs::write(&tmp, serde_json::to_vec(p).unwrap_or_default())?;
192    std::fs::rename(&tmp, &target).inspect_err(|_| {
193        let _ = std::fs::remove_file(&tmp);
194    })
195}
196
197/// Removes records not touched for a day (by file time: no parsing in the hook path).
198pub fn prune(home: &Path, now: i64) {
199    let Ok(rd) = std::fs::read_dir(dir(home)) else { return };
200    for e in rd.flatten() {
201        let old = e
202            .metadata()
203            .and_then(|m| m.modified())
204            .ok()
205            .and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok())
206            .is_some_and(|d| now - d.as_millis() as i64 > PRUNE_MS);
207        if old {
208            // Its message claims stay: a resumed session keeps its id, and they must outlive the messages
209            // they cover (msg.rs drops them past the TTL).
210            let _ = std::fs::remove_file(e.path());
211            // Its opt-in to wakes (listen.rs) goes with it (the file stem is the safe session id).
212            if let Some(session) = e.path().file_stem().and_then(|s| s.to_str()).filter(|s| !s.starts_with('.')) {
213                crate::listen::forget(home, session);
214            }
215        }
216    }
217}
218
219/// Every local record not yet due for pruning.
220pub fn local(home: &Path, now: i64) -> Vec<Presence> {
221    let Ok(rd) = std::fs::read_dir(dir(home)) else { return Vec::new() };
222    let mut v: Vec<Presence> = rd
223        .flatten()
224        .filter(|e| e.path().extension().is_some_and(|x| x == "json"))
225        .filter_map(|e| serde_json::from_slice::<Presence>(&std::fs::read(e.path()).ok()?).ok())
226        .filter(|p| now - p.ms <= PRUNE_MS)
227        .collect();
228    v.sort_by(|a, b| (a.started, &a.session).cmp(&(b.started, &b.session)));
229    v
230}
231
232/// A remote record as the mesh daemon stored it: the drone's presence and the member key that
233/// signed it (the daemon verified it on receipt).
234#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
235pub struct Remote {
236    pub p: Presence,
237    pub key: String,
238    pub sig: String,
239}
240
241/// The remote records not yet due for pruning (latest per member and session).
242pub fn remote(home: &Path, now: i64) -> Vec<Remote> {
243    let text = std::fs::read_to_string(remote_path(home)).unwrap_or_default();
244    let mut v: Vec<Remote> = text.lines().filter_map(|l| serde_json::from_str::<Remote>(l).ok()).filter(|r| now - r.p.ms <= PRUNE_MS).collect();
245    v.sort_by(|a, b| (&a.key, &a.p.session, b.p.ms).cmp(&(&b.key, &b.p.session, a.p.ms)));
246    v.dedup_by(|a, b| a.key == b.key && a.p.session == b.p.session);
247    v
248}
249
250/// `<store>/mesh/synced.json`, `{"<member key>": ms}`: when the last session with each member that
251/// completed started (this node's clock), written by the mesh daemon (borg-mesh sync.rs), dialled or
252/// served. Every line this node held before that ms was offered to the member in it, and the member's
253/// lines up to its own reading of the logs came back. Local, never synced.
254pub fn synced_path(home: &Path) -> PathBuf {
255    home.join("mesh").join("synced.json")
256}
257
258/// The members' last completed sessions (`synced_path`); empty when none completed yet.
259pub fn synced(home: &Path) -> std::collections::BTreeMap<String, i64> {
260    std::fs::read_to_string(synced_path(home)).ok().and_then(|t| serde_json::from_str(&t).ok()).unwrap_or_default()
261}
262
263/// Records a completed session with `key` that started at `ms` (an older one never moves it back, but a
264/// record later than the clock now goes: the clock stepped back since, and it would stay ahead of
265/// every claim for as long). One small write to a temp file, then a rename: a reader never sees half
266/// of it. `serve` and a one-shot `borg mesh sync` take no lock between them: one may drop the other's
267/// newer entry, which only makes a claim wait for the next session.
268pub fn note_synced(home: &Path, key: &str, ms: i64) -> std::io::Result<()> {
269    let mut all = synced(home);
270    if all.get(key).is_some_and(|&held| held >= ms && held <= crate::learn::now_ms()) {
271        return Ok(());
272    }
273    all.insert(key.into(), ms);
274    write_synced(home, &all)
275}
276
277/// Drops `key`'s record (its last dial failed): no claim waits on it until a session completes again.
278pub fn forget_synced(home: &Path, key: &str) -> std::io::Result<()> {
279    let mut all = synced(home);
280    if all.remove(key).is_none() {
281        return Ok(());
282    }
283    write_synced(home, &all)
284}
285
286fn write_synced(home: &Path, all: &std::collections::BTreeMap<String, i64>) -> std::io::Result<()> {
287    write_mesh(home, &synced_path(home), all)
288}
289
290/// One small write to a temp file, then a rename: a reader never sees half of `p`.
291fn write_mesh<T: Serialize>(home: &Path, p: &Path, all: &T) -> std::io::Result<()> {
292    std::fs::create_dir_all(home.join("mesh"))?;
293    let tmp = p.with_extension(format!("{}.tmp", std::process::id())); // a one-shot `borg mesh sync` beside `serve`
294    std::fs::write(&tmp, serde_json::to_string(all).unwrap_or_default())?;
295    std::fs::rename(&tmp, p)
296}
297
298/// `<store>/mesh/protocols.json`, `{"<member key>": Mismatch}`: the members whose last session ended at
299/// the handshake because they speak another session protocol (borg-mesh sync.rs `ALPN`), so nothing
300/// syncs with them. Written by the mesh daemon; `borg doctor` names each one. A session with the member
301/// that completes drops its record, and so does a membership that no longer lists it. Local, never synced.
302pub fn protocols_path(home: &Path) -> PathBuf {
303    home.join("mesh").join("protocols.json")
304}
305
306/// A member's protocol beside this node's, both as their ALPN (`borg/mesh/<n>`), and when it was seen.
307#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
308pub struct Mismatch {
309    pub theirs: String,
310    pub ours: String,
311    pub ms: i64,
312}
313
314impl Mismatch {
315    /// What the mesh log and `borg doctor` say about member `key`: who has to upgrade.
316    pub fn line(&self, key: &str) -> String {
317        let n = |p: &str| p.rsplit('/').next().and_then(|v| v.parse::<u32>().ok()).unwrap_or(0);
318        let who = if n(&self.theirs) < n(&self.ours) { "upgrade it" } else { "upgrade this node" };
319        format!("peer {} speaks {}, this node {}: nothing syncs with it; {who}", crate::msg::short_node(key), self.theirs, self.ours)
320    }
321
322    /// The same, as the member on the other end reads it about this node (`me`): the close reason sent to it.
323    pub fn told(&self, me: &str) -> String {
324        Mismatch { theirs: self.ours.clone(), ours: self.theirs.clone(), ms: self.ms }.line(me)
325    }
326}
327
328/// The members whose protocol differs (`protocols_path`); empty when none.
329pub fn mismatches(home: &Path) -> std::collections::BTreeMap<String, Mismatch> {
330    std::fs::read_to_string(protocols_path(home)).ok().and_then(|t| serde_json::from_str(&t).ok()).unwrap_or_default()
331}
332
333/// Records that `key` speaks another protocol (the newest sighting stays).
334pub fn note_mismatch(home: &Path, key: &str, m: Mismatch) -> std::io::Result<()> {
335    let mut all = mismatches(home);
336    all.insert(key.into(), m);
337    write_mesh(home, &protocols_path(home), &all)
338}
339
340/// Drops `key`'s record: a session with it completed, so it speaks this node's protocol now.
341pub fn forget_mismatch(home: &Path, key: &str) -> std::io::Result<()> {
342    let mut all = mismatches(home);
343    if all.remove(key).is_none() {
344        return Ok(());
345    }
346    write_mesh(home, &protocols_path(home), &all)
347}
348
349/// Keeps only the records of keys `keep` still allows (the mesh calls it when the membership it holds
350/// changes: a removed or revoked member never completes another session, so nothing else drops it).
351pub fn retain_mismatches(home: &Path, keep: impl Fn(&str) -> bool) -> std::io::Result<()> {
352    let mut all = mismatches(home);
353    let before = all.len();
354    all.retain(|k, _| keep(k));
355    if all.len() == before {
356        return Ok(());
357    }
358    write_mesh(home, &protocols_path(home), &all)
359}
360
361/// `<store>/mesh/reads.json`, `{"<member key>": v}`: the newest message line format (msg.rs
362/// `Message::v`) each member's node reads, as the protocol of its last completed session with this node
363/// says (borg-mesh sync.rs `reads`): 1 for 0.4.0 and 0.5.x, which drop a v2 line, 0 for 0.3.0, which
364/// syncs no messages. Written by the mesh daemon, dialled or served; `send` refuses what a session
365/// there could never be shown (msg.rs `SendError::Reads`). Local, never synced.
366pub fn reads_path(home: &Path) -> PathBuf {
367    home.join("mesh").join("reads.json")
368}
369
370/// What each member's node reads (`reads_path`); empty before any session completed.
371pub fn reads(home: &Path) -> std::collections::BTreeMap<String, u8> {
372    std::fs::read_to_string(reads_path(home)).ok().and_then(|t| serde_json::from_str(&t).ok()).unwrap_or_default()
373}
374
375/// Records that `key`'s node reads message lines up to format `v`; no write when that is known (a
376/// session completes every minute or so).
377pub fn note_reads(home: &Path, key: &str, v: u8) -> std::io::Result<()> {
378    let mut all = reads(home);
379    if all.get(key) == Some(&v) {
380        return Ok(());
381    }
382    all.insert(key.into(), v);
383    write_mesh(home, &reads_path(home), &all)
384}
385
386/// Keeps only the reads records of keys `keep` still allows, as `retain_mismatches` keeps theirs: a
387/// removed member's record misleads nothing (msg.rs `unread_on` counts the held members alone), but no
388/// session with it would ever replace it.
389pub fn retain_reads(home: &Path, keep: impl Fn(&str) -> bool) -> std::io::Result<()> {
390    let mut all = reads(home);
391    let before = all.len();
392    all.retain(|k, _| keep(k));
393    if all.len() == before {
394        return Ok(());
395    }
396    write_mesh(home, &reads_path(home), &all)
397}
398
399/// `borg doctor`'s line for each member the mesh cannot sync with because it speaks another session
400/// protocol (`mismatches`, recorded by `borg mesh serve` or `sync`), in the stores of `routes`
401/// (install.rs `collectives`): a handshake that fails otherwise says nothing.
402pub fn mesh_checks(routes: &[crate::collective::Route]) -> Vec<(crate::harness::Level, String)> {
403    use crate::collective::Route;
404    routes
405        .iter()
406        .filter_map(|r| match r {
407            Route::Collective { name, home, .. } => Some((name, home)),
408            Route::Off => None,
409        })
410        .flat_map(|(name, home)| mismatches(home).into_iter().map(move |(k, m)| (crate::harness::Level::Warn, format!("mesh {name}: {}", m.line(&k)))))
411        .collect()
412}
413
414/// The session's state at `now`; `alive` says whether a pid the harness gave still runs.
415pub fn state(p: &Presence, now: i64, alive: impl Fn(u32) -> bool) -> State {
416    if p.event == "end" || p.pid.is_some_and(|pid| !alive(pid)) {
417        State::Ended
418    } else if now - p.ms > IDLE_MS {
419        State::Idle
420    } else if p.event == "stop" {
421        State::Waiting
422    } else {
423        State::Working
424    }
425}
426
427/// Whether a local process runs (signal 0, so Linux and macOS alike).
428pub fn alive(pid: u32) -> bool {
429    i32::try_from(pid).is_ok_and(crate::live::alive)
430}
431
432#[cfg(test)]
433mod tests {
434    use super::*;
435    use serde_json::json;
436
437    fn tmp(tag: &str) -> PathBuf {
438        let d = std::env::temp_dir().join(format!("borg-presence-{tag}-{}", std::process::id()));
439        let _ = std::fs::remove_dir_all(&d);
440        d
441    }
442
443    fn who(_: &str) -> (String, String) {
444        ("alice@x".into(), "app".into())
445    }
446
447    /// Two sessions with one member can complete out of order (a served one beside a dialled one): the
448    /// later start stays.
449    #[test]
450    fn a_members_last_session_never_moves_back() {
451        let h = tmp("synced");
452        assert!(synced(&h).is_empty());
453        note_synced(&h, "k1", 200).unwrap();
454        note_synced(&h, "k1", 100).unwrap();
455        note_synced(&h, "k2", 50).unwrap();
456        assert_eq!(synced(&h).into_iter().collect::<Vec<_>>(), [("k1".to_string(), 200), ("k2".to_string(), 50)]);
457        note_synced(&h, "k1", 300).unwrap();
458        assert_eq!(synced(&h)["k1"], 300);
459        forget_synced(&h, "k1").unwrap();
460        assert_eq!(synced(&h).into_keys().collect::<Vec<_>>(), ["k2"], "a failed dial drops the member");
461        let _ = std::fs::remove_dir_all(&h);
462    }
463
464    #[test]
465    fn doctor_names_a_member_on_another_mesh_protocol() {
466        use crate::collective::Route;
467        use crate::harness::Level;
468        let home = tmp("protocols-doctor");
469        let route = Route::Collective { name: "team".into(), home: home.clone(), share: crate::collective::Share::Team };
470        assert!(mesh_checks(std::slice::from_ref(&route)).is_empty());
471        let m = Mismatch { theirs: "borg/mesh/5".into(), ours: "borg/mesh/10".into(), ms: 1 };
472        note_mismatch(&home, "abcdef0123456789", m).unwrap();
473        let checks = mesh_checks(&[route, Route::Off]);
474        assert_eq!(checks.len(), 1, "{checks:?}");
475        assert_eq!(checks[0].0, Level::Warn);
476        assert!(checks[0].1.starts_with("mesh team: peer abcdef012345 speaks borg/mesh/5") && checks[0].1.ends_with("upgrade it"), "{checks:?}");
477    }
478
479    /// A member on another protocol is recorded with who has to upgrade, until a session completes.
480    #[test]
481    fn a_protocol_mismatch_is_kept_until_a_session_completes() {
482        let h = tmp("protocols");
483        assert!(mismatches(&h).is_empty());
484        let old = Mismatch { theirs: "borg/mesh/5".into(), ours: "borg/mesh/10".into(), ms: 1 };
485        note_mismatch(&h, "abcdef0123456789", old.clone()).unwrap();
486        note_mismatch(&h, "k2", Mismatch { theirs: "borg/mesh/11".into(), ..old.clone() }).unwrap();
487        let all = mismatches(&h);
488        assert_eq!(
489            all["abcdef0123456789"].line("abcdef0123456789"),
490            "peer abcdef012345 speaks borg/mesh/5, this node borg/mesh/10: nothing syncs with it; upgrade it"
491        );
492        assert!(all["k2"].line("k2").ends_with("upgrade this node"), "a newer peer: this node is the old one");
493        forget_mismatch(&h, "abcdef0123456789").unwrap();
494        assert_eq!(mismatches(&h).into_keys().collect::<Vec<_>>(), ["k2"]);
495        let _ = std::fs::remove_dir_all(&h);
496    }
497
498    #[test]
499    fn a_record_ahead_of_the_clock_gives_way() {
500        // The clock stepped back an hour after a session: the next session's start replaces it.
501        let h = tmp("synced-skew");
502        let now = crate::learn::now_ms();
503        note_synced(&h, "k1", now + 3_600_000).unwrap();
504        note_synced(&h, "k1", now).unwrap();
505        assert_eq!(synced(&h)["k1"], now);
506        let _ = std::fs::remove_dir_all(&h);
507    }
508
509    #[test]
510    fn a_session_record_follows_its_hooks() {
511        let ev = |e: &str, extra: Value| {
512            let mut v = json!({"session_id": "s1", "cwd": "/w/app"});
513            v.as_object_mut().unwrap().extend(extra.as_object().unwrap().clone());
514            (e.to_string(), v)
515        };
516        let mut p = None;
517        let steps = [
518            ev("prompt", json!({"prompt": "\n  fix the flaky sync test\nand more", "pid": 4242})),
519            ev("post_tool", json!({"tool_name": "Edit", "tool_input": {"file_path": "/w/app/src/a.rs"}})),
520            ev("post_tool", json!({"tool_name": "Read", "tool_input": {"file_path": "/w/app/src/b.rs"}})),
521            ev("post_tool", json!({"tool_name": "Bash", "tool_input": {"command": "ls"}})),
522            ev("post_tool", json!({"tool_name": "Edit", "tool_input": {"file_path": "/w/app/src/a.rs"}})),
523            ev("post_tool", json!({"tool_name": "Write", "tool_input": {"file_path": "/elsewhere/c.rs"}})),
524            ev("post_tool", json!({"tool_name": "Write", "tool_input": {"file_path": "/w/app/d.rs"}})),
525        ];
526        for (i, (e, input)) in steps.iter().enumerate() {
527            p = Some(update(p, "corp", "claude", e, input, 1000 + i as i64, who));
528        }
529        let p = p.unwrap();
530        assert_eq!(p.prompt, "fix the flaky sync test", "first non-empty line");
531        assert_eq!(p.files, ["d.rs", "/elsewhere/c.rs", "src/a.rs"], "newest first, deduped, at most 3, relative inside cwd");
532        assert_eq!((p.started, p.ms, p.pid, p.engineer.as_str(), p.repo.as_str()), (1000, 1006, Some(4242), "alice@x", "app"));
533        let long = "x".repeat(300);
534        assert_eq!(first_line(&long).chars().count(), PROMPT_CHARS + 1);
535    }
536
537    #[test]
538    fn write_read_prune_and_state() {
539        let home = tmp("wrp");
540        let input = json!({"session_id": "s/../1", "cwd": "/w/app", "prompt": "hello"});
541        let p = update(None, "corp", "omp", "prompt", &input, 5_000, who);
542        write(&home, &p).unwrap();
543        assert_eq!(read(&home, "s/../1"), Some(p.clone()));
544        assert_eq!(path(&home, "s/../1"), dir(&home).join("s1.json"), "a session id never escapes the directory");
545        assert_eq!(local(&home, 5_000), vec![p.clone()]);
546        assert!(std::fs::read_dir(dir(&home)).unwrap().count() == 1, "no temp file left behind");
547
548        assert_eq!(state(&p, 5_000, |_| true), State::Working);
549        assert_eq!(state(&Presence { event: "stop".into(), ..p.clone() }, 5_000, |_| true), State::Waiting);
550        assert_eq!(state(&p, 5_000 + IDLE_MS + 1, |_| true), State::Idle);
551        assert_eq!(state(&Presence { event: "end".into(), ..p.clone() }, 5_000, |_| true), State::Ended);
552        assert_eq!(state(&Presence { pid: Some(7), ..p.clone() }, 5_000, |_| false), State::Ended, "the harness process is gone");
553
554        // A day later the record is no longer listed, and pruning (by file time) removes it.
555        assert!(local(&home, 5_000 + PRUNE_MS + 1).is_empty());
556        let far = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_millis() as i64 + PRUNE_MS + 1000;
557        prune(&home, far);
558        assert!(read(&home, "s/../1").is_none());
559        let _ = std::fs::remove_dir_all(&home);
560    }
561
562    #[test]
563    fn remote_keeps_the_latest_per_member_and_session() {
564        let home = tmp("remote");
565        std::fs::create_dir_all(home.join("mesh")).unwrap();
566        let p = |s: &str, ms: i64| Presence {
567            session: s.into(),
568            harness: "claude".into(),
569            engineer: "bob@x".into(),
570            repo: "r".into(),
571            collective: "corp".into(),
572            prompt: format!("at {ms}"),
573            files: vec![],
574            event: "prompt".into(),
575            ms,
576            started: 1,
577            pid: None,
578        };
579        let line = |s: &str, ms: i64, key: &str| serde_json::to_string(&Remote { p: p(s, ms), key: key.into(), sig: "00".into() }).unwrap();
580        let text = [line("a", 10, "k1"), line("a", 30, "k1"), line("a", 20, "k1"), line("a", 5, "k2"), "garbage".into()].join("\n");
581        std::fs::write(remote_path(&home), text).unwrap();
582        let r = remote(&home, 40);
583        assert_eq!(r.iter().map(|r| (r.key.as_str(), r.p.ms)).collect::<Vec<_>>(), [("k1", 30), ("k2", 5)]);
584        assert!(remote(&home, 40 + PRUNE_MS).is_empty(), "a day old: gone");
585        let _ = std::fs::remove_dir_all(&home);
586    }
587
588    /// A session resumed a day later keeps its id: the prune of its presence does not show its direct
589    /// messages again.
590    #[test]
591    fn a_resumed_session_is_not_shown_its_messages_again() {
592        let home = tmp("resume");
593        let now = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_millis() as i64;
594        let input = json!({"session_id": "s2"});
595        write(&home, &update(None, "corp", "claude", "prompt", &input, now, who)).unwrap();
596        let from = crate::msg::Sender { by: "s1".into(), name: "Two of Five".into(), harness: "claude".into(), node: "n1".into() };
597        crate::msg::send(&home, crate::msg::Message::new(crate::msg::TELL, &from, "s2", None, now, "the API is stable"), now).unwrap();
598        let caps = crate::harness::by_name("claude").expect("a harness").caps();
599        assert!(crate::msg::deliver(&home, caps, "prompt", &input, Some(now), now, None).is_some(), "shown");
600
601        let later = now + PRUNE_MS + 60_000;
602        prune(&home, later);
603        assert!(read(&home, "s2").is_none(), "the presence is pruned");
604        write(&home, &update(None, "corp", "claude", "prompt", &input, later, who)).unwrap();
605        assert_eq!(crate::msg::deliver(&home, caps, "prompt", &input, Some(later), later, None), None, "not shown again");
606        let _ = std::fs::remove_dir_all(&home);
607    }
608}