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}