Skip to main content

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