Skip to main content

vtcode_memory/event_log/
mod.rs

1//! Append-only per-session `ThreadEvent` log plus index and manifest.
2
3use std::collections::{BTreeMap, HashMap, VecDeque};
4use std::fs::File;
5use std::io::{BufRead, Read, Seek, SeekFrom, Write};
6use std::path::{Path, PathBuf};
7use std::sync::atomic::{AtomicBool, Ordering};
8use std::sync::{Arc, Mutex, OnceLock, Weak};
9
10use chrono::Utc;
11use serde::{Deserialize, Serialize};
12use vtcode_commons::VtCodePaths;
13use vtcode_exec_events::{EVENT_SCHEMA_VERSION, ThreadEvent, ThreadItemDetails, VersionedThreadEvent};
14
15use crate::error::SessionStoreError;
16
17/// Sidecar lock file flock-held while a session's event-log handles are open.
18pub(crate) const SESSION_LOCK_FILE: &str = "session.lock";
19use crate::manifest::{ManifestStore, PendingCapRewrite};
20use crate::session_dir;
21
22mod append;
23mod eviction;
24mod manifest;
25mod scan;
26#[cfg(test)]
27mod sync_tests;
28
29use eviction::default_eviction_summary_hook;
30pub use manifest::{SessionManifest, TurnIndex, TurnIndexEntry};
31use scan::{
32    count_persisted_event_records, decode_events, infer_scan_turn_base, requires_full_lifecycle_validation,
33    valid_lifecycle_payload,
34};
35
36/// Default maximum number of events retained per session before the oldest
37/// completed turns are evicted.
38pub const DEFAULT_MAX_EVENTS: usize = 10_000;
39
40/// Maximum serialized event bytes retained before an append forces a write.
41/// Turn boundaries and reads still flush immediately.
42const MAX_WRITE_BUFFER_BYTES: usize = 64 * 1024;
43
44/// Callback used to persist a summary of events before they are evicted.
45///
46/// The callback runs after the event bytes have been flushed and decoded, but
47/// before the canonical log is rewritten. A failure leaves the original log
48/// and in-memory index untouched, so retention never silently discards
49/// history.
50pub type EvictionSummaryHook = Arc<dyn Fn(&[ThreadEvent]) -> Result<(), SessionStoreError> + Send + Sync>;
51
52/// Minimal envelope used while rebuilding the turn index.
53///
54/// The index only needs the event discriminator. Deserializing a complete
55/// [`VersionedThreadEvent`] here would allocate every nested tool argument,
56/// output, and thread item even though none of that payload is retained.
57#[derive(Debug, Deserialize)]
58struct VersionedEventKind<'a> {
59    #[serde(rename = "schema_version", borrow)]
60    _schema_version: &'a str,
61    #[serde(borrow)]
62    event: EventKind<'a>,
63}
64
65#[derive(Debug, Deserialize)]
66struct EventKind<'a> {
67    #[serde(rename = "type", borrow)]
68    kind: &'a str,
69}
70
71/// Zero-clone serialization envelope for `ThreadEvent`.
72///
73/// Produces JSON byte-identical to `VersionedThreadEvent` but borrows the
74/// event by reference instead of cloning it. `append` is called for every
75/// runtime event, and `ThreadEvent` can carry large tool outputs / thread
76/// items — cloning just to feed `serde_json::to_string` was pure waste.
77#[derive(Serialize)]
78struct BorrowedVersionedEvent<'a> {
79    schema_version: &'a str,
80    event: &'a ThreadEvent,
81}
82
83/// Turn-lifecycle discriminator extracted from either a `ThreadEvent` (at
84/// append time) or a raw `&str` kind (during scan).  This is the single
85/// representation that both code paths feed into
86/// [`LogState::apply_lifecycle_event`], eliminating a duplicated state machine.
87#[derive(Debug, Clone, Copy, PartialEq, Eq)]
88enum LifecycleKind {
89    ThreadStarted,
90    ThreadCompleted,
91    TurnStarted,
92    TurnCompleted,
93    TurnFailed,
94    Other,
95}
96
97impl LifecycleKind {
98    /// Discriminate from a runtime `ThreadEvent` at append time.
99    #[inline]
100    fn from_event(event: &ThreadEvent) -> Self {
101        match event {
102            ThreadEvent::ThreadStarted(_) => Self::ThreadStarted,
103            ThreadEvent::ThreadCompleted(_) => Self::ThreadCompleted,
104            ThreadEvent::TurnStarted(_) => Self::TurnStarted,
105            ThreadEvent::TurnCompleted(_) => Self::TurnCompleted,
106            ThreadEvent::TurnFailed(_) => Self::TurnFailed,
107            _ => Self::Other,
108        }
109    }
110
111    /// Discriminate from a raw event-type string at scan time.
112    #[inline]
113    fn from_kind(kind: &str) -> Self {
114        match kind {
115            "thread.started" => Self::ThreadStarted,
116            "thread.completed" => Self::ThreadCompleted,
117            "turn.started" => Self::TurnStarted,
118            "turn.completed" => Self::TurnCompleted,
119            "turn.failed" => Self::TurnFailed,
120            _ => Self::Other,
121        }
122    }
123}
124
125/// In-memory state protected by a mutex (cheap; appends are infrequent relative
126/// to model inference).
127struct LogState {
128    manifest: SessionManifest,
129    index: TurnIndex,
130    /// Whether we are currently inside a turn (between TurnStarted and
131    /// TurnCompleted/TurnFailed). Used to update the last index entry's
132    /// offsets as intermediate events arrive.
133    in_turn: bool,
134    /// Running byte offset of the next append. Avoids a `stat` syscall per
135    /// event (the previous implementation re-statted the file twice on every
136    /// `append`); initialized from the file length on `open`.
137    next_offset: u64,
138    /// Buffered pending writes to batch syscalls. Events are appended here
139    /// and flushed to disk at turn boundaries or before read operations.
140    write_buf: Vec<u8>,
141    /// Written event bytes still require a successful sync before metadata.
142    needs_sync: bool,
143    /// A partial write could not be rolled back; state must be rebuilt on reopen.
144    write_failed: bool,
145    /// An existing final record lacks its newline delimiter.
146    needs_separator: bool,
147}
148
149#[derive(Debug, Clone, Copy)]
150struct CapEvictionPlan {
151    truncate_offset: u64,
152    evicted_event_count: u64,
153    evicted_turn_count: usize,
154}
155
156impl LogState {
157    fn new(session_id: &str) -> Self {
158        Self {
159            manifest: SessionManifest::new(session_id),
160            index: TurnIndex::default(),
161            in_turn: false,
162            next_offset: 0,
163            write_buf: Vec::with_capacity(65536),
164            needs_sync: false,
165            write_failed: false,
166            needs_separator: false,
167        }
168    }
169
170    /// Serialize `event` directly into the reusable write buffer with rollback
171    /// on failure.
172    ///
173    /// This encapsulates the invariant that `write_buf` never contains a
174    /// partial JSON document: if `serde_json::to_writer` fails mid-write the
175    /// buffer is truncated back to its pre-serialization boundary.  Returns
176    /// the `(start, end)` byte offsets of the serialized event so the caller
177    /// can feed them to [`Self::apply_lifecycle_event`].
178    fn serialize_event(&mut self, event: &ThreadEvent) -> Result<(u64, u64), SessionStoreError> {
179        let start = self.next_offset;
180        let buf_len_before = self.write_buf.len();
181        if let Err(err) = serde_json::to_writer(
182            &mut self.write_buf,
183            &BorrowedVersionedEvent { schema_version: EVENT_SCHEMA_VERSION, event },
184        ) {
185            self.write_buf.truncate(buf_len_before);
186            return Err(err.into());
187        }
188        self.write_buf.push(b'\n');
189        let written = self.write_buf.len() - buf_len_before;
190        let end = start + written as u64;
191        self.next_offset = end;
192        Ok((start, end))
193    }
194
195    /// Update the in-memory turn index and manifest counters for a single
196    /// event.
197    ///
198    /// This is the single implementation of the turn-lifecycle state machine;
199    /// both the append path (via [`LifecycleKind::from_event`]) and the scan
200    /// path (via [`LifecycleKind::from_kind`]) route through here, eliminating
201    /// a previously duplicated match block.
202    ///
203    /// Returns `true` when the event closes a turn boundary
204    /// (`TurnCompleted` / `TurnFailed`) so the caller can persist metadata
205    /// at the appropriate time (append persists immediately; scan persists
206    /// once after the full scan).
207    fn apply_lifecycle_event(&mut self, kind: LifecycleKind, start: u64, end: u64) -> bool {
208        let is_boundary = match kind {
209            LifecycleKind::ThreadStarted => {
210                self.manifest.status = "active".to_string();
211                false
212            }
213            LifecycleKind::ThreadCompleted => {
214                self.manifest.status = "completed".to_string();
215                true
216            }
217            LifecycleKind::TurnStarted => {
218                self.manifest.status = "active".to_string();
219                self.in_turn = true;
220                let n = self.manifest.turn_count + 1;
221                self.index.entries.push_back(TurnIndexEntry {
222                    turn_number: n,
223                    start_offset: start,
224                    end_offset: end,
225                    event_count: 1,
226                    ts: now_rfc3339(),
227                });
228                false
229            }
230            LifecycleKind::TurnCompleted | LifecycleKind::TurnFailed => {
231                if self.in_turn {
232                    if let Some(entry) = self.index.entries.back_mut() {
233                        entry.end_offset = end;
234                        entry.event_count += 1;
235                        // `turn_number` remains monotonic when older completed
236                        // turns have been evicted. The manifest is the source
237                        // for the next ordinal, so never replace it with the
238                        // retained index length.
239                        self.manifest.turn_count = self.manifest.turn_count.max(entry.turn_number);
240                    }
241                    self.in_turn = false;
242                }
243                true
244            }
245            LifecycleKind::Other => {
246                if self.in_turn
247                    && let Some(entry) = self.index.entries.back_mut()
248                {
249                    entry.end_offset = end;
250                    entry.event_count += 1;
251                }
252                false
253            }
254        };
255        // Persist the open-turn marker alongside the manifest. The marker is
256        // deliberately optional for backwards compatibility: an older
257        // manifest without it forces a scan on reopen so the state can be
258        // reconstructed from the canonical event log.
259        self.manifest.in_turn = Some(self.in_turn);
260        is_boundary
261    }
262
263    /// Plan a cap-enforcement eviction: pop the oldest completed turns from
264    /// the index until `event_count` is within `max_events`.
265    ///
266    /// Returns the byte offset at which the file should be rewritten and the
267    /// counts needed to apply the eviction after successful I/O. Returns
268    /// `None` when no eviction is needed.
269    fn plan_cap_eviction(&self, max_events: usize) -> Option<CapEvictionPlan> {
270        if max_events == 0 || self.manifest.event_count <= max_events as u64 {
271            return None;
272        }
273        let mut evicted_event_count = 0u64;
274        let mut truncate_offset = 0u64;
275        let mut evicted_turn_count = 0;
276        for (entry_index, oldest) in self.index.entries.iter().enumerate() {
277            // Never evict the active turn. Its entry has a provisional end
278            // offset and will be closed by a later completion/failure event;
279            // removing it here would make that event unindexed and lose the
280            // in-flight turn from reconstruction. If the completed history
281            // alone cannot bring the log under the cap, retain the active
282            // turn until it reaches a terminal boundary.
283            if self.in_turn && entry_index + 1 == self.index.entries.len() {
284                break;
285            }
286            if self.manifest.event_count.saturating_sub(evicted_event_count) <= max_events as u64 {
287                break;
288            }
289            truncate_offset = oldest.end_offset;
290            evicted_event_count += oldest.event_count;
291            evicted_turn_count += 1;
292        }
293        if truncate_offset == 0 || evicted_turn_count == 0 {
294            None
295        } else {
296            Some(CapEvictionPlan {
297                truncate_offset,
298                evicted_event_count,
299                evicted_turn_count,
300            })
301        }
302    }
303
304    fn apply_cap_eviction(&mut self, plan: CapEvictionPlan, next_offset: u64) {
305        for _ in 0..plan.evicted_turn_count {
306            let _ = self.index.entries.pop_front();
307        }
308        for entry in &mut self.index.entries {
309            entry.start_offset = entry.start_offset.saturating_sub(plan.truncate_offset);
310            entry.end_offset = entry.end_offset.saturating_sub(plan.truncate_offset);
311        }
312        self.next_offset = next_offset;
313        self.manifest.event_count = self.manifest.event_count.saturating_sub(plan.evicted_event_count);
314        self.manifest.retained_turn_base = Some(self.retained_turn_base_after_eviction(0));
315    }
316
317    fn retained_turn_base_after_eviction(&self, evicted_turn_count: usize) -> u64 {
318        self.index
319            .entries
320            .get(evicted_turn_count)
321            .map(|entry| entry.turn_number)
322            .or_else(|| self.manifest.turn_count.checked_add(1))
323            .unwrap_or(1)
324            .max(1)
325    }
326}
327
328/// State and file handle shared by every owner of one session in this process.
329///
330/// A keyed operation lock alone is insufficient: two independently opened
331/// handles could still carry stale turn counters and overwrite each other's
332/// metadata after taking the lock. Sharing the mutable state and append file
333/// makes the lock a true session boundary while preserving the value-type
334/// `SessionEventLog` API.
335struct SessionShared {
336    file: Mutex<Option<File>>,
337    state: Mutex<LogState>,
338    eviction_lock: Mutex<()>,
339    initialized: AtomicBool,
340    /// Exclusive flock on the session's `session.lock`, held for as long as
341    /// any handle to this session exists. Retention reads it as a liveness
342    /// signal so an open-but-idle session is never marked or evicted, and
343    /// exclusive opens refuse when another process holds it. Behind a mutex
344    /// so a shared (degraded) open can upgrade to exclusive without
345    /// reopening; only touched on open paths.
346    liveness_lock: Mutex<Option<File>>,
347}
348
349/// How strongly an open claims the session's liveness lock.
350#[derive(Debug, Clone, Copy, PartialEq, Eq)]
351enum OpenMode {
352    /// Best-effort claim for readers: contention degrades to unlocked and
353    /// the open succeeds, preserving the historical behavior.
354    Shared,
355    /// Mandatory claim for the session's single writer: contention with
356    /// another process fails the open with [`SessionStoreError::SessionBusy`]
357    /// instead of interleaving two writers on one event stream.
358    Exclusive,
359}
360
361/// Return the process-wide shared state for one session's canonical event file.
362///
363/// The weak registry avoids retaining closed sessions forever while still
364/// making repeated `open` calls converge on one file handle and turn state.
365fn shared_session(
366    events_path: &Path,
367    session_id: &str,
368    mode: OpenMode,
369) -> Result<Arc<SessionShared>, SessionStoreError> {
370    static SESSION_SHARED: OnceLock<Mutex<HashMap<PathBuf, Weak<SessionShared>>>> = OnceLock::new();
371
372    let key = events_path
373        .parent()
374        .and_then(|parent| vtcode_commons::paths::canonicalize(parent).ok())
375        .and_then(|parent| events_path.file_name().map(|name| parent.join(name)))
376        .unwrap_or_else(|| events_path.to_path_buf());
377    let registry = SESSION_SHARED.get_or_init(|| Mutex::new(HashMap::new()));
378    let mut shared_by_path = match registry.lock() {
379        Ok(locks) => locks,
380        Err(poisoned) => poisoned.into_inner(),
381    };
382    // Do not retain dead weak entries for every session ever opened by a
383    // long-running process. The registry is only an in-process coordination
384    // aid, so removing entries with no live owners is safe.
385    shared_by_path.retain(|_, shared| shared.strong_count() > 0);
386    if let Some(shared) = shared_by_path.get(&key).and_then(Weak::upgrade) {
387        // In-process reopen: handles share one file and turn state. An
388        // exclusive reopen additionally upgrades a degraded (lock-less)
389        // shared open so the writer always ends up holding the lock.
390        if mode == OpenMode::Exclusive {
391            shared.ensure_exclusive_liveness(events_path, session_id)?;
392        }
393        return Ok(shared);
394    }
395
396    let file = VtCodePaths::open_private_append_file(events_path)
397        .map_err(|error| SessionStoreError::io(events_path.to_path_buf(), std::io::Error::other(error)))?;
398    let liveness_lock = match mode {
399        OpenMode::Shared => acquire_liveness_lock(events_path, session_id),
400        OpenMode::Exclusive => acquire_liveness_lock_strict(events_path, session_id)?,
401    };
402    let shared = Arc::new(SessionShared {
403        file: Mutex::new(Some(file)),
404        state: Mutex::new(LogState::new(session_id)),
405        eviction_lock: Mutex::new(()),
406        initialized: AtomicBool::new(false),
407        liveness_lock: Mutex::new(liveness_lock),
408    });
409    shared_by_path.insert(key, Arc::downgrade(&shared));
410    Ok(shared)
411}
412
413/// Best-effort exclusive flock on the session's `session.lock`.
414///
415/// The lock is the liveness signal retention reads (`session_dir_is_live`): a
416/// held lock means a live process still has the session open. The crate has
417/// no logging surface and this is an advisory signal, not persistence, so a
418/// failure to create/open/lock degrades to unlocked (previous retention
419/// behavior) instead of failing the session open or swallowing a persistence
420/// error. The fd is released automatically when the shared handle drops.
421fn acquire_liveness_lock(events_path: &Path, session_id: &str) -> Option<File> {
422    acquire_liveness_lock_strict(events_path, session_id).unwrap_or(None)
423}
424
425/// Strict liveness claim for the session's single writer.
426///
427/// A `WouldBlock` contention proves another process holds the session open
428/// and fails with [`SessionStoreError::SessionBusy`]; every other failure
429/// (exotic filesystems, permissions) degrades to unlocked exactly like the
430/// best-effort path so a missing lock never blocks a session by itself.
431fn acquire_liveness_lock_strict(events_path: &Path, session_id: &str) -> Result<Option<File>, SessionStoreError> {
432    let Some(dir) = events_path.parent() else {
433        return Ok(None);
434    };
435    let lock_path = dir.join(SESSION_LOCK_FILE);
436    if VtCodePaths::write_private_file_atomic_if_absent(&lock_path, b"").is_err() {
437        return Ok(None);
438    }
439    let file = match std::fs::OpenOptions::new().read(true).write(true).open(&lock_path) {
440        Ok(file) => file,
441        Err(_) => return Ok(None),
442    };
443    match file.try_lock().map_err(std::io::Error::from) {
444        Ok(()) => Ok(Some(file)),
445        Err(error) if error.kind() == std::io::ErrorKind::WouldBlock => {
446            Err(SessionStoreError::SessionBusy { session: session_id.to_owned() })
447        }
448        Err(_) => Ok(None),
449    }
450}
451
452impl SessionShared {
453    /// Claim the liveness lock for an exclusive reopen of an already-shared
454    /// session, so the writer holds the lock even when a reader opened first
455    /// in this process. A no-op when the lock is already held.
456    fn ensure_exclusive_liveness(&self, events_path: &Path, session_id: &str) -> Result<(), SessionStoreError> {
457        let mut slot = self.liveness_lock.lock().map_err(poison)?;
458        if slot.is_none() {
459            *slot = acquire_liveness_lock_strict(events_path, session_id)?;
460        }
461        Ok(())
462    }
463}
464
465/// Canonical append-only event log for a single session.
466///
467/// All session history is reconstructable from this log. Live conversation
468/// state is never read back into context from here; the log is only consumed
469/// for revert, compaction, analytics, and long-term-learning queries.
470pub struct SessionEventLog {
471    events_path: PathBuf,
472    manifest_store: ManifestStore,
473    shared: Arc<SessionShared>,
474    max_events: usize,
475    eviction_summary_hook: EvictionSummaryHook,
476}
477
478impl SessionEventLog {
479    /// Open the log for `session_id`, creating the session directory tree and
480    /// rebuilding the index from `events.jsonl` if it already exists.
481    pub(crate) fn open(workspace: &Path, session_id: &str, max_events: usize) -> Result<Self, SessionStoreError> {
482        let dir = session_dir(workspace, session_id);
483        let hook = default_eviction_summary_hook(dir.join(crate::DERIVED_DIR), session_id.to_string());
484        Self::open_inner(workspace, session_id, max_events, hook, OpenMode::Shared)
485    }
486
487    /// Open the log as the session's single writer, failing with
488    /// [`SessionStoreError::SessionBusy`] when another process already holds
489    /// the session open instead of interleaving two writers on one stream.
490    /// Reopens in this process share state and succeed.
491    pub(crate) fn open_exclusive(
492        workspace: &Path,
493        session_id: &str,
494        max_events: usize,
495    ) -> Result<Self, SessionStoreError> {
496        let dir = session_dir(workspace, session_id);
497        let hook = default_eviction_summary_hook(dir.join(crate::DERIVED_DIR), session_id.to_string());
498        Self::open_inner(workspace, session_id, max_events, hook, OpenMode::Exclusive)
499    }
500
501    /// Open a log with an explicit eviction-summary callback.
502    ///
503    /// This is useful for hosts that keep derived memory in another store and
504    /// for deterministic failure-path tests. The callback must persist its
505    /// summary before returning `Ok(())`.
506    pub fn open_with_eviction_summary(
507        workspace: &Path,
508        session_id: &str,
509        max_events: usize,
510        eviction_summary_hook: EvictionSummaryHook,
511    ) -> Result<Self, SessionStoreError> {
512        Self::open_inner(workspace, session_id, max_events, eviction_summary_hook, OpenMode::Shared)
513    }
514
515    fn open_inner(
516        workspace: &Path,
517        session_id: &str,
518        max_events: usize,
519        eviction_summary_hook: EvictionSummaryHook,
520        mode: OpenMode,
521    ) -> Result<Self, SessionStoreError> {
522        let dir = session_dir(workspace, session_id);
523        crate::ensure_private_directory(&crate::sessions_root(workspace))?;
524        crate::ensure_private_directory(&dir)?;
525        crate::ensure_private_directory(&dir.join(crate::DERIVED_DIR))?;
526        crate::ensure_private_directory(&dir.join("index"))?;
527        let events_path = dir.join("events.jsonl");
528        let manifest_store = ManifestStore::new(dir.clone());
529        let pending_rewrite = manifest_store.load_pending_cap_rewrite()?;
530        let shared = shared_session(&events_path, session_id, mode)?;
531        let log = Self {
532            events_path: events_path.clone(),
533            manifest_store,
534            shared,
535            max_events,
536            eviction_summary_hook,
537        };
538        let _eviction_guard = log.shared.eviction_lock.lock().map_err(poison)?;
539        // Try the fast path: read the persisted manifest + index and skip
540        // the O(n) scan when they are present and consistent.
541        if !log.shared.initialized.load(Ordering::Acquire) {
542            let manifest_opt = log.manifest_store.load_manifest()?;
543            let index_opt = log.manifest_store.load_turn_index()?;
544            let file_len = log.event_file_metadata_len()?;
545            let needs_separator = if file_len == 0 {
546                false
547            } else {
548                let mut slot = log.shared.file.lock().map_err(poison)?;
549                let file = slot.as_mut().ok_or_else(|| log.event_file_unavailable())?;
550                file.seek(SeekFrom::End(-1))
551                    .map_err(|error| SessionStoreError::io(&events_path, error))?;
552                let mut last = [0];
553                file.read_exact(&mut last)
554                    .map_err(|error| SessionStoreError::io(&events_path, error))?;
555                last[0] != b'\n'
556            };
557            let pending_rewrite_matches_file = pending_rewrite.as_ref().is_some_and(|pending| {
558                pending.new_file_len == file_len && pending.new_file_len < pending.previous_file_len
559            });
560            match (&manifest_opt, &index_opt) {
561                (Some(manifest), Some(index))
562                    if !pending_rewrite_matches_file
563                        && manifest.in_turn.is_some()
564                        && manifest.persisted_file_len == Some(file_len)
565                        && index.is_valid_for_file(file_len)
566                        && index.is_consistent_with_manifest(manifest) =>
567                {
568                    let mut st = log.shared.state.lock().map_err(poison)?;
569                    st.in_turn = manifest.in_turn.unwrap_or(false);
570                    st.manifest = manifest.clone();
571                    st.index = index.clone();
572                    st.next_offset = file_len;
573                }
574                _ => {
575                    let scan_turn_base = infer_scan_turn_base(
576                        manifest_opt.as_ref(),
577                        index_opt.as_ref(),
578                        pending_rewrite.as_ref().filter(|_| pending_rewrite_matches_file),
579                        file_len,
580                    );
581                    {
582                        let mut st = log.shared.state.lock().map_err(poison)?;
583                        if let Some(previous) = manifest_opt.as_ref() {
584                            st.manifest = previous.clone();
585                        }
586                        // The canonical event file is authoritative after any
587                        // stale/corrupt metadata. Preserve the ordinal of the
588                        // first retained turn while rebuilding all counters.
589                        st.manifest.turn_count = scan_turn_base.saturating_sub(1);
590                        st.manifest.event_count = 0;
591                        st.manifest.status = "active".to_string();
592                        st.manifest.in_turn = Some(false);
593                        st.manifest.retained_turn_base = Some(scan_turn_base);
594                        st.index = TurnIndex::default();
595                        st.in_turn = false;
596                    }
597                    log.scan()?;
598                    let mut st = log.shared.state.lock().map_err(poison)?;
599                    st.next_offset = file_len;
600                    // A prior writer may have stopped after writing bytes but
601                    // before syncing them. Sync the scanned file before its
602                    // rebuilt metadata can claim those bytes are durable.
603                    st.needs_sync = file_len != 0;
604                    log.persist_meta_locked(&mut st)?;
605                }
606            }
607            if pending_rewrite.is_some() {
608                // A marker whose file length did not match either side of the
609                // rewrite is stale, while a matching marker has now been
610                // incorporated into the repaired metadata. In both cases it
611                // is safe to remove it after the open path has persisted the
612                // authoritative state.
613                log.manifest_store.clear_pending_cap_rewrite()?;
614            }
615            log.shared.state.lock().map_err(poison)?.needs_separator = needs_separator;
616            log.shared.initialized.store(true, Ordering::Release);
617        }
618        drop(_eviction_guard);
619        Ok(log)
620    }
621
622    /// Reconstruct every event belonging to `turn`.
623    pub(crate) fn reconstruct_turn(&self, turn: u64) -> Result<Vec<ThreadEvent>, SessionStoreError> {
624        // Keep the index snapshot and byte-range read together with cap
625        // rewriting. Otherwise an eviction can replace the file between these
626        // steps and leave the snapshot offsets pointing into unrelated events.
627        let _eviction_guard = self.shared.eviction_lock.lock().map_err(poison)?;
628        let entry = {
629            let st = self.shared.state.lock().map_err(poison)?;
630            st.index
631                .entries
632                .iter()
633                .find(|e| e.turn_number == turn)
634                .cloned()
635                .ok_or(SessionStoreError::TurnNotFound { session: st.manifest.session_id.clone(), turn })?
636        };
637        {
638            let mut st = self.shared.state.lock().map_err(poison)?;
639            self.flush_write_buf_locked(&mut st)?;
640        }
641        let buf = {
642            let mut file_slot = self.shared.file.lock().map_err(poison)?;
643            let file = file_slot.as_mut().ok_or_else(|| self.event_file_unavailable())?;
644            file.seek(SeekFrom::Start(entry.start_offset))
645                .map_err(|e| SessionStoreError::io(&self.events_path, e))?;
646            let len = usize::try_from(entry.end_offset.checked_sub(entry.start_offset).ok_or_else(|| {
647                SessionStoreError::io(
648                    &self.events_path,
649                    std::io::Error::new(std::io::ErrorKind::InvalidData, "turn index offsets are out of order"),
650                )
651            })?)
652            .map_err(|error| {
653                SessionStoreError::io(&self.events_path, std::io::Error::new(std::io::ErrorKind::InvalidData, error))
654            })?;
655            let mut buf = vec![0u8; len];
656            file.read_exact(&mut buf)
657                .map_err(|e| SessionStoreError::io(&self.events_path, e))?;
658            buf
659        };
660        let text = String::from_utf8_lossy(&buf);
661        let mut events = Vec::new();
662        for line in text.lines() {
663            let line = line.trim();
664            if line.is_empty() {
665                continue;
666            }
667            // The index scan only validates the event envelope (plus the
668            // lifecycle shape) so it can rebuild cheaply. A line accepted by
669            // the scan can therefore still fail full decoding here; skip it
670            // instead of failing the whole reconstruction (revert, compaction,
671            // and analytics must not break on a single malformed record).
672            let v: VersionedThreadEvent = match serde_json::from_str(line) {
673                Ok(v) => v,
674                Err(_) => continue,
675            };
676            events.push(v.into_event());
677        }
678        Ok(events)
679    }
680
681    /// Number of turns recorded.
682    #[must_use]
683    pub(crate) fn turn_count(&self) -> u64 {
684        self.shared.state.lock().map_err(poison).map_or(0, |s| s.manifest.turn_count)
685    }
686
687    /// Number of events recorded.
688    #[must_use]
689    pub fn event_count(&self) -> u64 {
690        self.shared.state.lock().map_err(poison).map_or(0, |s| s.manifest.event_count)
691    }
692
693    /// Flush pending event bytes and metadata to the session store.
694    /// Visit one consistent retained range, including records outside turns.
695    /// Reads are streamed while cap rewrites and appends are excluded.
696    pub fn visit_snapshot<F>(&self, mut visitor: F) -> Result<SessionManifest, SessionStoreError>
697    where
698        F: FnMut(u64, &[u8]),
699    {
700        use std::io::{BufRead, BufReader};
701        let _eviction_guard = self.shared.eviction_lock.lock().map_err(poison)?;
702        let mut st = self.shared.state.lock().map_err(poison)?;
703        self.persist_meta_locked(&mut st)?;
704        let mut file_slot = self.shared.file.lock().map_err(poison)?;
705        let file = file_slot.as_mut().ok_or_else(|| self.event_file_unavailable())?;
706        file.seek(SeekFrom::Start(0))
707            .map_err(|e| SessionStoreError::io(&self.events_path, e))?;
708        let mut reader = BufReader::new(file.take(st.next_offset));
709        let mut offset = 0;
710        let mut line = Vec::new();
711        loop {
712            line.clear();
713            let length = reader
714                .read_until(b'\n', &mut line)
715                .map_err(|e| SessionStoreError::io(&self.events_path, e))?;
716            if length == 0 {
717                break;
718            }
719            visitor(offset, &line);
720            offset += length as u64;
721        }
722        Ok(st.manifest.clone())
723    }
724
725    /// Flush pending event bytes and metadata to the session store.
726    pub fn flush(&self) -> Result<(), SessionStoreError> {
727        let _eviction_guard = self.shared.eviction_lock.lock().map_err(poison)?;
728        let mut st = self.shared.state.lock().map_err(poison)?;
729        self.persist_meta_locked(&mut st)
730    }
731
732    /// Snapshot of the session manifest.
733    #[must_use]
734    pub fn manifest(&self) -> SessionManifest {
735        self.shared
736            .state
737            .lock()
738            .map_err(poison)
739            .map(|s| s.manifest.clone())
740            .unwrap_or_else(|_| SessionManifest::new(""))
741    }
742
743    /// Snapshot of the turn index.
744    #[must_use]
745    pub fn turn_index(&self) -> TurnIndex {
746        self.shared
747            .state
748            .lock()
749            .map_err(poison)
750            .map(|s| s.index.clone())
751            .unwrap_or_default()
752    }
753
754    /// Flush metadata for callers that explicitly close a log handle.
755    ///
756    /// Terminal status is intentionally controlled only by a persisted
757    /// `thread.completed` event. This method does not synthesize lifecycle
758    /// state for callers that merely release a store handle.
759    pub(crate) fn complete(&self) -> Result<(), SessionStoreError> {
760        let _eviction_guard = self.shared.eviction_lock.lock().map_err(poison)?;
761        let mut st = self.shared.state.lock().map_err(poison)?;
762        st.manifest.updated_at = now_rfc3339();
763        self.persist_meta_locked(&mut st)
764    }
765
766    /// Rebuild index + manifest by scanning `events.jsonl` (authoritative).
767    ///
768    /// Reads the file line-by-line via `BufReader` to avoid loading the entire
769    /// log into memory. Long-lived sessions can otherwise produce multi-megabyte
770    /// logs that spike memory on every reopen.
771    fn scan(&self) -> Result<(), SessionStoreError> {
772        let mut st = self.shared.state.lock().map_err(poison)?;
773        let file = self
774            .shared
775            .file
776            .lock()
777            .map_err(poison)?
778            .as_ref()
779            .ok_or_else(|| self.event_file_unavailable())?
780            .try_clone()
781            .map_err(|e| SessionStoreError::io(&self.events_path, e))?;
782        let mut reader = std::io::BufReader::new(file);
783        reader
784            .seek(SeekFrom::Start(0))
785            .map_err(|e| SessionStoreError::io(&self.events_path, e))?;
786        let mut buf = Vec::new();
787        let mut pos = 0u64;
788        let mut first_ts: Option<String> = None;
789        loop {
790            buf.clear();
791            let n = reader
792                .read_until(b'\n', &mut buf)
793                .map_err(|e| SessionStoreError::io(&self.events_path, e))?;
794            if n == 0 {
795                break;
796            }
797            let line_end = pos + n as u64;
798            let trimmed = std::str::from_utf8(&buf).unwrap_or("").trim();
799            if !trimmed.is_empty()
800                && let Ok(v) = serde_json::from_str::<VersionedEventKind<'_>>(trimmed)
801            {
802                let kind = v.event.kind;
803                if requires_full_lifecycle_validation(kind) && !valid_lifecycle_payload(kind, trimmed) {
804                    pos = line_end;
805                    continue;
806                }
807                st.manifest.event_count += 1;
808                // `thread.started` is not part of the turn lifecycle — it
809                // only seeds `created_at` on the first occurrence.
810                if kind == "thread.started" && first_ts.is_none() {
811                    first_ts = Some(now_rfc3339());
812                }
813                // Route turn-lifecycle events through the same state machine
814                // as `append`, eliminating a previously duplicated match block.
815                st.apply_lifecycle_event(LifecycleKind::from_kind(kind), pos, line_end);
816            }
817            pos = line_end;
818        }
819        // Keep the open-turn state reconstructed from the canonical event log.
820        // This lets a reopened session continue a turn that was flushed before
821        // its completion event was written.
822        st.manifest.in_turn = Some(st.in_turn);
823        if let Some(ts) = first_ts
824            && st.manifest.created_at.is_empty()
825        {
826            st.manifest.created_at = ts;
827        }
828        Ok(())
829    }
830
831    fn persist_meta_locked(&self, st: &mut LogState) -> Result<(), SessionStoreError> {
832        self.flush_write_buf_locked(st)?;
833        // The manifest is only eligible for the fast reopen path when it
834        // describes the complete on-disk event file. Drop intentionally
835        // flushes bytes without metadata, so a length mismatch safely forces
836        // the authoritative scan on the next open.
837        st.manifest.persisted_file_len = Some(st.next_offset);
838        // Publish the derived index first. If a process stops between these
839        // two atomic renames, the older manifest still carries a stale file
840        // length and forces a scan instead of allowing the new manifest to
841        // pair with an older, apparently valid index.
842        self.manifest_store.write_turn_index(&st.index)?;
843        self.manifest_store.write_manifest(&st.manifest)?;
844        Ok(())
845    }
846
847    /// Flush the in-memory write buffer to the underlying file.
848    fn flush_write_buf_locked(&self, st: &mut LogState) -> Result<(), SessionStoreError> {
849        self.flush_write_buf_with_sync_locked(st, File::sync_data)
850    }
851
852    fn flush_write_buf_with_sync_locked(
853        &self,
854        st: &mut LogState,
855        sync: impl FnOnce(&File) -> std::io::Result<()>,
856    ) -> Result<(), SessionStoreError> {
857        self.flush_write_buf_with_ops_locked(st, |file, bytes| file.write_all(bytes), File::set_len, sync)
858    }
859
860    fn flush_write_buf_with_ops_locked(
861        &self,
862        st: &mut LogState,
863        write: impl FnOnce(&mut File, &[u8]) -> std::io::Result<()>,
864        rollback: impl FnOnce(&File, u64) -> std::io::Result<()>,
865        sync: impl FnOnce(&File) -> std::io::Result<()>,
866    ) -> Result<(), SessionStoreError> {
867        if st.write_failed {
868            return Err(self.event_write_failed());
869        }
870        if st.write_buf.is_empty() && !st.needs_sync {
871            return Ok(());
872        }
873        let mut file_slot = self.shared.file.lock().map_err(poison)?;
874        let file = file_slot.as_mut().ok_or_else(|| self.event_file_unavailable())?;
875        if !st.write_buf.is_empty() {
876            let previous_len = file.metadata().map_err(|e| SessionStoreError::io(&self.events_path, e))?.len();
877            if let Err(error) = write(file, &st.write_buf) {
878                if let Err(rollback_error) = rollback(file, previous_len) {
879                    st.write_buf.clear();
880                    st.write_failed = true;
881                    return Err(SessionStoreError::io(
882                        &self.events_path,
883                        std::io::Error::other(format!(
884                            "event write failed: {error}; rollback failed: {rollback_error}"
885                        )),
886                    ));
887                }
888                return Err(SessionStoreError::io(&self.events_path, error));
889            }
890            st.write_buf.clear();
891            st.needs_sync = true;
892        }
893        sync(file).map_err(|error| SessionStoreError::io(&self.events_path, error))?;
894        st.needs_sync = false;
895        Ok(())
896    }
897
898    fn event_file_metadata_len(&self) -> Result<u64, SessionStoreError> {
899        let file_slot = self.shared.file.lock().map_err(poison)?;
900        file_slot
901            .as_ref()
902            .ok_or_else(|| self.event_file_unavailable())?
903            .metadata()
904            .map(|metadata| metadata.len())
905            .map_err(|error| SessionStoreError::io(&self.events_path, error))
906    }
907
908    fn open_event_file(&self) -> Result<File, SessionStoreError> {
909        VtCodePaths::open_private_append_file(&self.events_path)
910            .map_err(|error| SessionStoreError::io(&self.events_path, std::io::Error::other(error)))
911    }
912
913    fn replace_event_file_contents(&self, contents: &[u8]) -> Result<u64, SessionStoreError> {
914        let old_file = {
915            let mut file_slot = self.shared.file.lock().map_err(poison)?;
916            file_slot.take().ok_or_else(|| self.event_file_unavailable())?
917        };
918        drop(old_file);
919
920        if let Err(error) = VtCodePaths::write_private_file_atomic(&self.events_path, contents)
921            .map_err(|error| SessionStoreError::io(&self.events_path, std::io::Error::other(error)))
922        {
923            let restored = self.open_event_file();
924            if let Ok(file) = restored {
925                let mut file_slot = self.shared.file.lock().map_err(poison)?;
926                *file_slot = Some(file);
927                return Err(error);
928            }
929            return Err(SessionStoreError::io(
930                &self.events_path,
931                std::io::Error::other(format!("{error}; failed to restore event log handle")),
932            ));
933        }
934
935        let replacement = self.open_event_file()?;
936        let next_offset = replacement
937            .metadata()
938            .map_err(|error| SessionStoreError::io(&self.events_path, error))?
939            .len();
940        let mut file_slot = self.shared.file.lock().map_err(poison)?;
941        *file_slot = Some(replacement);
942        Ok(next_offset)
943    }
944
945    fn event_file_unavailable(&self) -> SessionStoreError {
946        SessionStoreError::io(&self.events_path, std::io::Error::other("event log file is unavailable"))
947    }
948
949    fn event_write_failed(&self) -> SessionStoreError {
950        SessionStoreError::io(
951            &self.events_path,
952            std::io::Error::other("event log write rollback failed; reopen required"),
953        )
954    }
955}
956
957impl Drop for SessionEventLog {
958    fn drop(&mut self) {
959        if let Ok(_eviction_guard) = self.shared.eviction_lock.lock()
960            && let Ok(mut st) = self.shared.state.lock()
961        {
962            // The fallible `flush` method is the authoritative shutdown path;
963            // Drop only provides a best-effort byte flush for callers that do
964            // not explicitly close the log. Rewriting metadata here could
965            // overwrite a manifest update made by another owner after the
966            // last append.
967            let _ = self.flush_write_buf_locked(&mut st);
968        }
969    }
970}
971
972/// Locate the next newline at or after `from`, returning a past-the-end index.
973fn poison<T>(_e: std::sync::PoisonError<T>) -> SessionStoreError {
974    SessionStoreError::Io {
975        path: PathBuf::new(),
976        source: std::io::Error::other("session store lock poisoned"),
977    }
978}
979
980fn now_rfc3339() -> String {
981    Utc::now().to_rfc3339()
982}
983
984#[cfg(test)]
985mod borrowed_envelope_tests;
986#[cfg(test)]
987mod cap_eviction_tests;
988#[cfg(test)]
989mod lifecycle_state_machine_tests;