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