vtcode_memory/
snapshot.rs1use std::io::{Read, Seek, SeekFrom};
4use std::path::Path;
5
6use vtcode_exec_events::VersionedThreadEvent;
7
8use crate::SessionStoreError;
9
10type Result<T> = std::result::Result<T, SessionStoreError>;
11
12#[derive(Debug)]
14pub struct EventTail {
15 pub events: Vec<VersionedThreadEvent>,
17 pub truncated: bool,
19}
20
21pub fn read_event_tail(workspace: &Path, session_id: &str, max_bytes: usize) -> Result<Option<EventTail>> {
27 if max_bytes == 0
28 || max_bytes > 16 * 1024 * 1024
29 || session_id.is_empty()
30 || session_id != crate::sanitize_id(session_id)
31 {
32 return Err(SessionStoreError::io(
33 workspace,
34 std::io::Error::new(std::io::ErrorKind::InvalidInput, "invalid session identifier or event tail limit"),
35 ));
36 }
37 let relative = Path::new(crate::SESSIONS_DIR).join(session_id).join("events.jsonl");
38 let mut file = match vtcode_commons::fs::bound_file::open_file_beneath(workspace, &relative) {
39 Ok(file) => file,
40 Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None),
41 Err(error) => return Err(SessionStoreError::io(workspace.join(&relative), error)),
42 };
43 let length = file
44 .metadata()
45 .map_err(|error| SessionStoreError::io(workspace.join(&relative), error))?
46 .len();
47 let start = length.saturating_sub(max_bytes as u64);
48 let partial_prefix = if start > 0 {
49 file.seek(SeekFrom::Start(start - 1))
50 .map_err(|error| SessionStoreError::io(workspace.join(&relative), error))?;
51 let mut preceding = [0];
52 file.read_exact(&mut preceding)
53 .map_err(|error| SessionStoreError::io(workspace.join(&relative), error))?;
54 preceding[0] != b'\n'
55 } else {
56 false
57 };
58 file.seek(SeekFrom::Start(start))
59 .map_err(|error| SessionStoreError::io(workspace.join(&relative), error))?;
60 let mut bytes = Vec::new();
61 file.take(length - start)
62 .read_to_end(&mut bytes)
63 .map_err(|error| SessionStoreError::io(workspace.join(&relative), error))?;
64 decode_tail(&bytes, start > 0, partial_prefix).map(Some)
65}
66
67fn decode_tail(bytes: &[u8], omitted_prefix: bool, partial_prefix: bool) -> Result<EventTail> {
68 let mut records = bytes.split_inclusive(|byte| *byte == b'\n');
69 if partial_prefix {
72 records.next();
73 }
74 let mut tail = EventTail { events: Vec::new(), truncated: omitted_prefix };
75 for record in records {
76 if !record.ends_with(b"\n") {
77 tail.truncated = true;
78 break;
79 }
80 if record.iter().all(u8::is_ascii_whitespace) {
81 continue;
82 }
83 tail.events.push(serde_json::from_slice(record)?);
84 }
85 Ok(tail)
86}
87
88#[cfg(test)]
89mod tests {
90 use super::*;
91 use vtcode_exec_events::{ThreadEvent, ThreadStartedEvent};
92
93 fn record(id: &str) -> Vec<u8> {
94 let mut bytes =
95 serde_json::to_vec(&VersionedThreadEvent::new(ThreadEvent::ThreadStarted(ThreadStartedEvent {
96 thread_id: id.into(),
97 })))
98 .unwrap();
99 bytes.push(b'\n');
100 bytes
101 }
102
103 #[test]
104 fn tail_preserves_order_and_excludes_partial_records_at_both_edges() {
105 let mut bytes = record("first");
106 bytes.extend(record("second"));
107 bytes.extend(b"{unfinished");
108 let full = decode_tail(&bytes, false, false).unwrap();
109 assert_eq!(full.events.len(), 2);
110 assert!(full.truncated);
111 let bounded = decode_tail(&bytes[5..], true, true).unwrap();
112 assert_eq!(bounded.events, vec![serde_json::from_slice::<VersionedThreadEvent>(&record("second")).unwrap()]);
113 assert!(bounded.truncated);
114 assert!(decode_tail(b"bad\n", false, false).is_err());
115 }
116
117 #[cfg(unix)]
118 #[test]
119 fn read_is_bounded_and_missing_history_does_not_create_a_store() {
120 let dir = tempfile::tempdir().unwrap();
121 let root = vtcode_commons::canonicalize(dir.path()).unwrap();
122 assert!(read_event_tail(&root, "missing", 1024).unwrap().is_none());
123 assert!(!root.join(".vtcode").exists());
124 let log = crate::open(&root, "saved", crate::DEFAULT_MAX_EVENTS).unwrap();
125 log.append(&ThreadEvent::ThreadStarted(ThreadStartedEvent { thread_id: "first".into() }))
126 .unwrap();
127 log.append(&ThreadEvent::ThreadStarted(ThreadStartedEvent { thread_id: "second".into() }))
128 .unwrap();
129 log.flush().unwrap();
130 let path = crate::session_directory(&root, "saved").join("events.jsonl");
131 let before = std::fs::read(&path).unwrap();
132 let tail = read_event_tail(&root, "saved", record("second").len()).unwrap().unwrap();
133 assert_eq!(tail.events.len(), 1);
134 assert!(tail.truncated);
135 assert_eq!(std::fs::read(path).unwrap(), before);
136 assert!(read_event_tail(&root, "../escape", 1024).is_err());
137 assert!(read_event_tail(&root, "saved", 0).is_err());
138 }
139
140 #[cfg(unix)]
141 #[test]
142 fn history_rejects_symlinked_session_directories() {
143 let dir = tempfile::tempdir().unwrap();
144 let root = vtcode_commons::canonicalize(dir.path()).unwrap();
145 let outside = tempfile::tempdir().unwrap();
146 std::fs::create_dir_all(root.join(".vtcode/sessions")).unwrap();
147 std::os::unix::fs::symlink(outside.path(), root.join(".vtcode/sessions/linked")).unwrap();
148 assert!(read_event_tail(&root, "linked", 1024).is_err());
149 }
150}