Skip to main content

tapes_harnesses/transcript/
files.rs

1//! Transcript-file discovery and JSONL handling.
2//!
3//! Extracted verbatim from a daemon client's transcript file discovery, which
4//! in turn mirrors the Go reference client
5//! (`pkg/backfill/transcript_upload.go`) — so every client that feeds the
6//! transcript lane, including the manual `tapes backfill transcripts` CLI,
7//! produces byte-identical upload sets for the same on-disk state:
8//!
9//! * main transcript: `<projects_dir>/<sid>.jsonl`
10//! * subagents:       `<projects_dir>/<sid>/subagents/agent-<id>.jsonl`
11//! * fork metadata:   `<projects_dir>/<sid>/subagents/agent-<id>.meta.json`
12//!
13//! and JSONL→records conversion that skips blank or malformed lines
14//! (the harness occasionally truncates the final line mid-write) while
15//! keeping every valid line **verbatim** — the ingest server
16//! content-hashes the records array for idempotency, so the bytes must
17//! be stable across pushes.
18
19use std::path::{Path, PathBuf};
20
21use serde::Deserialize;
22
23/// Fork metadata the harness writes alongside each subagent
24/// transcript (`agent-<id>.meta.json`). `tool_use_id` is the Task
25/// tool_use that forked the agent — the causal edge the tapes deriver
26/// attaches.
27#[derive(Debug, Clone, Default, PartialEq, Eq, Deserialize)]
28#[serde(rename_all = "camelCase", default)]
29pub struct SubagentMeta {
30    /// Id of the `Task` tool_use block that spawned this subagent.
31    pub tool_use_id: String,
32    /// Subagent type (e.g. `general-purpose`).
33    pub agent_type: String,
34    /// Harness-supplied description of the delegated task.
35    pub description: String,
36}
37
38/// One transcript file in a session's upload set.
39#[derive(Debug, Clone, PartialEq, Eq)]
40pub struct TranscriptFile {
41    /// Absolute path to the `.jsonl` file.
42    pub path: PathBuf,
43    /// `None` for the main transcript; `Some(<id>)` for
44    /// `subagents/agent-<id>.jsonl`.
45    pub agent_id: Option<String>,
46    /// Parsed `agent-<id>.meta.json` when present (subagents only).
47    /// Missing or malformed meta degrades to the default (empty)
48    /// fields, matching the Go client's best-effort decode.
49    pub meta: SubagentMeta,
50}
51
52impl TranscriptFile {
53    /// Human-readable label for logs: `<sid>/main` or
54    /// `<sid>/agent-<id>`.
55    #[must_use]
56    pub fn label(&self, sid: &str) -> String {
57        match &self.agent_id {
58            None => format!("{sid}/main"),
59            Some(id) => format!("{sid}/agent-{id}"),
60        }
61    }
62}
63
64/// Discover the upload set for one session: the main transcript plus
65/// every subagent transcript (with its fork metadata). Returns an
66/// empty vec when the main transcript does not exist yet — a session
67/// can be attributed on the wire before its first transcript flush.
68///
69/// Subagent discovery failures (`subagents/` missing, unreadable) are
70/// not errors: most sessions never fork.
71#[must_use]
72pub fn session_files(projects_dir: &Path, sid: &str) -> Vec<TranscriptFile> {
73    let mut out = Vec::new();
74    let main = projects_dir.join(format!("{sid}.jsonl"));
75    if !main.is_file() {
76        return out;
77    }
78    out.push(TranscriptFile {
79        path: main,
80        agent_id: None,
81        meta: SubagentMeta::default(),
82    });
83
84    let sub_dir = projects_dir.join(sid).join("subagents");
85    let Ok(entries) = std::fs::read_dir(&sub_dir) else {
86        return out; // no subagents
87    };
88    for entry in entries.flatten() {
89        let name = entry.file_name();
90        let name = name.to_string_lossy();
91        let Some(agent_id) = name
92            .strip_prefix("agent-")
93            .and_then(|rest| rest.strip_suffix(".jsonl"))
94        else {
95            continue;
96        };
97        let meta_path = sub_dir.join(format!("agent-{agent_id}.meta.json"));
98        let meta = std::fs::read(&meta_path)
99            .ok()
100            .and_then(|raw| serde_json::from_slice::<SubagentMeta>(&raw).ok())
101            .unwrap_or_default();
102        out.push(TranscriptFile {
103            path: entry.path(),
104            agent_id: Some(agent_id.to_owned()),
105            meta,
106        });
107    }
108    // Deterministic order (read_dir order is platform-dependent):
109    // main first, then subagents sorted by id.
110    out.sort_by(|a, b| a.agent_id.cmp(&b.agent_id));
111    out
112}
113
114/// Convert raw JSONL bytes into a JSON array string, keeping each
115/// valid line **verbatim** and skipping blank or malformed lines.
116/// Verbatim matters: the ingest server's dedup key is a content hash
117/// of the records array, so any re-serialization (key reordering,
118/// whitespace changes) would register identical content as a new
119/// version.
120#[must_use]
121pub fn jsonl_to_records(raw: &[u8]) -> String {
122    let mut records = String::from("[");
123    let mut first = true;
124    for line in raw.split(|&b| b == b'\n') {
125        let line = trim_ascii(line);
126        if line.is_empty() || !is_valid_json(line) {
127            continue;
128        }
129        if !first {
130            records.push(',');
131        }
132        first = false;
133        // Valid JSON is valid UTF-8 by construction; `is_valid_json`
134        // already proved it parses, so the lossy conversion never
135        // actually replaces bytes here.
136        records.push_str(&String::from_utf8_lossy(line));
137    }
138    records.push(']');
139    records
140}
141
142/// `true` when `bytes` parse as a single JSON value — the same check
143/// as Go's `json.Valid`. `IgnoredAny` validates without building a
144/// tree, so multi-MB transcripts don't allocate per line.
145fn is_valid_json(bytes: &[u8]) -> bool {
146    serde_json::from_slice::<serde::de::IgnoredAny>(bytes).is_ok()
147}
148
149/// `slice::trim_ascii` equivalent (stable since 1.80, spelled out here
150/// to keep intent obvious): strip leading/trailing ASCII whitespace.
151fn trim_ascii(mut bytes: &[u8]) -> &[u8] {
152    while let [first, rest @ ..] = bytes {
153        if first.is_ascii_whitespace() {
154            bytes = rest;
155        } else {
156            break;
157        }
158    }
159    while let [rest @ .., last] = bytes {
160        if last.is_ascii_whitespace() {
161            bytes = rest;
162        } else {
163            break;
164        }
165    }
166    bytes
167}
168
169/// Size + mtime fingerprint used by the trigger state machine to
170/// decide whether a session's upload set changed since the last
171/// successful push. Cheap (one `stat` per file) and conservative: any
172/// fingerprint drift re-pushes, and the server dedups identical
173/// content.
174#[derive(Debug, Clone, Copy, PartialEq, Eq)]
175pub struct FileFingerprint {
176    /// File length in bytes.
177    pub len: u64,
178    /// Last-modified time.
179    pub mtime: std::time::SystemTime,
180}
181
182/// Fingerprint one file. `None` when the file vanished or `stat`
183/// failed — callers treat that as "skip this tick".
184#[must_use]
185pub fn fingerprint(path: &Path) -> Option<FileFingerprint> {
186    let meta = std::fs::metadata(path).ok()?;
187    Some(FileFingerprint {
188        len: meta.len(),
189        mtime: meta.modified().ok()?,
190    })
191}
192
193#[cfg(test)]
194#[allow(clippy::unwrap_used, clippy::expect_used)]
195mod tests {
196    use super::*;
197
198    #[test]
199    fn jsonl_to_records_keeps_valid_lines_verbatim() {
200        // Key order and spacing inside each line must survive — the
201        // server's dedup hash is computed over these exact bytes.
202        let raw = b"{\"b\":1,\"a\":2}\n{\"x\": \"y\"}\n";
203        assert_eq!(jsonl_to_records(raw), r#"[{"b":1,"a":2},{"x": "y"}]"#);
204    }
205
206    #[test]
207    fn jsonl_to_records_skips_blank_and_malformed_lines() {
208        // The harness occasionally truncates the final line mid-write;
209        // the Go client (and the server) skip it rather than fail.
210        let raw = b"{\"ok\":1}\n\n   \n{\"trunc\":tr\n{\"ok\":2}";
211        assert_eq!(jsonl_to_records(raw), r#"[{"ok":1},{"ok":2}]"#);
212    }
213
214    #[test]
215    fn jsonl_to_records_empty_input_is_empty_array() {
216        assert_eq!(jsonl_to_records(b""), "[]");
217        assert_eq!(jsonl_to_records(b"\n\n"), "[]");
218    }
219
220    #[test]
221    fn session_files_missing_main_returns_empty() {
222        let dir = tempfile::tempdir().unwrap();
223        assert!(session_files(dir.path(), "ghost").is_empty());
224    }
225
226    #[test]
227    fn session_files_main_only() {
228        let dir = tempfile::tempdir().unwrap();
229        std::fs::write(dir.path().join("sid-1.jsonl"), "{}\n").unwrap();
230        let files = session_files(dir.path(), "sid-1");
231        assert_eq!(files.len(), 1);
232        assert_eq!(files[0].agent_id, None);
233        assert_eq!(files[0].label("sid-1"), "sid-1/main");
234    }
235
236    #[test]
237    fn session_files_discovers_subagents_with_meta() {
238        let dir = tempfile::tempdir().unwrap();
239        std::fs::write(dir.path().join("sid-1.jsonl"), "{}\n").unwrap();
240        let sub = dir.path().join("sid-1").join("subagents");
241        std::fs::create_dir_all(&sub).unwrap();
242        std::fs::write(sub.join("agent-abc.jsonl"), "{}\n").unwrap();
243        std::fs::write(
244            sub.join("agent-abc.meta.json"),
245            r#"{"toolUseId":"toolu_01","agentType":"general-purpose","description":"dig"}"#,
246        )
247        .unwrap();
248        // A subagent without meta.json degrades to empty fields.
249        std::fs::write(sub.join("agent-zzz.jsonl"), "{}\n").unwrap();
250        // Non-transcript noise is ignored.
251        std::fs::write(sub.join("agent-abc.meta.json.bak"), "junk").unwrap();
252        std::fs::write(sub.join("notes.txt"), "junk").unwrap();
253
254        let files = session_files(dir.path(), "sid-1");
255        assert_eq!(files.len(), 3);
256        assert_eq!(files[0].agent_id, None, "main sorts first");
257        assert_eq!(files[1].agent_id.as_deref(), Some("abc"));
258        assert_eq!(files[1].meta.tool_use_id, "toolu_01");
259        assert_eq!(files[1].meta.agent_type, "general-purpose");
260        assert_eq!(files[1].meta.description, "dig");
261        assert_eq!(files[1].label("sid-1"), "sid-1/agent-abc");
262        assert_eq!(files[2].agent_id.as_deref(), Some("zzz"));
263        assert_eq!(files[2].meta, SubagentMeta::default());
264    }
265
266    #[test]
267    fn fingerprint_tracks_len() {
268        let dir = tempfile::tempdir().unwrap();
269        let p = dir.path().join("f.jsonl");
270        std::fs::write(&p, "{}\n").unwrap();
271        let a = fingerprint(&p).unwrap();
272        assert_eq!(a.len, 3);
273        std::fs::write(&p, "{}\n{}\n").unwrap();
274        let b = fingerprint(&p).unwrap();
275        assert_ne!(a, b);
276        assert!(fingerprint(&dir.path().join("missing")).is_none());
277    }
278}