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