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