Skip to main content

tapes_harnesses/transcript/
sweep.rs

1//! Sweep-on-start: find transcripts on disk that no live session would reveal.
2//!
3//! This is the one piece of the transcript lane that is **new** rather than
4//! moved, and it closes a real gap in the design it was extracted from.
5//!
6//! # The gap
7//!
8//! A daemon client learns which sessions to upload from its proxy's per-request
9//! attribution: a forwarded request maps to a `~/.claude/sessions/<pid>.json`,
10//! and that session enters the registry. The registry lives in memory only, and
11//! is rebuilt from live traffic after a restart. That works for any session still
12//! running — its next request re-registers it — but a session that **started and
13//! ended while the daemon was down**, or one that was mid-flight when the daemon
14//! died and exited before it came back, is never re-registered. Its transcript
15//! sits on disk indefinitely, recoverable only by someone remembering to run
16//! `tapes backfill transcripts` by hand.
17//!
18//! The window is not exotic: it is every daemon restart, every upgrade, and every
19//! crash. And the transcript is the *only* source of the causal/fork skeleton —
20//! wire capture yields a complete call inventory but no fork edges — so a missed
21//! transcript is permanently missing structure, not a delayed duplicate.
22//!
23//! # The fix
24//!
25//! [`sweep`] reads the transcript tree directly instead of asking the registry,
26//! so it sees every session that ever wrote a transcript under the given root
27//! regardless of whether a process is alive. A client runs it once at startup and
28//! pushes what it finds.
29//!
30//! This is only safe because the ingest endpoint dedups on a content hash (see
31//! [`super::payload`]): a sweep that re-offers a thousand already-uploaded
32//! transcripts costs bandwidth and answers `deduped`, and re-offering is
33//! precisely the point — the client cannot know what the *previous* process
34//! managed to send. [`SweepOptions::modified_within`] exists to bound that cost,
35//! not to make it correct.
36//!
37//! # Recovering the session envelope
38//!
39//! The projects directory name is the cwd with `/` replaced by `-` (see
40//! [`crate::attribution::claude::fork_parent::encode_cwd`]), which is **not reversible**:
41//! a path containing a literal `-` decodes ambiguously. So sweep does not decode
42//! it. It reads the head of the transcript instead, where the harness records the
43//! true `cwd` and its own `version` on most records — exact values rather than
44//! guesses.
45
46use std::path::{Path, PathBuf};
47use std::time::{Duration, SystemTime};
48
49use serde::Deserialize;
50
51use super::files::{self, TranscriptFile};
52
53/// Bytes of each transcript read while recovering `cwd` / `version`.
54///
55/// The harness writes a handful of preamble records (`mode`,
56/// `permission-mode`, `file-history-snapshot`) that carry neither field before
57/// the first record that does, so this has to be more than a line or two — but a
58/// transcript can be many megabytes, and sweeping a large tree must not read all
59/// of it.
60const HEAD_SCAN_BYTES: usize = 64 * 1024;
61
62/// A session discovered on disk by [`sweep`].
63#[derive(Debug, Clone, PartialEq, Eq)]
64pub struct SweptSession {
65    /// The harness session id, from the transcript's filename.
66    pub session_id: String,
67    /// The project directory the transcript set lives in.
68    pub projects_dir: PathBuf,
69    /// True working directory, read out of the transcript's own records. `None`
70    /// when no record in the scanned head carried one.
71    pub cwd: Option<String>,
72    /// Harness version, read out of the transcript's own records.
73    pub harness_version: Option<String>,
74    /// The session's full upload set — main transcript plus any subagents, in the
75    /// same order [`files::session_files`] returns.
76    pub files: Vec<TranscriptFile>,
77}
78
79/// Bounds on what a sweep will report.
80#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
81pub struct SweepOptions {
82    /// Only report sessions whose newest transcript file was modified within this
83    /// window. `None` reports everything under the root.
84    ///
85    /// A cost control, not a correctness one: the endpoint dedups, so a wider
86    /// window is always safe. Clients with a long-lived transcript tree will want
87    /// one — a year of sessions is a lot of pointless `deduped` responses at
88    /// every restart.
89    pub modified_within: Option<Duration>,
90}
91
92impl SweepOptions {
93    /// Report only sessions touched within `window`.
94    #[must_use]
95    pub fn modified_within(window: Duration) -> Self {
96        Self {
97            modified_within: Some(window),
98        }
99    }
100}
101
102/// Walk `projects_root` and return every session with a transcript on disk.
103///
104/// `projects_root` is the harness's project tree — `~/.claude/projects/` — whose
105/// immediate children are cwd-encoded directories, each holding `<sid>.jsonl`
106/// files.
107///
108/// Best-effort throughout: an unreadable root yields an empty vec, and an
109/// unreadable subdirectory or a vanished file is skipped rather than failing the
110/// sweep. A startup scan that cannot read one project must still report the rest.
111///
112/// Results are sorted by session id so a sweep is reproducible; `read_dir` order
113/// is platform-dependent.
114#[must_use]
115pub fn sweep(projects_root: &Path, options: &SweepOptions) -> Vec<SweptSession> {
116    let cutoff = options
117        .modified_within
118        .and_then(|window| SystemTime::now().checked_sub(window));
119
120    let Ok(projects) = std::fs::read_dir(projects_root) else {
121        return Vec::new();
122    };
123    let mut out = Vec::new();
124    for project in projects.flatten() {
125        let projects_dir = project.path();
126        if !projects_dir.is_dir() {
127            continue;
128        }
129        let Ok(entries) = std::fs::read_dir(&projects_dir) else {
130            continue;
131        };
132        for entry in entries.flatten() {
133            let name = entry.file_name();
134            let name = name.to_string_lossy();
135            let Some(session_id) = name.strip_suffix(".jsonl") else {
136                continue;
137            };
138            // `session_files` re-stats the main transcript and returns an empty
139            // set if it is not a regular file, which also filters directories
140            // that happen to end in `.jsonl`.
141            let set = files::session_files(&projects_dir, session_id);
142            if set.is_empty() {
143                continue;
144            }
145            if let Some(cutoff) = cutoff
146                && !touched_since(&set, cutoff)
147            {
148                continue;
149            }
150            let (cwd, harness_version) = read_session_facts(&entry.path());
151            out.push(SweptSession {
152                session_id: session_id.to_owned(),
153                projects_dir: projects_dir.clone(),
154                cwd,
155                harness_version,
156                files: set,
157            });
158        }
159    }
160    // projects_dir breaks ties so equal session ids (the same session seen
161    // under two roots) sweep in one reproducible order rather than
162    // filesystem-enumeration order.
163    out.sort_by(|a, b| {
164        a.session_id
165            .cmp(&b.session_id)
166            .then_with(|| a.projects_dir.cmp(&b.projects_dir))
167    });
168    out
169}
170
171/// `true` when any file in the set was modified at or after `cutoff`.
172fn touched_since(set: &[TranscriptFile], cutoff: SystemTime) -> bool {
173    set.iter()
174        .filter_map(|file| files::fingerprint(&file.path))
175        .any(|fp| fp.mtime >= cutoff)
176}
177
178/// One transcript record, reduced to the two facts a sweep needs.
179///
180/// The harness stamps `cwd` and `version` on most records but not on its preamble
181/// (`mode`, `permission-mode`, `file-history-snapshot`), so a scan takes the
182/// first of each it finds rather than assuming record zero has them.
183#[derive(Deserialize)]
184struct SessionFacts {
185    cwd: Option<String>,
186    version: Option<String>,
187}
188
189/// Recover `(cwd, harness_version)` from the head of a transcript.
190///
191/// Returns `(None, None)` when the file cannot be read or no scanned record
192/// carried either field — both are optional on the wire, so an unknown value
193/// simply travels as absent.
194fn read_session_facts(path: &Path) -> (Option<String>, Option<String>) {
195    let Some(head) = read_head(path, HEAD_SCAN_BYTES) else {
196        return (None, None);
197    };
198    let mut cwd = None;
199    let mut version = None;
200    for line in head.split(|&b| b == b'\n') {
201        // The final line of a bounded read is usually truncated mid-record; a
202        // failed parse is expected and simply skipped.
203        let Ok(facts) = serde_json::from_slice::<SessionFacts>(line) else {
204            continue;
205        };
206        if cwd.is_none() {
207            cwd = facts.cwd.filter(|value| !value.is_empty());
208        }
209        if version.is_none() {
210            version = facts.version.filter(|value| !value.is_empty());
211        }
212        if cwd.is_some() && version.is_some() {
213            break;
214        }
215    }
216    (cwd, version)
217}
218
219/// Read up to `cap` bytes from the head of `path`.
220fn read_head(path: &Path, cap: usize) -> Option<Vec<u8>> {
221    use std::io::Read;
222    let mut file = std::fs::File::open(path).ok()?;
223    let mut buf = vec![0u8; cap];
224    let read = file.read(&mut buf).ok()?;
225    buf.truncate(read);
226    Some(buf)
227}
228
229#[cfg(test)]
230#[allow(clippy::unwrap_used, clippy::expect_used)]
231mod tests {
232    use super::*;
233
234    /// Lay down `<root>/<encoded-cwd>/<sid>.jsonl` with records that carry the
235    /// harness's `cwd` and `version`, preceded by the preamble records that
236    /// carry neither.
237    fn write_session(root: &Path, cwd: &str, sid: &str) -> PathBuf {
238        let dir = root.join(crate::attribution::claude::fork_parent::encode_cwd(cwd));
239        std::fs::create_dir_all(&dir).unwrap();
240        let path = dir.join(format!("{sid}.jsonl"));
241        // Two preamble records carrying neither fact, then a real one — the
242        // layout a live harness writes.
243        let body = format!(
244            concat!(
245                "{{\"type\":\"mode\",\"sessionId\":\"x\"}}\n",
246                "{{\"type\":\"file-history-snapshot\"}}\n",
247                "{{\"type\":\"user\",\"sessionId\":\"{sid}\",\"cwd\":\"{cwd}\",",
248                "\"version\":\"2.1.205\"}}\n",
249            ),
250            sid = sid,
251            cwd = cwd,
252        );
253        std::fs::write(&path, body).unwrap();
254        path
255    }
256
257    /// Backdate a file's mtime without sleeping.
258    fn backdate(path: &Path, by: Duration) {
259        let file = std::fs::File::options().append(true).open(path).unwrap();
260        file.set_modified(SystemTime::now() - by).unwrap();
261    }
262
263    /// The gap this module exists to close.
264    ///
265    /// A client whose session registry is rebuilt from live traffic can only see
266    /// `live` — the session with a `sessions/<pid>.json` behind it. `orphan`
267    /// started and ended while the client was down, so no request will ever
268    /// re-register it and its transcript would sit on disk forever. Sweep reads
269    /// the transcript tree instead of the registry, so it reports both.
270    ///
271    /// The `assert!` on `orphan` is the one that would fail against pre-sweep
272    /// behaviour: registry-driven discovery returns exactly `[live]`.
273    #[test]
274    fn sweep_finds_sessions_no_live_registry_would_reveal() {
275        let root = tempfile::tempdir().unwrap();
276        write_session(root.path(), "/x/y", "live");
277        write_session(root.path(), "/a/b", "orphan");
278
279        let swept = sweep(root.path(), &SweepOptions::default());
280        let ids: Vec<&str> = swept.iter().map(|s| s.session_id.as_str()).collect();
281        assert_eq!(ids, vec!["live", "orphan"], "sorted by session id");
282        assert!(
283            ids.contains(&"orphan"),
284            "a session with no live process must still be swept",
285        );
286    }
287
288    /// Sweep recovers the *true* cwd from the transcript's own records rather
289    /// than decoding the directory name — which is not decodable, because
290    /// `encode_cwd` maps `/` to `-` and a path containing a literal `-` is
291    /// ambiguous. This cwd round-trips to the same encoded directory as
292    /// `/opt/my-project`, so a decoder would have to guess.
293    #[test]
294    fn sweep_reads_the_true_cwd_and_version_from_the_transcript() {
295        let root = tempfile::tempdir().unwrap();
296        write_session(root.path(), "/opt/my-project", "sid-1");
297
298        let swept = sweep(root.path(), &SweepOptions::default());
299        assert_eq!(swept.len(), 1);
300        assert_eq!(swept[0].cwd.as_deref(), Some("/opt/my-project"));
301        assert_eq!(swept[0].harness_version.as_deref(), Some("2.1.205"));
302        assert_eq!(
303            swept[0].projects_dir.file_name().unwrap().to_string_lossy(),
304            "-opt-my-project",
305            "the encoded directory is genuinely ambiguous, hence reading the records",
306        );
307    }
308
309    /// A transcript whose records carry neither fact still sweeps — both fields
310    /// are optional on the wire, so unknown travels as absent rather than
311    /// blocking the upload.
312    #[test]
313    fn sweep_reports_a_session_whose_records_carry_no_facts() {
314        let root = tempfile::tempdir().unwrap();
315        let dir = root.path().join("-x-y");
316        std::fs::create_dir_all(&dir).unwrap();
317        std::fs::write(dir.join("bare.jsonl"), "{\"type\":\"mode\"}\n").unwrap();
318
319        let swept = sweep(root.path(), &SweepOptions::default());
320        assert_eq!(swept.len(), 1);
321        assert_eq!(swept[0].session_id, "bare");
322        assert_eq!(swept[0].cwd, None);
323        assert_eq!(swept[0].harness_version, None);
324    }
325
326    /// The full upload set comes along, subagents and fork metadata included —
327    /// sweep is discovery for the same push path a live session uses, not a
328    /// reduced one.
329    #[test]
330    fn sweep_carries_the_whole_upload_set() {
331        let root = tempfile::tempdir().unwrap();
332        write_session(root.path(), "/x/y", "sid-1");
333        let sub = root.path().join("-x-y").join("sid-1").join("subagents");
334        std::fs::create_dir_all(&sub).unwrap();
335        std::fs::write(sub.join("agent-abc.jsonl"), "{}\n").unwrap();
336        std::fs::write(
337            sub.join("agent-abc.meta.json"),
338            r#"{"toolUseId":"toolu_7","agentType":"explore","description":"d"}"#,
339        )
340        .unwrap();
341
342        let swept = sweep(root.path(), &SweepOptions::default());
343        assert_eq!(swept.len(), 1);
344        assert_eq!(swept[0].files.len(), 2);
345        assert_eq!(swept[0].files[0].agent_id, None, "main sorts first");
346        assert_eq!(swept[0].files[1].agent_id.as_deref(), Some("abc"));
347        assert_eq!(swept[0].files[1].meta.tool_use_id, "toolu_7");
348    }
349
350    /// The age window bounds sweep's cost on a long-lived transcript tree.
351    #[test]
352    fn sweep_honours_the_modified_within_window() {
353        let root = tempfile::tempdir().unwrap();
354        write_session(root.path(), "/x/y", "recent");
355        let stale = write_session(root.path(), "/a/b", "stale");
356        backdate(&stale, Duration::from_secs(60 * 60 * 24 * 30));
357
358        let all = sweep(root.path(), &SweepOptions::default());
359        assert_eq!(all.len(), 2, "no window reports everything");
360
361        let recent = sweep(
362            root.path(),
363            &SweepOptions::modified_within(Duration::from_secs(3600)),
364        );
365        assert_eq!(recent.len(), 1);
366        assert_eq!(recent[0].session_id, "recent");
367    }
368
369    /// A session is inside the window when *any* of its files is: a long-idle
370    /// main transcript whose subagent just wrote must not be dropped.
371    #[test]
372    fn sweep_window_considers_the_newest_file_in_the_set() {
373        let root = tempfile::tempdir().unwrap();
374        let main = write_session(root.path(), "/x/y", "sid-1");
375        let sub = root.path().join("-x-y").join("sid-1").join("subagents");
376        std::fs::create_dir_all(&sub).unwrap();
377        std::fs::write(sub.join("agent-abc.jsonl"), "{}\n").unwrap();
378        backdate(&main, Duration::from_secs(60 * 60 * 24 * 30));
379
380        let swept = sweep(
381            root.path(),
382            &SweepOptions::modified_within(Duration::from_secs(3600)),
383        );
384        assert_eq!(swept.len(), 1, "the fresh subagent keeps the session in");
385    }
386
387    /// Non-transcript noise and an unreadable root are both non-events: a
388    /// startup scan must degrade rather than fail.
389    #[test]
390    fn sweep_ignores_noise_and_a_missing_root() {
391        let root = tempfile::tempdir().unwrap();
392        let dir = root.path().join("-x-y");
393        std::fs::create_dir_all(&dir).unwrap();
394        std::fs::write(dir.join("notes.txt"), "junk").unwrap();
395        std::fs::write(dir.join("archive.jsonl.bak"), "junk").unwrap();
396        // A directory whose name ends in .jsonl is not a transcript.
397        std::fs::create_dir_all(dir.join("weird.jsonl")).unwrap();
398        // A stray file directly under the root, not inside a project dir.
399        std::fs::write(root.path().join("loose.jsonl"), "{}\n").unwrap();
400
401        assert!(sweep(root.path(), &SweepOptions::default()).is_empty());
402        assert!(sweep(&root.path().join("nope"), &SweepOptions::default()).is_empty());
403    }
404}