Skip to main content

vtcode_memory/
event_log.rs

1//! Append-only per-session `ThreadEvent` log plus index and manifest.
2
3use std::collections::VecDeque;
4use std::fs::{File, OpenOptions};
5use std::io::{BufRead, Read, Seek, SeekFrom, Write};
6use std::path::{Path, PathBuf};
7use std::sync::Mutex;
8
9use chrono::Utc;
10use serde::{Deserialize, Serialize};
11use vtcode_exec_events::{ThreadEvent, VersionedThreadEvent};
12
13use crate::error::SessionStoreError;
14use crate::manifest::ManifestStore;
15use crate::session_dir;
16
17/// Default maximum number of events retained per session before the oldest
18/// completed turns are evicted.
19pub const DEFAULT_MAX_EVENTS: usize = 10_000;
20
21/// In-memory state protected by a mutex (cheap; appends are infrequent relative
22/// to model inference).
23struct LogState {
24    manifest: SessionManifest,
25    index: TurnIndex,
26    /// Whether we are currently inside a turn (between TurnStarted and
27    /// TurnCompleted/TurnFailed). Used to update the last index entry's
28    /// offsets as intermediate events arrive.
29    in_turn: bool,
30    /// Running byte offset of the next append. Avoids a `stat` syscall per
31    /// event (the previous implementation re-statted the file twice on every
32    /// `append`); initialized from the file length on `open`.
33    next_offset: u64,
34    /// Buffered pending writes to batch syscalls. Events are appended here
35    /// and flushed to disk at turn boundaries or before read operations.
36    write_buf: Vec<u8>,
37}
38
39impl LogState {
40    fn new(session_id: &str) -> Self {
41        Self {
42            manifest: SessionManifest::new(session_id),
43            index: TurnIndex::default(),
44            in_turn: false,
45            next_offset: 0,
46            write_buf: Vec::with_capacity(65536),
47        }
48    }
49}
50
51/// Canonical append-only event log for a single session.
52///
53/// All session history is reconstructable from this log. Live conversation
54/// state is never read back into context from here; the log is only consumed
55/// for revert, compaction, analytics, and long-term-learning queries.
56pub struct SessionEventLog {
57    events_path: PathBuf,
58    manifest_store: ManifestStore,
59    file: Mutex<File>,
60    state: Mutex<LogState>,
61    max_events: usize,
62}
63
64impl SessionEventLog {
65    /// Open the log for `session_id`, creating the session directory tree and
66    /// rebuilding the index from `events.jsonl` if it already exists.
67    pub(crate) fn open(workspace: &Path, session_id: &str, max_events: usize) -> Result<Self, SessionStoreError> {
68        let dir = session_dir(workspace, session_id);
69        std::fs::create_dir_all(dir.join(crate::DERIVED_DIR))
70            .map_err(|e| SessionStoreError::CreateDir { path: dir.clone(), source: e })?;
71        std::fs::create_dir_all(dir.join("index"))
72            .map_err(|e| SessionStoreError::CreateDir { path: dir.clone(), source: e })?;
73        let events_path = dir.join("events.jsonl");
74        let file = OpenOptions::new()
75            .create(true)
76            .read(true)
77            .append(true)
78            .open(&events_path)
79            .map_err(|e| SessionStoreError::io(events_path.clone(), e))?;
80        let manifest_store = ManifestStore::new(dir.clone());
81        let log = Self {
82            events_path: events_path.clone(),
83            manifest_store,
84            file: Mutex::new(file),
85            state: Mutex::new(LogState::new(session_id)),
86            max_events,
87        };
88        // Try the fast path: read the persisted manifest + index and skip
89        // the O(n) scan when they are present and consistent.
90        let manifest_opt = log.manifest_store.load_manifest()?;
91        let index_opt = log.manifest_store.load_turn_index()?;
92        let file_len = std::fs::metadata(&events_path)
93            .map_err(|e| SessionStoreError::io(events_path.clone(), e))?
94            .len();
95        match (manifest_opt, index_opt) {
96            (Some(manifest), Some(index)) => {
97                let mut st = log.state.lock().map_err(poison)?;
98                st.manifest = manifest;
99                st.index = index;
100                st.next_offset = file_len;
101            }
102            _ => {
103                log.scan()?;
104                let mut st = log.state.lock().map_err(poison)?;
105                st.next_offset = file_len;
106            }
107        }
108        Ok(log)
109    }
110
111    /// Append an event to the log and update the in-memory index/manifest.
112    pub fn append(&self, event: &ThreadEvent) -> Result<(), SessionStoreError> {
113        let line = serde_json::to_string(&VersionedThreadEvent::new(event.clone()))?;
114        let written = line.len() + 1;
115        let mut st = self.state.lock().map_err(poison)?;
116        let start = st.next_offset;
117        writeln!(&mut st.write_buf, "{line}").map_err(|e| SessionStoreError::io(&self.events_path, e))?;
118        let end = start + written as u64;
119        st.next_offset = end;
120
121        st.manifest.event_count += 1;
122        st.manifest.updated_at = now_rfc3339();
123        match event {
124            ThreadEvent::TurnStarted(_) => {
125                st.in_turn = true;
126                let n = st.manifest.turn_count + 1;
127                st.index.entries.push_back(TurnIndexEntry {
128                    turn_number: n,
129                    start_offset: start,
130                    end_offset: end,
131                    event_count: 1,
132                    ts: now_rfc3339(),
133                });
134            }
135            ThreadEvent::TurnCompleted(_) => {
136                if st.in_turn {
137                    if let Some(entry) = st.index.entries.back_mut() {
138                        entry.end_offset = end;
139                        entry.event_count += 1;
140                    }
141                    st.in_turn = false;
142                    st.manifest.turn_count = st.index.entries.len() as u64;
143                }
144                st.manifest.status = "completed".to_string();
145                self.persist_meta_locked(&mut st)?;
146            }
147            ThreadEvent::TurnFailed(_) => {
148                if st.in_turn {
149                    if let Some(entry) = st.index.entries.back_mut() {
150                        entry.end_offset = end;
151                        entry.event_count += 1;
152                    }
153                    st.in_turn = false;
154                    st.manifest.turn_count = st.index.entries.len() as u64;
155                }
156                st.manifest.status = "failed".to_string();
157                self.persist_meta_locked(&mut st)?;
158            }
159            _ => {
160                if st.in_turn
161                    && let Some(entry) = st.index.entries.back_mut()
162                {
163                    entry.end_offset = end;
164                    entry.event_count += 1;
165                }
166            }
167        }
168        drop(st);
169        let _ = self.enforce_event_cap();
170        Ok(())
171    }
172
173    /// Enforce the per-session event cap by evicting the oldest completed
174    /// turns when the log exceeds [`Self::max_events`]. Returns `Ok(())` even
175    /// when no truncation is needed or the cap is disabled (`max_events == 0`).
176    fn enforce_event_cap(&self) -> Result<(), SessionStoreError> {
177        if self.max_events == 0 {
178            return Ok(());
179        }
180        let mut st = self.state.lock().map_err(poison)?;
181        self.flush_write_buf_locked(&mut st)?;
182        if st.manifest.event_count <= self.max_events as u64 {
183            return Ok(());
184        }
185
186        let _excess = st.manifest.event_count as i64 - self.max_events as i64;
187        let mut evicted_event_count = 0u64;
188        let mut truncate_offset = 0u64;
189
190        while st.manifest.event_count > self.max_events as u64
191            && let Some(oldest) = st.index.entries.front()
192        {
193            truncate_offset = oldest.end_offset;
194            evicted_event_count += oldest.event_count;
195            st.index.entries.pop_front();
196        }
197
198        if truncate_offset == 0 {
199            return Ok(());
200        }
201
202        {
203            let mut file = self.file.lock().map_err(poison)?;
204            file.seek(SeekFrom::Start(truncate_offset))
205                .map_err(|e| SessionStoreError::io(&self.events_path, e))?;
206            let mut remaining = Vec::new();
207            file.read_to_end(&mut remaining)
208                .map_err(|e| SessionStoreError::io(&self.events_path, e))?;
209            file.set_len(0).map_err(|e| SessionStoreError::io(&self.events_path, e))?;
210            file.seek(SeekFrom::Start(0))
211                .map_err(|e| SessionStoreError::io(&self.events_path, e))?;
212            file.write_all(&remaining)
213                .map_err(|e| SessionStoreError::io(&self.events_path, e))?;
214            file.flush().map_err(|e| SessionStoreError::io(&self.events_path, e))?;
215        }
216
217        for entry in &mut st.index.entries {
218            entry.start_offset -= truncate_offset;
219            entry.end_offset -= truncate_offset;
220        }
221        st.next_offset -= truncate_offset;
222        st.manifest.event_count = st.manifest.event_count.saturating_sub(evicted_event_count);
223        Ok(())
224    }
225
226    /// Reconstruct every event belonging to `turn`.
227    pub(crate) fn reconstruct_turn(&self, turn: u64) -> Result<Vec<ThreadEvent>, SessionStoreError> {
228        let entry = {
229            let st = self.state.lock().map_err(poison)?;
230            st.index
231                .entries
232                .iter()
233                .find(|e| e.turn_number == turn)
234                .cloned()
235                .ok_or(SessionStoreError::TurnNotFound { session: st.manifest.session_id.clone(), turn })?
236        };
237        {
238            let mut st = self.state.lock().map_err(poison)?;
239            self.flush_write_buf_locked(&mut st)?;
240        }
241        let buf = {
242            let mut file = self.file.lock().map_err(poison)?;
243            file.seek(SeekFrom::Start(entry.start_offset))
244                .map_err(|e| SessionStoreError::io(&self.events_path, e))?;
245            let len = (entry.end_offset - entry.start_offset) as usize;
246            let mut buf = vec![0u8; len];
247            file.read_exact(&mut buf)
248                .map_err(|e| SessionStoreError::io(&self.events_path, e))?;
249            buf
250        };
251        let text = String::from_utf8_lossy(&buf);
252        let mut events = Vec::new();
253        for line in text.lines() {
254            let line = line.trim();
255            if line.is_empty() {
256                continue;
257            }
258            let v: VersionedThreadEvent = serde_json::from_str(line).map_err(SessionStoreError::Json)?;
259            events.push(v.into_event());
260        }
261        Ok(events)
262    }
263
264    /// Number of turns recorded.
265    #[must_use]
266    pub(crate) fn turn_count(&self) -> u64 {
267        self.state.lock().map_err(poison).map_or(0, |s| s.manifest.turn_count)
268    }
269
270    /// Number of events recorded.
271    #[must_use]
272    pub fn event_count(&self) -> u64 {
273        self.state.lock().map_err(poison).map_or(0, |s| s.manifest.event_count)
274    }
275
276    /// Snapshot of the session manifest.
277    #[must_use]
278    pub fn manifest(&self) -> SessionManifest {
279        self.state
280            .lock()
281            .map_err(poison)
282            .map(|s| s.manifest.clone())
283            .unwrap_or_else(|_| SessionManifest::new(""))
284    }
285
286    /// Snapshot of the turn index.
287    #[must_use]
288    pub fn turn_index(&self) -> TurnIndex {
289        self.state.lock().map_err(poison).map(|s| s.index.clone()).unwrap_or_default()
290    }
291
292    /// Mark the session completed and flush metadata.
293    pub(crate) fn complete(&self) -> Result<(), SessionStoreError> {
294        let mut st = self.state.lock().map_err(poison)?;
295        st.manifest.status = "completed".to_string();
296        st.manifest.updated_at = now_rfc3339();
297        self.persist_meta_locked(&mut st)
298    }
299
300    /// Rebuild index + manifest by scanning `events.jsonl` (authoritative).
301    ///
302    /// Reads the file line-by-line via `BufReader` to avoid loading the entire
303    /// log into memory. Long-lived sessions can otherwise produce multi-megabyte
304    /// logs that spike memory on every reopen.
305    fn scan(&self) -> Result<(), SessionStoreError> {
306        let mut st = self.state.lock().map_err(poison)?;
307        if !self.events_path.exists() {
308            return Ok(());
309        }
310        let file = File::open(&self.events_path).map_err(|e| SessionStoreError::io(&self.events_path, e))?;
311        let mut reader = std::io::BufReader::new(file);
312        let mut buf = Vec::new();
313        let mut pos = 0u64;
314        let mut first_ts: Option<String> = None;
315        let mut in_turn = false;
316        loop {
317            buf.clear();
318            let n = reader
319                .read_until(b'\n', &mut buf)
320                .map_err(|e| SessionStoreError::io(&self.events_path, e))?;
321            if n == 0 {
322                break;
323            }
324            let line_end = pos + n as u64;
325            let trimmed = std::str::from_utf8(&buf).unwrap_or("").trim();
326            if !trimmed.is_empty()
327                && let Ok(v) = serde_json::from_str::<VersionedThreadEvent>(trimmed)
328            {
329                let event = v.into_event();
330                st.manifest.event_count += 1;
331                match &event {
332                    ThreadEvent::ThreadStarted(_) => {
333                        if first_ts.is_none() {
334                            first_ts = Some(now_rfc3339());
335                        }
336                    }
337                    ThreadEvent::TurnStarted(_) => {
338                        in_turn = true;
339                        let n = st.manifest.turn_count + 1;
340                        st.index.entries.push_back(TurnIndexEntry {
341                            turn_number: n,
342                            start_offset: pos,
343                            end_offset: line_end,
344                            event_count: 1,
345                            ts: now_rfc3339(),
346                        });
347                    }
348                    ThreadEvent::TurnCompleted(_) | ThreadEvent::TurnFailed(_) => {
349                        if in_turn {
350                            if let Some(entry) = st.index.entries.back_mut() {
351                                entry.end_offset = line_end;
352                                entry.event_count += 1;
353                            }
354                            in_turn = false;
355                            st.manifest.turn_count = st.index.entries.len() as u64;
356                        }
357                        match &event {
358                            ThreadEvent::TurnCompleted(_) => {
359                                st.manifest.status = "completed".to_string();
360                            }
361                            ThreadEvent::TurnFailed(_) => {
362                                st.manifest.status = "failed".to_string();
363                            }
364                            _ => {}
365                        }
366                    }
367                    _ => {
368                        if in_turn && let Some(entry) = st.index.entries.back_mut() {
369                            entry.end_offset = line_end;
370                            entry.event_count += 1;
371                        }
372                    }
373                }
374            }
375            pos = line_end;
376        }
377        if let Some(ts) = first_ts
378            && st.manifest.created_at.is_empty()
379        {
380            st.manifest.created_at = ts;
381        }
382        Ok(())
383    }
384
385    fn persist_meta_locked(&self, st: &mut LogState) -> Result<(), SessionStoreError> {
386        self.flush_write_buf_locked(st)?;
387        self.manifest_store.write_manifest(&st.manifest)?;
388        self.manifest_store.write_turn_index(&st.index)?;
389        Ok(())
390    }
391
392    /// Flush the in-memory write buffer to the underlying file.
393    fn flush_write_buf_locked(&self, st: &mut LogState) -> Result<(), SessionStoreError> {
394        if st.write_buf.is_empty() {
395            return Ok(());
396        }
397        let mut file = self.file.lock().map_err(poison)?;
398        file.write_all(&st.write_buf)
399            .map_err(|e| SessionStoreError::io(&self.events_path, e))?;
400        st.write_buf.clear();
401        Ok(())
402    }
403}
404
405impl Drop for SessionEventLog {
406    fn drop(&mut self) {
407        if let Ok(mut st) = self.state.lock() {
408            if !st.write_buf.is_empty() {
409                if let Ok(mut file) = self.file.lock() {
410                    let _ = file.write_all(&st.write_buf);
411                    let _ = file.flush();
412                    st.write_buf.clear();
413                }
414            }
415        }
416    }
417}
418
419/// Locate the next newline at or after `from`, returning a past-the-end index.
420fn poison<T>(_e: std::sync::PoisonError<T>) -> SessionStoreError {
421    SessionStoreError::Io {
422        path: PathBuf::new(),
423        source: std::io::Error::other("session store lock poisoned"),
424    }
425}
426
427fn now_rfc3339() -> String {
428    Utc::now().to_rfc3339()
429}
430
431/// Session-level metadata persisted to `manifest.json`.
432#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
433pub struct SessionManifest {
434    /// Stable session identifier (directory name).
435    pub session_id: String,
436    /// Layout schema version (`SESSION_STORE_SCHEMA_VERSION`).
437    schema_version: u32,
438    /// RFC3339 creation timestamp.
439    pub created_at: String,
440    /// RFC3339 last-update timestamp.
441    pub updated_at: String,
442    /// Number of completed turns.
443    pub turn_count: u64,
444    /// Total number of events recorded.
445    pub event_count: u64,
446    /// Lifecycle status (`active` | `completed`).
447    pub status: String,
448}
449
450impl SessionManifest {
451    /// Create a fresh manifest for a session.
452    #[must_use]
453    pub(crate) fn new(session_id: &str) -> Self {
454        let ts = now_rfc3339();
455        Self {
456            session_id: session_id.to_string(),
457            schema_version: crate::SESSION_STORE_SCHEMA_VERSION,
458            created_at: ts.clone(),
459            updated_at: ts,
460            turn_count: 0,
461            event_count: 0,
462            status: "active".to_string(),
463        }
464    }
465}
466
467/// Byte-offset index of a single turn within `events.jsonl`.
468#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
469pub struct TurnIndexEntry {
470    /// Turn ordinal (1-based).
471    turn_number: u64,
472    /// Byte offset of the turn's first event.
473    start_offset: u64,
474    /// Byte offset just past the turn's last event.
475    end_offset: u64,
476    /// Number of events in the turn.
477    event_count: u64,
478    /// RFC3339 timestamp of turn start.
479    ts: String,
480}
481
482/// Ordered index of all turns in a session.
483#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
484pub struct TurnIndex {
485    /// Turn entries in ordinal order.
486    entries: VecDeque<TurnIndexEntry>,
487}
488
489impl TurnIndex {
490    /// Number of indexed turns.
491    #[must_use]
492    pub fn len(&self) -> usize {
493        self.entries.len()
494    }
495
496    /// Whether the index is empty.
497    #[must_use]
498    pub fn is_empty(&self) -> bool {
499        self.entries.is_empty()
500    }
501}