tapes_harnesses/transcript/
files.rs1use std::path::{Path, PathBuf};
20
21use serde::Deserialize;
22
23#[derive(Debug, Clone, Default, PartialEq, Eq, Deserialize)]
28#[serde(rename_all = "camelCase", default)]
29pub struct SubagentMeta {
30 pub tool_use_id: String,
32 pub agent_type: String,
34 pub description: String,
36}
37
38#[derive(Debug, Clone, PartialEq, Eq)]
40pub struct TranscriptFile {
41 pub path: PathBuf,
43 pub agent_id: Option<String>,
46 pub meta: SubagentMeta,
50}
51
52impl TranscriptFile {
53 #[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#[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; };
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 out.sort_by(|a, b| a.agent_id.cmp(&b.agent_id));
111 out
112}
113
114#[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 records.push_str(&String::from_utf8_lossy(line));
137 }
138 records.push(']');
139 records
140}
141
142fn is_valid_json(bytes: &[u8]) -> bool {
146 serde_json::from_slice::<serde::de::IgnoredAny>(bytes).is_ok()
147}
148
149fn 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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
175pub struct FileFingerprint {
176 pub len: u64,
178 pub mtime: std::time::SystemTime,
180}
181
182#[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 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 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 std::fs::write(sub.join("agent-zzz.jsonl"), "{}\n").unwrap();
250 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}