1use std::collections::HashMap;
28use std::sync::{Arc, Mutex};
29
30use super::adapter::{HistoryError, HistoryMessage};
31use crate::backend::agent_session::{OUTPUT_FILE, TSIDX_FILE};
32use crate::backend::storage::filestore::FileStore;
33
34const READ_CHUNK: i64 = 4 * 1024 * 1024;
36
37#[derive(Debug, Clone, PartialEq)]
39pub(crate) struct RecordSession {
40 pub session_id: String,
41 pub cwd: Option<String>,
43 pub ranges: Vec<(String, i64, i64)>,
46 pub first_ms: i64,
48 pub last_ms: i64,
49}
50
51#[derive(Debug, Clone, PartialEq)]
53struct Run {
54 session_id: String,
55 start: i64,
56 end: i64,
57}
58
59#[derive(Debug, Default, Clone)]
61struct ZoneScan {
62 read_to: i64,
64 runs: Vec<Run>,
65 unowned_from: Option<i64>,
68 cwd: HashMap<String, String>,
69}
70
71pub(crate) struct AgentRecords {
74 store: StoreSource,
75 scans: Mutex<HashMap<String, ZoneScan>>,
76}
77
78enum StoreSource {
79 Global,
81 #[cfg(test)]
82 Fixed(Option<Arc<FileStore>>),
83}
84
85impl AgentRecords {
86 pub(crate) fn global() -> Self {
87 AgentRecords { store: StoreSource::Global, scans: Mutex::new(HashMap::new()) }
88 }
89
90 #[cfg(test)]
91 pub(crate) fn new(store: Option<Arc<FileStore>>) -> Self {
92 AgentRecords { store: StoreSource::Fixed(store), scans: Mutex::new(HashMap::new()) }
93 }
94
95 fn store(&self) -> Option<Arc<FileStore>> {
96 match &self.store {
97 StoreSource::Global => crate::backend::agent_session::global_transcript_store().cloned(),
98 #[cfg(test)]
99 StoreSource::Fixed(store) => store.clone(),
100 }
101 }
102
103 pub(crate) fn sessions_of(&self, uid: &str) -> (Vec<RecordSession>, Vec<String>) {
107 let Some(store) = self.store() else {
108 return (Vec::new(), Vec::new());
109 };
110 let store = &*store;
111 if !crate::backend::agent_session::is_valid_definition_id(uid) {
112 return (Vec::new(), Vec::new());
113 }
114 let mut problems = Vec::new();
115 let zones = match store.get_all_zone_ids() {
116 Ok(all) => all.into_iter().filter(|z| is_agents_own_zone(z, uid)).collect::<Vec<_>>(),
117 Err(e) => return (Vec::new(), vec![format!("agent record: listing zones failed: {e}")]),
118 };
119
120 let mut by_id: HashMap<String, RecordSession> = HashMap::new();
121 let mut order: Vec<String> = Vec::new();
122 for zone in &zones {
123 let scan = {
124 let mut scans = self.scans.lock().unwrap_or_else(|p| p.into_inner());
125 let scan = scans.entry(zone.clone()).or_default();
126 if let Err(e) = scan_zone(store, zone, scan) {
127 problems.push(format!("agent record {zone}: {e}"));
128 }
129 scan.clone()
130 };
131 let mut runs = scan.runs.clone();
135 if let (Some(from), Some(last)) = (scan.unowned_from, runs.last_mut()) {
136 if zone.contains(":archive:") {
137 last.end = last.end.max(scan.read_to.max(from));
138 }
139 }
140 let stamps = zone_stamps(store, zone, &runs);
141 for (i, run) in runs.iter().enumerate() {
142 let (first, last) = stamps.get(i).copied().unwrap_or((0, 0));
143 let session = by_id.entry(run.session_id.clone()).or_insert_with(|| {
144 order.push(run.session_id.clone());
145 RecordSession {
146 session_id: run.session_id.clone(),
147 cwd: scan.cwd.get(&run.session_id).cloned(),
148 ranges: Vec::new(),
149 first_ms: 0,
150 last_ms: 0,
151 }
152 });
153 session.ranges.push((zone.clone(), run.start, run.end));
154 if first != 0 && (session.first_ms == 0 || first < session.first_ms) {
155 session.first_ms = first;
156 }
157 session.last_ms = session.last_ms.max(last);
158 if session.cwd.is_none() {
159 session.cwd = scan.cwd.get(&run.session_id).cloned();
160 }
161 }
162 }
163
164 for segment in crate::backend::continuity_segments::segments(store, uid) {
167 let Some(sid) = segment.start.provider_session_id else { continue };
168 by_id.entry(sid.clone()).or_insert_with(|| {
169 order.push(sid.clone());
170 RecordSession {
171 session_id: sid,
172 cwd: Some(segment.start.cwd).filter(|c| !c.is_empty()),
173 ranges: Vec::new(),
174 first_ms: segment.start.started_at_ms,
175 last_ms: segment.ended_at_ms.unwrap_or(segment.start.started_at_ms),
176 }
177 });
178 }
179
180 let position: HashMap<&str, usize> = order.iter().enumerate().map(|(i, id)| (id.as_str(), i)).collect();
181 let mut sessions: Vec<RecordSession> = by_id.into_values().collect();
182 sessions.sort_by(|a, b| {
183 b.last_ms
184 .cmp(&a.last_ms)
185 .then(position[b.session_id.as_str()].cmp(&position[a.session_id.as_str()]))
186 });
187 (sessions, problems)
188 }
189
190 pub(crate) fn read(&self, session: &RecordSession) -> Result<(Vec<HistoryMessage>, u32), HistoryError> {
193 let Some(store) = self.store() else {
194 return Err(HistoryError::Other("AgentMux's transcript store isn't available".into()));
195 };
196 let store = &*store;
197 let mut messages = Vec::new();
198 let mut skipped = 0u32;
199 for (zone, start, end) in &session.ranges {
200 let mut offsets: Vec<u64> = Vec::new();
201 let mut zone_messages: Vec<HistoryMessage> = Vec::new();
202 for_each_line(store, zone, *start, *end, |line, offset| {
203 if line.iter().all(u8::is_ascii_whitespace) || is_stream_event(line) {
204 return;
205 }
206 let Ok(entry) = serde_json::from_slice::<serde_json::Value>(line) else {
207 skipped += 1;
208 return;
209 };
210 if entry.get("session_id").and_then(|v| v.as_str()).is_some_and(|sid| sid != session.session_id) {
211 return;
212 }
213 if let Some(message) = super::claude_adapter::message_from_entry(&entry, 0) {
214 offsets.push(offset as u64);
215 zone_messages.push(message);
216 }
217 })
218 .map_err(|e| HistoryError::Other(format!("agent record {zone}: {e}")))?;
219 if let Some(stamps) = line_stamps(store, zone, &offsets) {
220 for (message, ms) in zone_messages.iter_mut().zip(stamps) {
221 message.timestamp = ms;
222 }
223 }
224 messages.extend(zone_messages);
225 }
226 Ok((messages, skipped))
227 }
228}
229
230fn is_agents_own_zone(zone: &str, uid: &str) -> bool {
233 let Some(rest) = zone.strip_prefix("agent:").and_then(|z| z.strip_prefix(uid)) else {
234 return false;
235 };
236 rest == ":current" || rest.strip_prefix(":archive:").is_some_and(|ms| !ms.is_empty() && ms.bytes().all(|b| b.is_ascii_digit()))
237}
238
239fn is_stream_event(line: &[u8]) -> bool {
241 memchr::memmem::find(line.get(..48).unwrap_or(line), b"\"stream_event\"").is_some()
242}
243
244fn line_session_id(line: &[u8]) -> Option<String> {
248 if is_stream_event(line) {
249 const KEY: &[u8] = b"\"session_id\":\"";
250 let at = memchr::memmem::rfind(line, KEY)? + KEY.len();
251 let len = memchr::memchr(b'"', &line[at..])?;
252 return String::from_utf8(line[at..at + len].to_vec()).ok().filter(|s| !s.is_empty());
253 }
254 #[derive(serde::Deserialize)]
256 struct Top {
257 #[serde(default)]
258 session_id: Option<String>,
259 }
260 serde_json::from_slice::<Top>(line).ok()?.session_id.filter(|s| !s.is_empty())
261}
262
263fn init_cwd(line: &[u8]) -> Option<String> {
265 if memchr::memmem::find(line.get(..96).unwrap_or(line), b"\"subtype\":\"init\"").is_none() {
267 return None;
268 }
269 let entry: serde_json::Value = serde_json::from_slice(line).ok()?;
270 (entry.get("subtype").and_then(|v| v.as_str()) == Some("init"))
271 .then(|| entry.get("cwd").and_then(|v| v.as_str()).map(str::to_string))
272 .flatten()
273}
274
275fn scan_line(scan: &mut ZoneScan, line: &[u8], start: i64, end: i64) {
276 if line.iter().all(u8::is_ascii_whitespace) {
277 return;
278 }
279 let Some(sid) = line_session_id(line) else {
280 scan.unowned_from.get_or_insert(start);
281 return;
282 };
283 let from = scan.unowned_from.take().unwrap_or(start);
284 match scan.runs.last_mut() {
285 Some(run) if run.session_id == sid && run.end == from => run.end = end,
286 _ => scan.runs.push(Run { session_id: sid.clone(), start: from, end }),
287 }
288 if let Some(cwd) = init_cwd(line) {
289 scan.cwd.entry(sid).or_insert(cwd);
290 }
291}
292
293fn scan_zone(store: &FileStore, zone: &str, scan: &mut ZoneScan) -> Result<(), String> {
296 let size = match store.stat(zone, OUTPUT_FILE).map_err(|e| e.to_string())? {
297 Some(file) => file.size,
298 None => {
299 *scan = ZoneScan::default();
300 return Ok(());
301 }
302 };
303 if size < scan.read_to {
304 *scan = ZoneScan::default();
305 }
306 let from = scan.read_to;
307 let mut last_complete = from;
308 for_each_line(store, zone, from, size, |line, offset| {
309 let end = offset + line.len() as i64 + 1;
310 scan_line(scan, line, offset, end);
311 last_complete = end;
312 })?;
313 scan.read_to = last_complete;
314 Ok(())
315}
316
317fn for_each_line(
321 store: &FileStore,
322 zone: &str,
323 start: i64,
324 end: i64,
325 mut f: impl FnMut(&[u8], i64),
326) -> Result<(), String> {
327 let mut pos = start;
328 let mut carry: Vec<u8> = Vec::new();
329 let mut carry_at = start;
330 while pos < end {
331 let (_, data) = store
332 .read_at(zone, OUTPUT_FILE, pos, READ_CHUNK.min(end - pos))
333 .map_err(|e| e.to_string())?;
334 if data.is_empty() {
335 break;
336 }
337 pos += data.len() as i64;
338 carry.extend_from_slice(&data);
339 let mut line_start = 0usize;
340 while let Some(nl) = memchr::memchr(b'\n', &carry[line_start..]) {
341 f(&carry[line_start..line_start + nl], carry_at + line_start as i64);
342 line_start += nl + 1;
343 }
344 carry.drain(..line_start);
345 carry_at += line_start as i64;
346 }
347 Ok(())
348}
349
350fn line_stamps(store: &FileStore, zone: &str, offsets: &[u64]) -> Option<Vec<i64>> {
353 let size = store.stat(zone, TSIDX_FILE).ok()??.size;
354 let read = |off: i64, len: i64| store.read_at(zone, TSIDX_FILE, off, len).map(|(_, data)| Some(data));
355 crate::backend::blockcontroller::shell::stamps::stamps_for(&read, size, offsets).ok()?
356}
357
358fn zone_stamps(store: &FileStore, zone: &str, runs: &[Run]) -> Vec<(i64, i64)> {
361 let mut points: Vec<u64> = runs
362 .iter()
363 .flat_map(|r| [r.start as u64, (r.end - 1).max(r.start) as u64])
364 .collect();
365 let original = points.clone();
366 points.sort_unstable();
367 points.dedup();
368 let Some(stamps) = line_stamps(store, zone, &points) else {
369 return vec![(0, 0); runs.len()];
370 };
371 let at: HashMap<u64, i64> = points.into_iter().zip(stamps).collect();
372 original.chunks(2).map(|p| (at[&p[0]], at[&p[1]])).collect()
373}
374
375#[cfg(test)]
377pub(crate) mod fixtures {
378 use super::*;
379 use crate::backend::storage::filestore::{FileMeta, FileOpts};
380
381 pub(crate) const UID: &str = "4d2a2b8c-1111-4222-8333-944455556666";
382
383 pub(crate) fn store() -> Arc<FileStore> {
384 Arc::new(FileStore::open_in_memory().unwrap())
385 }
386
387 pub(crate) fn append(fs: &FileStore, zone: &str, lines: &[String]) {
388 if fs.stat(zone, OUTPUT_FILE).unwrap().is_none() {
389 fs.make_file(zone, OUTPUT_FILE, FileMeta::default(), FileOpts::default()).unwrap();
390 }
391 let mut data = lines.join("\n");
392 data.push('\n');
393 fs.append_data(zone, OUTPUT_FILE, data.as_bytes()).unwrap();
394 }
395
396 pub(crate) fn stamp(fs: &FileStore, zone: &str, off: u64, ms: i64) {
397 if fs.stat(zone, TSIDX_FILE).unwrap().is_none() {
398 fs.make_file(zone, TSIDX_FILE, FileMeta::default(), FileOpts::default()).unwrap();
399 }
400 fs.append_data(zone, TSIDX_FILE, format!("{{\"off\":{off},\"ms\":{ms}}}\n").as_bytes()).unwrap();
401 }
402
403 pub(crate) fn init(sid: &str, cwd: &str) -> String {
404 format!(r#"{{"type":"system","subtype":"init","cwd":"{cwd}","session_id":"{sid}","tools":[]}}"#)
405 }
406 pub(crate) fn assistant(sid: &str, text: &str) -> String {
407 format!(
408 r#"{{"type":"assistant","message":{{"role":"assistant","content":[{{"type":"text","text":"{text}"}}]}},"parent_tool_use_id":null,"session_id":"{sid}"}}"#
409 )
410 }
411 pub(crate) fn typed(text: &str) -> String {
412 format!(r#"{{"type":"user","message":{{"role":"user","content":"{text}"}}}}"#)
413 }
414 pub(crate) fn delta(sid: &str) -> String {
415 format!(
416 r#"{{"type":"stream_event","event":{{"type":"content_block_delta","delta":{{"text":"has \"session_id\":\"not-me\" inside"}}}},"session_id":"{sid}","parent_tool_use_id":null}}"#
417 )
418 }
419
420 pub(crate) fn zone() -> String {
421 format!("agent:{UID}:current")
422 }
423
424}
425
426#[cfg(test)]
427mod tests {
428 use super::fixtures::*;
429 use super::*;
430 use crate::backend::storage::filestore::{FileMeta, FileOpts};
431
432 #[test]
435 fn sessions_are_read_from_the_record_not_guessed() {
436 let fs = store();
437 append(&fs, &zone(), &[init("s1", "/work/a"), assistant("s1", "first answer"), delta("s1")]);
438 append(&fs, &zone(), &[typed("second question"), init("s2", "/work/b"), assistant("s2", "second answer")]);
439 let records = AgentRecords::new(Some(fs));
440
441 let (sessions, problems) = records.sessions_of(UID);
442 assert!(problems.is_empty(), "{problems:?}");
443 let ids: Vec<&str> = sessions.iter().map(|s| s.session_id.as_str()).collect();
444 assert_eq!(ids, vec!["s2", "s1"], "newest first");
445 assert_eq!(sessions[0].cwd.as_deref(), Some("/work/b"));
446 assert_eq!(sessions[1].cwd.as_deref(), Some("/work/a"));
447
448 let (s2, skipped) = records.read(&sessions[0]).unwrap();
449 let texts: Vec<&str> = s2.iter().map(|m| m.content.as_str()).collect();
450 assert_eq!(texts, vec!["second question", "second answer"], "the typed message is s2's");
451 assert_eq!(skipped, 0);
452 let (s1, _) = records.read(&sessions[1]).unwrap();
453 assert_eq!(s1.iter().map(|m| m.content.as_str()).collect::<Vec<_>>(), vec!["first answer"]);
454 }
455
456 #[test]
459 fn a_rescan_picks_up_what_was_appended() {
460 let fs = store();
461 append(&fs, &zone(), &[init("s1", "/w"), assistant("s1", "one")]);
462 let records = AgentRecords::new(Some(fs.clone()));
463 assert_eq!(records.sessions_of(UID).0.len(), 1);
464
465 append(&fs, &zone(), &[typed("more"), assistant("s1", "two"), init("s3", "/w"), assistant("s3", "three")]);
466 let (sessions, _) = records.sessions_of(UID);
467 let s1 = sessions.iter().find(|s| s.session_id == "s1").unwrap();
468 assert_eq!(s1.ranges.len(), 1, "s1's lines are one run");
469 let texts: Vec<String> = records.read(s1).unwrap().0.into_iter().map(|m| m.content).collect();
470 assert_eq!(texts, vec!["one", "more", "two"]);
471 assert!(sessions.iter().any(|s| s.session_id == "s3"));
472 }
473
474 #[test]
478 fn an_archived_conversation_stays_and_the_new_one_is_read_afresh() {
479 let fs = store();
480 append(&fs, &zone(), &[init("old", "/w"), assistant("old", "before the archive, a long answer")]);
481 let records = AgentRecords::new(Some(fs.clone()));
482 assert_eq!(records.sessions_of(UID).0.len(), 1);
483
484 let archive = format!("agent:{UID}:archive:1790000000000");
485 let bytes = fs.read_file(&zone(), OUTPUT_FILE).unwrap().unwrap();
486 fs.make_file(&archive, OUTPUT_FILE, FileMeta::default(), FileOpts::default()).unwrap();
487 fs.append_data(&archive, OUTPUT_FILE, &bytes).unwrap();
488 fs.delete_file(&zone(), OUTPUT_FILE).unwrap();
489 append(&fs, &zone(), &[init("new", "/w")]);
490
491 let (sessions, _) = records.sessions_of(UID);
492 let mut ids: Vec<&str> = sessions.iter().map(|s| s.session_id.as_str()).collect();
493 ids.sort();
494 assert_eq!(ids, vec!["new", "old"]);
495 let old = sessions.iter().find(|s| s.session_id == "old").unwrap();
496 assert_eq!(old.ranges[0].0, archive);
497 }
498
499 #[test]
502 fn only_the_agents_own_zones_are_read() {
503 assert!(is_agents_own_zone(&format!("agent:{UID}:current"), UID));
504 assert!(is_agents_own_zone(&format!("agent:{UID}:archive:1790000000000"), UID));
505 assert!(!is_agents_own_zone("agent:claude:current", UID));
506 assert!(!is_agents_own_zone(&format!("agent:{UID}0:current"), UID), "a longer id is another agent");
507 assert!(!is_agents_own_zone(&format!("agent:{UID}:archive:"), UID));
508 assert!(!is_agents_own_zone(&format!("agent-uid:{UID}:segments"), UID));
509 }
510
511 #[test]
513 fn a_half_written_line_waits_for_the_next_scan() {
514 let fs = store();
515 append(&fs, &zone(), &[init("s1", "/w")]);
516 fs.append_data(&zone(), OUTPUT_FILE, br#"{"type":"assistant","message":{"con"#).unwrap();
517 let records = AgentRecords::new(Some(fs.clone()));
518 let (sessions, _) = records.sessions_of(UID);
519 assert!(records.read(&sessions[0]).unwrap().0.is_empty());
520
521 fs.append_data(
522 &zone(),
523 OUTPUT_FILE,
524 b"tent\":[{\"type\":\"text\",\"text\":\"done\"}]},\"session_id\":\"s1\"}\n",
525 )
526 .unwrap();
527 let (sessions, _) = records.sessions_of(UID);
528 let texts: Vec<String> = records.read(&sessions[0]).unwrap().0.into_iter().map(|m| m.content).collect();
529 assert_eq!(texts, vec!["done"]);
530 }
531
532 #[test]
534 fn messages_are_dated_from_the_timestamp_index() {
535 let fs = store();
536 let first = init("s1", "/w");
537 append(&fs, &zone(), &[first.clone()]);
538 stamp(&fs, &zone(), 0, 1_000);
539 let second_at = first.len() as u64 + 1;
540 append(&fs, &zone(), &[assistant("s1", "answer")]);
541 stamp(&fs, &zone(), second_at, 5_000);
542 let records = AgentRecords::new(Some(fs));
543 let (sessions, _) = records.sessions_of(UID);
544 assert_eq!((sessions[0].first_ms, sessions[0].last_ms), (1_000, 5_000));
545 let (messages, _) = records.read(&sessions[0]).unwrap();
546 assert_eq!(messages[0].timestamp, 5_000);
547 }
548
549 #[test]
551 fn an_unreadable_record_line_is_counted() {
552 let fs = store();
553 append(&fs, &zone(), &[init("s1", "/w"), "{\"type\":\"assistant\",\"mess".into(), assistant("s1", "ok")]);
554 let records = AgentRecords::new(Some(fs));
555 let (sessions, _) = records.sessions_of(UID);
556 let (messages, skipped) = records.read(&sessions[0]).unwrap();
557 assert_eq!(messages.len(), 1);
558 assert_eq!(skipped, 1);
559 }
560
561 #[test]
562 fn no_store_reads_nothing() {
563 let records = AgentRecords::new(None);
564 assert_eq!(records.sessions_of(UID).0, Vec::new());
565 }
566}