Skip to main content

vtcode_memory/
snapshot.rs

1//! Bounded read-only projections of canonical history for external clients.
2
3use 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/// Complete records from the end of a canonical event log, in persisted order.
13#[derive(Debug)]
14pub struct EventTail {
15    /// Schema-versioned canonical records; never synthesized lifecycle events.
16    pub events: Vec<VersionedThreadEvent>,
17    /// The byte limit or an incomplete final record excluded history.
18    pub truncated: bool,
19}
20
21/// Read at most `max_bytes` from a session without creating files, taking a
22/// writer handle, changing metadata, or loading history into agent context.
23/// Missing logs return `None` for compatibility with legacy archives.
24/// The workspace must be an absolute, resolved root; symlink paths are rejected.
25/// Platforms without handle-bound reads return an `Unsupported` I/O error.
26pub 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    // A bounded tail can begin inside a JSON record. Discard that entire record
70    // rather than treating the suffix as authoritative evidence.
71    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}