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;
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_commons::VtCodePaths;
12use vtcode_exec_events::{EVENT_SCHEMA_VERSION, ThreadEvent, VersionedThreadEvent};
13
14use crate::error::SessionStoreError;
15use crate::manifest::ManifestStore;
16use crate::session_dir;
17
18/// Default maximum number of events retained per session before the oldest
19/// completed turns are evicted.
20pub const DEFAULT_MAX_EVENTS: usize = 10_000;
21
22/// Maximum serialized event bytes retained before an append forces a write.
23/// Turn boundaries and reads still flush immediately.
24const MAX_WRITE_BUFFER_BYTES: usize = 64 * 1024;
25
26/// Minimal envelope used while rebuilding the turn index.
27///
28/// The index only needs the event discriminator. Deserializing a complete
29/// [`VersionedThreadEvent`] here would allocate every nested tool argument,
30/// output, and thread item even though none of that payload is retained.
31#[derive(Debug, Deserialize)]
32struct VersionedEventKind<'a> {
33    #[serde(rename = "schema_version", borrow)]
34    _schema_version: &'a str,
35    #[serde(borrow)]
36    event: EventKind<'a>,
37}
38
39#[derive(Debug, Deserialize)]
40struct EventKind<'a> {
41    #[serde(rename = "type", borrow)]
42    kind: &'a str,
43}
44
45/// Zero-clone serialization envelope for `ThreadEvent`.
46///
47/// Produces JSON byte-identical to `VersionedThreadEvent` but borrows the
48/// event by reference instead of cloning it. `append` is called for every
49/// runtime event, and `ThreadEvent` can carry large tool outputs / thread
50/// items — cloning just to feed `serde_json::to_string` was pure waste.
51#[derive(Serialize)]
52struct BorrowedVersionedEvent<'a> {
53    schema_version: &'a str,
54    event: &'a ThreadEvent,
55}
56
57/// Turn-lifecycle discriminator extracted from either a `ThreadEvent` (at
58/// append time) or a raw `&str` kind (during scan).  This is the single
59/// representation that both code paths feed into
60/// [`LogState::apply_lifecycle_event`], eliminating a duplicated state machine.
61#[derive(Debug, Clone, Copy, PartialEq, Eq)]
62enum LifecycleKind {
63    ThreadStarted,
64    ThreadCompleted,
65    TurnStarted,
66    TurnCompleted,
67    TurnFailed,
68    Other,
69}
70
71impl LifecycleKind {
72    /// Discriminate from a runtime `ThreadEvent` at append time.
73    #[inline]
74    fn from_event(event: &ThreadEvent) -> Self {
75        match event {
76            ThreadEvent::ThreadStarted(_) => Self::ThreadStarted,
77            ThreadEvent::ThreadCompleted(_) => Self::ThreadCompleted,
78            ThreadEvent::TurnStarted(_) => Self::TurnStarted,
79            ThreadEvent::TurnCompleted(_) => Self::TurnCompleted,
80            ThreadEvent::TurnFailed(_) => Self::TurnFailed,
81            _ => Self::Other,
82        }
83    }
84
85    /// Discriminate from a raw event-type string at scan time.
86    #[inline]
87    fn from_kind(kind: &str) -> Self {
88        match kind {
89            "thread.started" => Self::ThreadStarted,
90            "thread.completed" => Self::ThreadCompleted,
91            "turn.started" => Self::TurnStarted,
92            "turn.completed" => Self::TurnCompleted,
93            "turn.failed" => Self::TurnFailed,
94            _ => Self::Other,
95        }
96    }
97}
98
99/// In-memory state protected by a mutex (cheap; appends are infrequent relative
100/// to model inference).
101struct LogState {
102    manifest: SessionManifest,
103    index: TurnIndex,
104    /// Whether we are currently inside a turn (between TurnStarted and
105    /// TurnCompleted/TurnFailed). Used to update the last index entry's
106    /// offsets as intermediate events arrive.
107    in_turn: bool,
108    /// Running byte offset of the next append. Avoids a `stat` syscall per
109    /// event (the previous implementation re-statted the file twice on every
110    /// `append`); initialized from the file length on `open`.
111    next_offset: u64,
112    /// Buffered pending writes to batch syscalls. Events are appended here
113    /// and flushed to disk at turn boundaries or before read operations.
114    write_buf: Vec<u8>,
115}
116
117#[derive(Debug, Clone, Copy)]
118struct CapEvictionPlan {
119    truncate_offset: u64,
120    evicted_event_count: u64,
121    evicted_turn_count: usize,
122}
123
124impl LogState {
125    fn new(session_id: &str) -> Self {
126        Self {
127            manifest: SessionManifest::new(session_id),
128            index: TurnIndex::default(),
129            in_turn: false,
130            next_offset: 0,
131            write_buf: Vec::with_capacity(65536),
132        }
133    }
134
135    /// Serialize `event` directly into the reusable write buffer with rollback
136    /// on failure.
137    ///
138    /// This encapsulates the invariant that `write_buf` never contains a
139    /// partial JSON document: if `serde_json::to_writer` fails mid-write the
140    /// buffer is truncated back to its pre-serialization boundary.  Returns
141    /// the `(start, end)` byte offsets of the serialized event so the caller
142    /// can feed them to [`Self::apply_lifecycle_event`].
143    fn serialize_event(&mut self, event: &ThreadEvent) -> Result<(u64, u64), SessionStoreError> {
144        let start = self.next_offset;
145        let buf_len_before = self.write_buf.len();
146        if let Err(err) = serde_json::to_writer(
147            &mut self.write_buf,
148            &BorrowedVersionedEvent { schema_version: EVENT_SCHEMA_VERSION, event },
149        ) {
150            self.write_buf.truncate(buf_len_before);
151            return Err(err.into());
152        }
153        self.write_buf.push(b'\n');
154        let written = self.write_buf.len() - buf_len_before;
155        let end = start + written as u64;
156        self.next_offset = end;
157        Ok((start, end))
158    }
159
160    /// Update the in-memory turn index and manifest counters for a single
161    /// event.
162    ///
163    /// This is the single implementation of the turn-lifecycle state machine;
164    /// both the append path (via [`LifecycleKind::from_event`]) and the scan
165    /// path (via [`LifecycleKind::from_kind`]) route through here, eliminating
166    /// a previously duplicated match block.
167    ///
168    /// Returns `true` when the event closes a turn boundary
169    /// (`TurnCompleted` / `TurnFailed`) so the caller can persist metadata
170    /// at the appropriate time (append persists immediately; scan persists
171    /// once after the full scan).
172    fn apply_lifecycle_event(&mut self, kind: LifecycleKind, start: u64, end: u64) -> bool {
173        match kind {
174            LifecycleKind::ThreadStarted => {
175                self.manifest.status = "active".to_string();
176                false
177            }
178            LifecycleKind::ThreadCompleted => {
179                self.manifest.status = "completed".to_string();
180                true
181            }
182            LifecycleKind::TurnStarted => {
183                self.manifest.status = "active".to_string();
184                self.in_turn = true;
185                let n = self.manifest.turn_count + 1;
186                self.index.entries.push_back(TurnIndexEntry {
187                    turn_number: n,
188                    start_offset: start,
189                    end_offset: end,
190                    event_count: 1,
191                    ts: now_rfc3339(),
192                });
193                false
194            }
195            LifecycleKind::TurnCompleted | LifecycleKind::TurnFailed => {
196                if self.in_turn {
197                    if let Some(entry) = self.index.entries.back_mut() {
198                        entry.end_offset = end;
199                        entry.event_count += 1;
200                    }
201                    self.in_turn = false;
202                    self.manifest.turn_count = self.index.entries.len() as u64;
203                }
204                true
205            }
206            LifecycleKind::Other => {
207                if self.in_turn
208                    && let Some(entry) = self.index.entries.back_mut()
209                {
210                    entry.end_offset = end;
211                    entry.event_count += 1;
212                }
213                false
214            }
215        }
216    }
217
218    /// Plan a cap-enforcement eviction: pop the oldest completed turns from
219    /// the index until `event_count` is within `max_events`.
220    ///
221    /// Returns the byte offset at which the file should be rewritten and the
222    /// counts needed to apply the eviction after successful I/O. Returns
223    /// `None` when no eviction is needed.
224    fn plan_cap_eviction(&self, max_events: usize) -> Option<CapEvictionPlan> {
225        if max_events == 0 || self.manifest.event_count <= max_events as u64 {
226            return None;
227        }
228        let mut evicted_event_count = 0u64;
229        let mut truncate_offset = 0u64;
230        let mut evicted_turn_count = 0;
231        for oldest in &self.index.entries {
232            if self.manifest.event_count.saturating_sub(evicted_event_count) <= max_events as u64 {
233                break;
234            }
235            truncate_offset = oldest.end_offset;
236            evicted_event_count += oldest.event_count;
237            evicted_turn_count += 1;
238        }
239        if truncate_offset == 0 || evicted_turn_count == 0 {
240            None
241        } else {
242            Some(CapEvictionPlan {
243                truncate_offset,
244                evicted_event_count,
245                evicted_turn_count,
246            })
247        }
248    }
249
250    fn apply_cap_eviction(&mut self, plan: CapEvictionPlan, next_offset: u64) {
251        for _ in 0..plan.evicted_turn_count {
252            let _ = self.index.entries.pop_front();
253        }
254        for entry in &mut self.index.entries {
255            entry.start_offset = entry.start_offset.saturating_sub(plan.truncate_offset);
256            entry.end_offset = entry.end_offset.saturating_sub(plan.truncate_offset);
257        }
258        self.next_offset = next_offset;
259        self.manifest.event_count = self.manifest.event_count.saturating_sub(plan.evicted_event_count);
260    }
261}
262
263/// Canonical append-only event log for a single session.
264///
265/// All session history is reconstructable from this log. Live conversation
266/// state is never read back into context from here; the log is only consumed
267/// for revert, compaction, analytics, and long-term-learning queries.
268pub struct SessionEventLog {
269    events_path: PathBuf,
270    manifest_store: ManifestStore,
271    file: Mutex<Option<File>>,
272    state: Mutex<LogState>,
273    max_events: usize,
274}
275
276impl SessionEventLog {
277    /// Open the log for `session_id`, creating the session directory tree and
278    /// rebuilding the index from `events.jsonl` if it already exists.
279    pub(crate) fn open(workspace: &Path, session_id: &str, max_events: usize) -> Result<Self, SessionStoreError> {
280        let dir = session_dir(workspace, session_id);
281        crate::ensure_private_directory(&crate::sessions_root(workspace))?;
282        crate::ensure_private_directory(&dir)?;
283        crate::ensure_private_directory(&dir.join(crate::DERIVED_DIR))?;
284        crate::ensure_private_directory(&dir.join("index"))?;
285        let events_path = dir.join("events.jsonl");
286        let file = VtCodePaths::open_private_append_file(&events_path)
287            .map_err(|error| SessionStoreError::io(events_path.clone(), std::io::Error::other(error)))?;
288        let manifest_store = ManifestStore::new(dir.clone());
289        let log = Self {
290            events_path: events_path.clone(),
291            manifest_store,
292            file: Mutex::new(Some(file)),
293            state: Mutex::new(LogState::new(session_id)),
294            max_events,
295        };
296        // Try the fast path: read the persisted manifest + index and skip
297        // the O(n) scan when they are present and consistent.
298        let manifest_opt = log.manifest_store.load_manifest()?;
299        let index_opt = log.manifest_store.load_turn_index()?;
300        let file_len = log.event_file_metadata_len()?;
301        match (manifest_opt, index_opt) {
302            (Some(manifest), Some(index)) if index.is_valid_for_file(file_len) => {
303                let mut st = log.state.lock().map_err(poison)?;
304                st.manifest = manifest;
305                st.index = index;
306                st.next_offset = file_len;
307            }
308            _ => {
309                log.scan()?;
310                let mut st = log.state.lock().map_err(poison)?;
311                st.next_offset = file_len;
312                log.persist_meta_locked(&mut st)?;
313            }
314        }
315        Ok(log)
316    }
317
318    /// Append an event to the log and update the in-memory index/manifest.
319    pub fn append(&self, event: &ThreadEvent) -> Result<(), SessionStoreError> {
320        let mut st = self.state.lock().map_err(poison)?;
321
322        // Serialize into the write buffer with rollback on failure — the
323        // invariant that `write_buf` never contains partial JSON is
324        // encapsulated in `serialize_event`.
325        let (start, end) = st.serialize_event(event)?;
326
327        st.manifest.event_count += 1;
328        st.manifest.updated_at = now_rfc3339();
329
330        // Route through the single turn-lifecycle state machine.  When the
331        // event closes a turn, persist metadata immediately so a reopen
332        // after a mid-turn crash sees a consistent index.
333        let is_turn_boundary = st.apply_lifecycle_event(LifecycleKind::from_event(event), start, end);
334        if is_turn_boundary {
335            self.persist_meta_locked(&mut st)?;
336        }
337
338        if st.write_buf.len() >= MAX_WRITE_BUFFER_BYTES {
339            // Persist metadata with the bounded byte flush so a reopen after
340            // a mid-turn crash does not trust an index that predates these
341            // already-written events.
342            self.persist_meta_locked(&mut st)?;
343        }
344        drop(st);
345        self.enforce_event_cap()
346    }
347
348    /// Enforce the per-session event cap by evicting the oldest completed
349    /// turns when the log exceeds [`Self::max_events`]. Returns `Ok(())` even
350    /// when no truncation is needed or the cap is disabled (`max_events == 0`).
351    fn enforce_event_cap(&self) -> Result<(), SessionStoreError> {
352        let mut st = self.state.lock().map_err(poison)?;
353
354        // `plan_cap_eviction` encapsulates the index arithmetic and returns
355        // `None` when the cap is disabled or not yet exceeded.
356        let Some(plan) = st.plan_cap_eviction(self.max_events) else {
357            return Ok(());
358        };
359
360        // Keep ordinary appends in memory until a turn boundary or an
361        // explicit read. Cap enforcement is the one append-time path that
362        // needs the complete on-disk file before rewriting it.
363        self.flush_write_buf_locked(&mut st)?;
364
365        let remaining = {
366            let mut file_slot = self.file.lock().map_err(poison)?;
367            let file = file_slot.as_mut().ok_or_else(|| self.event_file_unavailable())?;
368            let file_len = file
369                .metadata()
370                .map_err(|error| SessionStoreError::io(&self.events_path, error))?
371                .len();
372            if plan.truncate_offset > file_len {
373                return Err(SessionStoreError::io(
374                    &self.events_path,
375                    std::io::Error::new(std::io::ErrorKind::InvalidData, "cap offset exceeds event log length"),
376                ));
377            }
378            file.seek(SeekFrom::Start(plan.truncate_offset))
379                .map_err(|e| SessionStoreError::io(&self.events_path, e))?;
380            let mut remaining = Vec::new();
381            file.read_to_end(&mut remaining)
382                .map_err(|e| SessionStoreError::io(&self.events_path, e))?;
383            remaining
384        };
385
386        let next_offset = self.replace_event_file_contents(&remaining)?;
387        st.apply_cap_eviction(plan, next_offset);
388        // The rewrite changed byte offsets and retained counts; persist the
389        // derived metadata before exposing the append as successful.
390        self.persist_meta_locked(&mut st)?;
391        Ok(())
392    }
393
394    /// Reconstruct every event belonging to `turn`.
395    pub(crate) fn reconstruct_turn(&self, turn: u64) -> Result<Vec<ThreadEvent>, SessionStoreError> {
396        let entry = {
397            let st = self.state.lock().map_err(poison)?;
398            st.index
399                .entries
400                .iter()
401                .find(|e| e.turn_number == turn)
402                .cloned()
403                .ok_or(SessionStoreError::TurnNotFound { session: st.manifest.session_id.clone(), turn })?
404        };
405        {
406            let mut st = self.state.lock().map_err(poison)?;
407            self.flush_write_buf_locked(&mut st)?;
408        }
409        let buf = {
410            let mut file_slot = self.file.lock().map_err(poison)?;
411            let file = file_slot.as_mut().ok_or_else(|| self.event_file_unavailable())?;
412            file.seek(SeekFrom::Start(entry.start_offset))
413                .map_err(|e| SessionStoreError::io(&self.events_path, e))?;
414            let len = usize::try_from(entry.end_offset.checked_sub(entry.start_offset).ok_or_else(|| {
415                SessionStoreError::io(
416                    &self.events_path,
417                    std::io::Error::new(std::io::ErrorKind::InvalidData, "turn index offsets are out of order"),
418                )
419            })?)
420            .map_err(|error| {
421                SessionStoreError::io(&self.events_path, std::io::Error::new(std::io::ErrorKind::InvalidData, error))
422            })?;
423            let mut buf = vec![0u8; len];
424            file.read_exact(&mut buf)
425                .map_err(|e| SessionStoreError::io(&self.events_path, e))?;
426            buf
427        };
428        let text = String::from_utf8_lossy(&buf);
429        let mut events = Vec::new();
430        for line in text.lines() {
431            let line = line.trim();
432            if line.is_empty() {
433                continue;
434            }
435            // The index scan only validates the event envelope (plus the
436            // lifecycle shape) so it can rebuild cheaply. A line accepted by
437            // the scan can therefore still fail full decoding here; skip it
438            // instead of failing the whole reconstruction (revert, compaction,
439            // and analytics must not break on a single malformed record).
440            let v: VersionedThreadEvent = match serde_json::from_str(line) {
441                Ok(v) => v,
442                Err(_) => continue,
443            };
444            events.push(v.into_event());
445        }
446        Ok(events)
447    }
448
449    /// Number of turns recorded.
450    #[must_use]
451    pub(crate) fn turn_count(&self) -> u64 {
452        self.state.lock().map_err(poison).map_or(0, |s| s.manifest.turn_count)
453    }
454
455    /// Number of events recorded.
456    #[must_use]
457    pub fn event_count(&self) -> u64 {
458        self.state.lock().map_err(poison).map_or(0, |s| s.manifest.event_count)
459    }
460
461    /// Flush pending event bytes and metadata to the session store.
462    pub fn flush(&self) -> Result<(), SessionStoreError> {
463        let mut st = self.state.lock().map_err(poison)?;
464        self.persist_meta_locked(&mut st)
465    }
466
467    /// Snapshot of the session manifest.
468    #[must_use]
469    pub fn manifest(&self) -> SessionManifest {
470        self.state
471            .lock()
472            .map_err(poison)
473            .map(|s| s.manifest.clone())
474            .unwrap_or_else(|_| SessionManifest::new(""))
475    }
476
477    /// Snapshot of the turn index.
478    #[must_use]
479    pub fn turn_index(&self) -> TurnIndex {
480        self.state.lock().map_err(poison).map(|s| s.index.clone()).unwrap_or_default()
481    }
482
483    /// Flush metadata for callers that explicitly close a log handle.
484    ///
485    /// Terminal status is intentionally controlled only by a persisted
486    /// `thread.completed` event. This method does not synthesize lifecycle
487    /// state for callers that merely release a store handle.
488    pub(crate) fn complete(&self) -> Result<(), SessionStoreError> {
489        let mut st = self.state.lock().map_err(poison)?;
490        st.manifest.updated_at = now_rfc3339();
491        self.persist_meta_locked(&mut st)
492    }
493
494    /// Rebuild index + manifest by scanning `events.jsonl` (authoritative).
495    ///
496    /// Reads the file line-by-line via `BufReader` to avoid loading the entire
497    /// log into memory. Long-lived sessions can otherwise produce multi-megabyte
498    /// logs that spike memory on every reopen.
499    fn scan(&self) -> Result<(), SessionStoreError> {
500        let mut st = self.state.lock().map_err(poison)?;
501        let file = self
502            .file
503            .lock()
504            .map_err(poison)?
505            .as_ref()
506            .ok_or_else(|| self.event_file_unavailable())?
507            .try_clone()
508            .map_err(|e| SessionStoreError::io(&self.events_path, e))?;
509        let mut reader = std::io::BufReader::new(file);
510        reader
511            .seek(SeekFrom::Start(0))
512            .map_err(|e| SessionStoreError::io(&self.events_path, e))?;
513        let mut buf = Vec::new();
514        let mut pos = 0u64;
515        let mut first_ts: Option<String> = None;
516        loop {
517            buf.clear();
518            let n = reader
519                .read_until(b'\n', &mut buf)
520                .map_err(|e| SessionStoreError::io(&self.events_path, e))?;
521            if n == 0 {
522                break;
523            }
524            let line_end = pos + n as u64;
525            let trimmed = std::str::from_utf8(&buf).unwrap_or("").trim();
526            if !trimmed.is_empty()
527                && let Ok(v) = serde_json::from_str::<VersionedEventKind<'_>>(trimmed)
528            {
529                let kind = v.event.kind;
530                if requires_full_lifecycle_validation(kind)
531                    && serde_json::from_str::<VersionedThreadEvent>(trimmed).is_err()
532                {
533                    pos = line_end;
534                    continue;
535                }
536                st.manifest.event_count += 1;
537                // `thread.started` is not part of the turn lifecycle — it
538                // only seeds `created_at` on the first occurrence.
539                if kind == "thread.started" && first_ts.is_none() {
540                    first_ts = Some(now_rfc3339());
541                }
542                // Route turn-lifecycle events through the same state machine
543                // as `append`, eliminating a previously duplicated match block.
544                st.apply_lifecycle_event(LifecycleKind::from_kind(kind), pos, line_end);
545            }
546            pos = line_end;
547        }
548        // The scan uses `LogState.in_turn` via `apply_lifecycle_event`; reset
549        // it so a reopen that ends mid-turn does not leave the state machine
550        // in the "inside a turn" position (the fast path also starts with
551        // `in_turn = false`).
552        st.in_turn = false;
553        if let Some(ts) = first_ts
554            && st.manifest.created_at.is_empty()
555        {
556            st.manifest.created_at = ts;
557        }
558        Ok(())
559    }
560
561    fn persist_meta_locked(&self, st: &mut LogState) -> Result<(), SessionStoreError> {
562        self.flush_write_buf_locked(st)?;
563        self.manifest_store.write_manifest(&st.manifest)?;
564        self.manifest_store.write_turn_index(&st.index)?;
565        Ok(())
566    }
567
568    /// Flush the in-memory write buffer to the underlying file.
569    fn flush_write_buf_locked(&self, st: &mut LogState) -> Result<(), SessionStoreError> {
570        if st.write_buf.is_empty() {
571            return Ok(());
572        }
573        let mut file_slot = self.file.lock().map_err(poison)?;
574        let file = file_slot.as_mut().ok_or_else(|| self.event_file_unavailable())?;
575        let previous_len = file.metadata().map_err(|e| SessionStoreError::io(&self.events_path, e))?.len();
576        if let Err(error) = file.write_all(&st.write_buf) {
577            if file.set_len(previous_len).is_err() {
578                st.write_buf.clear();
579            }
580            return Err(SessionStoreError::io(&self.events_path, error));
581        }
582        if let Err(error) = file.sync_data() {
583            st.write_buf.clear();
584            return Err(SessionStoreError::io(&self.events_path, error));
585        }
586        st.write_buf.clear();
587        Ok(())
588    }
589
590    fn event_file_metadata_len(&self) -> Result<u64, SessionStoreError> {
591        let file_slot = self.file.lock().map_err(poison)?;
592        file_slot
593            .as_ref()
594            .ok_or_else(|| self.event_file_unavailable())?
595            .metadata()
596            .map(|metadata| metadata.len())
597            .map_err(|error| SessionStoreError::io(&self.events_path, error))
598    }
599
600    fn open_event_file(&self) -> Result<File, SessionStoreError> {
601        VtCodePaths::open_private_append_file(&self.events_path)
602            .map_err(|error| SessionStoreError::io(&self.events_path, std::io::Error::other(error)))
603    }
604
605    fn replace_event_file_contents(&self, contents: &[u8]) -> Result<u64, SessionStoreError> {
606        let old_file = {
607            let mut file_slot = self.file.lock().map_err(poison)?;
608            file_slot.take().ok_or_else(|| self.event_file_unavailable())?
609        };
610        drop(old_file);
611
612        if let Err(error) = VtCodePaths::write_private_file_atomic(&self.events_path, contents)
613            .map_err(|error| SessionStoreError::io(&self.events_path, std::io::Error::other(error)))
614        {
615            let restored = self.open_event_file();
616            if let Ok(file) = restored {
617                let mut file_slot = self.file.lock().map_err(poison)?;
618                *file_slot = Some(file);
619                return Err(error);
620            }
621            return Err(SessionStoreError::io(
622                &self.events_path,
623                std::io::Error::other(format!("{error}; failed to restore event log handle")),
624            ));
625        }
626
627        let replacement = self.open_event_file()?;
628        let next_offset = replacement
629            .metadata()
630            .map_err(|error| SessionStoreError::io(&self.events_path, error))?
631            .len();
632        let mut file_slot = self.file.lock().map_err(poison)?;
633        *file_slot = Some(replacement);
634        Ok(next_offset)
635    }
636
637    fn event_file_unavailable(&self) -> SessionStoreError {
638        SessionStoreError::io(&self.events_path, std::io::Error::other("event log file is unavailable"))
639    }
640}
641
642fn requires_full_lifecycle_validation(kind: &str) -> bool {
643    matches!(kind, "thread.started" | "thread.completed" | "turn.started" | "turn.completed" | "turn.failed")
644}
645
646impl Drop for SessionEventLog {
647    fn drop(&mut self) {
648        if let Ok(mut st) = self.state.lock() {
649            // The fallible `flush` method is the authoritative shutdown path;
650            // Drop only provides a best-effort byte flush for callers that do
651            // not explicitly close the log. Rewriting metadata here could
652            // overwrite a manifest update made by another owner after the
653            // last append.
654            let _ = self.flush_write_buf_locked(&mut st);
655        }
656    }
657}
658
659/// Locate the next newline at or after `from`, returning a past-the-end index.
660fn poison<T>(_e: std::sync::PoisonError<T>) -> SessionStoreError {
661    SessionStoreError::Io {
662        path: PathBuf::new(),
663        source: std::io::Error::other("session store lock poisoned"),
664    }
665}
666
667fn now_rfc3339() -> String {
668    Utc::now().to_rfc3339()
669}
670
671/// Session-level metadata persisted to `manifest.json`.
672#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
673pub struct SessionManifest {
674    /// Stable session identifier (directory name).
675    pub session_id: String,
676    /// Layout schema version (`SESSION_STORE_SCHEMA_VERSION`).
677    schema_version: u32,
678    /// RFC3339 creation timestamp.
679    pub created_at: String,
680    /// RFC3339 last-update timestamp.
681    pub updated_at: String,
682    /// Number of completed turns.
683    pub turn_count: u64,
684    /// Total number of events recorded.
685    pub event_count: u64,
686    /// Lifecycle status (`active` | `completed`).
687    pub status: String,
688}
689
690impl SessionManifest {
691    /// Create a fresh manifest for a session.
692    #[must_use]
693    pub(crate) fn new(session_id: &str) -> Self {
694        let ts = now_rfc3339();
695        Self {
696            session_id: session_id.to_string(),
697            schema_version: crate::SESSION_STORE_SCHEMA_VERSION,
698            created_at: ts.clone(),
699            updated_at: ts,
700            turn_count: 0,
701            event_count: 0,
702            status: "active".to_string(),
703        }
704    }
705}
706
707/// Byte-offset index of a single turn within `events.jsonl`.
708#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
709pub struct TurnIndexEntry {
710    /// Turn ordinal (1-based).
711    turn_number: u64,
712    /// Byte offset of the turn's first event.
713    start_offset: u64,
714    /// Byte offset just past the turn's last event.
715    end_offset: u64,
716    /// Number of events in the turn.
717    event_count: u64,
718    /// RFC3339 timestamp of turn start.
719    ts: String,
720}
721
722/// Ordered index of all turns in a session.
723#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
724pub struct TurnIndex {
725    /// Turn entries in ordinal order.
726    entries: VecDeque<TurnIndexEntry>,
727}
728
729impl TurnIndex {
730    /// Number of indexed turns.
731    #[must_use]
732    pub fn len(&self) -> usize {
733        self.entries.len()
734    }
735
736    /// Whether the index is empty.
737    #[must_use]
738    pub fn is_empty(&self) -> bool {
739        self.entries.is_empty()
740    }
741
742    fn is_valid_for_file(&self, file_len: u64) -> bool {
743        let mut previous_end = 0u64;
744        self.entries.iter().all(|entry| {
745            let valid = entry.event_count > 0
746                && entry.start_offset >= previous_end
747                && entry.start_offset <= entry.end_offset
748                && entry.end_offset <= file_len;
749            if valid {
750                previous_end = entry.end_offset;
751            }
752            valid
753        })
754    }
755}
756
757#[cfg(test)]
758mod borrowed_envelope_tests {
759    use super::{BorrowedVersionedEvent, EVENT_SCHEMA_VERSION};
760    use vtcode_exec_events::{
761        ThreadEvent, ThreadStartedEvent, TurnCompletedEvent, TurnStartedEvent, Usage, VersionedThreadEvent,
762    };
763
764    /// The borrowed envelope must produce JSON byte-identical to
765    /// `VersionedThreadEvent::new(event.clone())`. This guards against drift if
766    /// either the envelope or the canonical wrapper is modified.
767    #[test]
768    fn borrowed_envelope_matches_versioned_envelope() {
769        for event in [
770            ThreadEvent::ThreadStarted(ThreadStartedEvent { thread_id: "thread".to_string() }),
771            ThreadEvent::TurnStarted(TurnStartedEvent::default()),
772            ThreadEvent::TurnCompleted(TurnCompletedEvent { usage: Usage::default() }),
773        ] {
774            let canonical =
775                serde_json::to_string(&VersionedThreadEvent::new(event.clone())).expect("canonical serialize");
776            let borrowed = serde_json::to_string(&BorrowedVersionedEvent {
777                schema_version: EVENT_SCHEMA_VERSION,
778                event: &event,
779            })
780            .expect("borrowed serialize");
781            assert_eq!(canonical, borrowed, "JSON differs for {event:?}");
782        }
783    }
784}
785
786#[cfg(test)]
787mod lifecycle_state_machine_tests {
788    use super::{LifecycleKind, LogState};
789    use vtcode_exec_events::{
790        ThreadCompletedEvent, ThreadCompletionSubtype, ThreadEvent, ThreadStartedEvent, TurnCompletedEvent,
791        TurnFailedEvent, TurnStartedEvent, Usage,
792    };
793
794    fn fresh_state() -> LogState {
795        LogState::new("test-session")
796    }
797
798    #[test]
799    fn lifecycle_kind_from_event_covers_all_variants() {
800        assert_eq!(
801            LifecycleKind::from_event(&ThreadEvent::TurnStarted(TurnStartedEvent::default())),
802            LifecycleKind::TurnStarted
803        );
804        assert_eq!(
805            LifecycleKind::from_event(&ThreadEvent::TurnCompleted(TurnCompletedEvent { usage: Usage::default() })),
806            LifecycleKind::TurnCompleted
807        );
808        assert_eq!(
809            LifecycleKind::from_event(&ThreadEvent::TurnFailed(TurnFailedEvent {
810                message: "err".to_string(),
811                usage: None,
812            })),
813            LifecycleKind::TurnFailed
814        );
815        assert_eq!(
816            LifecycleKind::from_event(&ThreadEvent::ThreadStarted(ThreadStartedEvent { thread_id: "x".to_string() })),
817            LifecycleKind::ThreadStarted
818        );
819        assert_eq!(
820            LifecycleKind::from_event(&ThreadEvent::ThreadCompleted(Box::new(ThreadCompletedEvent {
821                thread_id: "x".to_string(),
822                session_id: "x".to_string(),
823                subtype: ThreadCompletionSubtype::Success,
824                outcome_code: "completed".to_string(),
825                result: None,
826                stop_reason: None,
827                usage: Usage::default(),
828                total_cost_usd: None,
829                num_turns: 1,
830            }))),
831            LifecycleKind::ThreadCompleted
832        );
833        assert_eq!(LifecycleKind::from_kind("thread.started"), LifecycleKind::ThreadStarted);
834        assert_eq!(LifecycleKind::from_kind("thread.completed"), LifecycleKind::ThreadCompleted);
835    }
836
837    #[test]
838    fn lifecycle_kind_from_str_matches_event_discriminator() {
839        assert_eq!(LifecycleKind::from_kind("turn.started"), LifecycleKind::TurnStarted);
840        assert_eq!(LifecycleKind::from_kind("turn.completed"), LifecycleKind::TurnCompleted);
841        assert_eq!(LifecycleKind::from_kind("turn.failed"), LifecycleKind::TurnFailed);
842        assert_eq!(LifecycleKind::from_kind("tool.called"), LifecycleKind::Other);
843        assert_eq!(LifecycleKind::from_kind("thread.started"), LifecycleKind::ThreadStarted);
844        assert_eq!(LifecycleKind::from_kind("thread.completed"), LifecycleKind::ThreadCompleted);
845    }
846
847    #[test]
848    fn turn_started_pushes_index_entry_and_sets_in_turn() {
849        let mut st = fresh_state();
850        st.manifest.status = "completed".to_string();
851        let is_boundary = st.apply_lifecycle_event(LifecycleKind::TurnStarted, 0, 100);
852        assert!(!is_boundary, "TurnStarted is not a turn boundary");
853        assert!(st.in_turn);
854        assert_eq!(st.manifest.status, "active");
855        assert_eq!(st.index.entries.len(), 1);
856        let entry = &st.index.entries[0];
857        assert_eq!(entry.turn_number, 1);
858        assert_eq!(entry.start_offset, 0);
859        assert_eq!(entry.end_offset, 100);
860        assert_eq!(entry.event_count, 1);
861    }
862
863    #[test]
864    fn intermediate_events_extend_current_turn() {
865        let mut st = fresh_state();
866        st.apply_lifecycle_event(LifecycleKind::TurnStarted, 0, 100);
867        // Simulate two intermediate events.
868        let is_b1 = st.apply_lifecycle_event(LifecycleKind::Other, 100, 200);
869        let is_b2 = st.apply_lifecycle_event(LifecycleKind::Other, 200, 300);
870        assert!(!is_b1 && !is_b2);
871        assert!(st.in_turn);
872        assert_eq!(st.index.entries.len(), 1);
873        let entry = &st.index.entries[0];
874        assert_eq!(entry.end_offset, 300);
875        assert_eq!(entry.event_count, 3);
876    }
877
878    #[test]
879    fn turn_completed_closes_turn_and_returns_boundary() {
880        let mut st = fresh_state();
881        st.apply_lifecycle_event(LifecycleKind::TurnStarted, 0, 100);
882        st.apply_lifecycle_event(LifecycleKind::Other, 100, 200);
883        let is_boundary = st.apply_lifecycle_event(LifecycleKind::TurnCompleted, 200, 300);
884        assert!(is_boundary);
885        assert!(!st.in_turn);
886        assert_eq!(st.manifest.turn_count, 1);
887        assert_eq!(st.manifest.status, "active");
888        st.apply_lifecycle_event(LifecycleKind::ThreadCompleted, 300, 400);
889        assert_eq!(st.manifest.status, "completed");
890        let entry = &st.index.entries[0];
891        assert_eq!(entry.end_offset, 300);
892        assert_eq!(entry.event_count, 3);
893    }
894
895    #[test]
896    fn turn_failed_closes_turn_without_terminal_thread_status() {
897        let mut st = fresh_state();
898        st.apply_lifecycle_event(LifecycleKind::TurnStarted, 0, 100);
899        let is_boundary = st.apply_lifecycle_event(LifecycleKind::TurnFailed, 100, 200);
900        assert!(is_boundary);
901        assert!(!st.in_turn);
902        assert_eq!(st.manifest.turn_count, 1);
903        assert_eq!(st.manifest.status, "active");
904        st.apply_lifecycle_event(LifecycleKind::ThreadCompleted, 200, 300);
905        assert_eq!(st.manifest.status, "completed");
906    }
907
908    #[test]
909    fn turn_completed_without_turn_started_is_idempotent() {
910        let mut st = fresh_state();
911        // Receiving TurnCompleted without a preceding TurnStarted should not
912        // panic or corrupt the index; terminal status remains active until the
913        // thread lifecycle itself completes.
914        let is_boundary = st.apply_lifecycle_event(LifecycleKind::TurnCompleted, 0, 100);
915        assert!(is_boundary);
916        assert!(!st.in_turn);
917        assert_eq!(st.manifest.turn_count, 0, "no turn was started");
918        assert_eq!(st.manifest.status, "active");
919        st.apply_lifecycle_event(LifecycleKind::ThreadCompleted, 100, 200);
920        assert_eq!(st.manifest.status, "completed");
921        assert!(st.index.entries.is_empty());
922    }
923
924    #[test]
925    fn multiple_turns_get_incrementing_ordinals() {
926        let mut st = fresh_state();
927        for n in 1..=3 {
928            st.apply_lifecycle_event(LifecycleKind::TurnStarted, n * 100, n * 100 + 50);
929            st.apply_lifecycle_event(LifecycleKind::TurnCompleted, n * 100 + 50, n * 100 + 100);
930        }
931        assert_eq!(st.index.entries.len(), 3);
932        for (i, entry) in st.index.entries.iter().enumerate() {
933            assert_eq!(entry.turn_number, (i + 1) as u64);
934        }
935        assert_eq!(st.manifest.turn_count, 3);
936    }
937}
938
939#[cfg(test)]
940mod cap_eviction_tests {
941    use super::{LogState, TurnIndexEntry};
942
943    /// Build a `LogState` with `turns` fake turns, each having `events_per_turn`
944    /// events, starting at byte offset 0.
945    fn state_with_turns(turns: usize, events_per_turn: u64) -> LogState {
946        let mut st = LogState::new("cap-test");
947        st.manifest.event_count = (turns as u64) * events_per_turn;
948        let mut offset = 0u64;
949        for n in 1..=turns {
950            st.index.entries.push_back(TurnIndexEntry {
951                turn_number: n as u64,
952                start_offset: offset,
953                end_offset: offset + events_per_turn * 10,
954                event_count: events_per_turn,
955                ts: "2026-01-01T00:00:00Z".to_string(),
956            });
957            offset += events_per_turn * 10;
958        }
959        st
960    }
961
962    #[test]
963    fn no_eviction_when_under_cap() {
964        let st = state_with_turns(3, 2); // 6 events
965        assert!(st.plan_cap_eviction(10).is_none());
966        assert_eq!(st.index.entries.len(), 3, "no turns should be evicted");
967    }
968
969    #[test]
970    fn no_eviction_when_cap_disabled() {
971        let st = state_with_turns(5, 2); // 10 events
972        assert!(st.plan_cap_eviction(0).is_none());
973        assert_eq!(st.index.entries.len(), 5);
974    }
975
976    #[test]
977    fn evicts_oldest_turns_to_meet_cap() {
978        // 5 turns × 2 events = 10 events; cap = 6 → need to evict 2 turns (4 events).
979        let st = state_with_turns(5, 2);
980        let plan = st.plan_cap_eviction(6).expect("eviction planned");
981        assert_eq!(plan.evicted_event_count, 4, "should evict 4 events (2 turns)");
982        assert_eq!(st.index.entries.len(), 5, "planning must not mutate state");
983        // Truncate offset is the end of the last evicted turn.
984        assert_eq!(plan.truncate_offset, 40); // 2 turns × 20 bytes each
985
986        // Applying the plan leaves turns 3, 4, 5.
987        let mut st = st;
988        st.apply_cap_eviction(plan, 60);
989        assert_eq!(st.index.entries.len(), 3, "should keep 3 turns");
990        assert_eq!(st.index.entries[0].turn_number, 3);
991        assert_eq!(st.index.entries[2].turn_number, 5);
992    }
993
994    #[test]
995    fn evicts_all_turns_when_cap_smaller_than_one_turn() {
996        // 3 turns × 5 events = 15 events; cap = 3 → evict turns until ≤ 3 remain.
997        // Each turn has 5 events, so evicting 2 turns leaves 5 (>3), evicting
998        // 3 turns leaves 0.
999        let st = state_with_turns(3, 5);
1000        let plan = st.plan_cap_eviction(3).expect("eviction planned");
1001        assert_eq!(plan.evicted_event_count, 15, "all events evicted");
1002        let mut st = st;
1003        st.apply_cap_eviction(plan, 0);
1004        assert_eq!(st.index.entries.len(), 0);
1005    }
1006}