Skip to main content

vtcode_memory/
event_log.rs

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