1use std::collections::VecDeque;
4use std::fs::{File, OpenOptions};
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_exec_events::{ThreadEvent, VersionedThreadEvent};
12
13use crate::error::SessionStoreError;
14use crate::manifest::ManifestStore;
15use crate::session_dir;
16
17pub const DEFAULT_MAX_EVENTS: usize = 10_000;
20
21const MAX_WRITE_BUFFER_BYTES: usize = 64 * 1024;
24
25#[derive(Debug, Deserialize)]
31struct VersionedEventKind<'a> {
32 #[serde(rename = "schema_version", borrow)]
33 _schema_version: &'a str,
34 #[serde(borrow)]
35 event: EventKind<'a>,
36}
37
38#[derive(Debug, Deserialize)]
39struct EventKind<'a> {
40 #[serde(rename = "type", borrow)]
41 kind: &'a str,
42}
43
44struct LogState {
47 manifest: SessionManifest,
48 index: TurnIndex,
49 in_turn: bool,
53 next_offset: u64,
57 write_buf: Vec<u8>,
60}
61
62impl LogState {
63 fn new(session_id: &str) -> Self {
64 Self {
65 manifest: SessionManifest::new(session_id),
66 index: TurnIndex::default(),
67 in_turn: false,
68 next_offset: 0,
69 write_buf: Vec::with_capacity(65536),
70 }
71 }
72}
73
74pub struct SessionEventLog {
80 events_path: PathBuf,
81 manifest_store: ManifestStore,
82 file: Mutex<File>,
83 state: Mutex<LogState>,
84 max_events: usize,
85}
86
87impl SessionEventLog {
88 pub(crate) fn open(workspace: &Path, session_id: &str, max_events: usize) -> Result<Self, SessionStoreError> {
91 let dir = session_dir(workspace, session_id);
92 std::fs::create_dir_all(dir.join(crate::DERIVED_DIR))
93 .map_err(|e| SessionStoreError::CreateDir { path: dir.clone(), source: e })?;
94 std::fs::create_dir_all(dir.join("index"))
95 .map_err(|e| SessionStoreError::CreateDir { path: dir.clone(), source: e })?;
96 let events_path = dir.join("events.jsonl");
97 let file = OpenOptions::new()
98 .create(true)
99 .read(true)
100 .append(true)
101 .open(&events_path)
102 .map_err(|e| SessionStoreError::io(events_path.clone(), e))?;
103 let manifest_store = ManifestStore::new(dir.clone());
104 let log = Self {
105 events_path: events_path.clone(),
106 manifest_store,
107 file: Mutex::new(file),
108 state: Mutex::new(LogState::new(session_id)),
109 max_events,
110 };
111 let manifest_opt = log.manifest_store.load_manifest()?;
114 let index_opt = log.manifest_store.load_turn_index()?;
115 let file_len = std::fs::metadata(&events_path)
116 .map_err(|e| SessionStoreError::io(events_path.clone(), e))?
117 .len();
118 match (manifest_opt, index_opt) {
119 (Some(manifest), Some(index)) => {
120 let mut st = log.state.lock().map_err(poison)?;
121 st.manifest = manifest;
122 st.index = index;
123 st.next_offset = file_len;
124 }
125 _ => {
126 log.scan()?;
127 let mut st = log.state.lock().map_err(poison)?;
128 st.next_offset = file_len;
129 }
130 }
131 Ok(log)
132 }
133
134 pub fn append(&self, event: &ThreadEvent) -> Result<(), SessionStoreError> {
136 let line = serde_json::to_string(&VersionedThreadEvent::new(event.clone()))?;
137 let written = line.len() + 1;
138 let mut st = self.state.lock().map_err(poison)?;
139 let start = st.next_offset;
140 writeln!(&mut st.write_buf, "{line}").map_err(|e| SessionStoreError::io(&self.events_path, e))?;
141 let end = start + written as u64;
142 st.next_offset = end;
143
144 st.manifest.event_count += 1;
145 st.manifest.updated_at = now_rfc3339();
146 match event {
147 ThreadEvent::TurnStarted(_) => {
148 st.in_turn = true;
149 let n = st.manifest.turn_count + 1;
150 st.index.entries.push_back(TurnIndexEntry {
151 turn_number: n,
152 start_offset: start,
153 end_offset: end,
154 event_count: 1,
155 ts: now_rfc3339(),
156 });
157 }
158 ThreadEvent::TurnCompleted(_) => {
159 if st.in_turn {
160 if let Some(entry) = st.index.entries.back_mut() {
161 entry.end_offset = end;
162 entry.event_count += 1;
163 }
164 st.in_turn = false;
165 st.manifest.turn_count = st.index.entries.len() as u64;
166 }
167 st.manifest.status = "completed".to_string();
168 self.persist_meta_locked(&mut st)?;
169 }
170 ThreadEvent::TurnFailed(_) => {
171 if st.in_turn {
172 if let Some(entry) = st.index.entries.back_mut() {
173 entry.end_offset = end;
174 entry.event_count += 1;
175 }
176 st.in_turn = false;
177 st.manifest.turn_count = st.index.entries.len() as u64;
178 }
179 st.manifest.status = "failed".to_string();
180 self.persist_meta_locked(&mut st)?;
181 }
182 _ => {
183 if st.in_turn
184 && let Some(entry) = st.index.entries.back_mut()
185 {
186 entry.end_offset = end;
187 entry.event_count += 1;
188 }
189 }
190 }
191 if st.write_buf.len() >= MAX_WRITE_BUFFER_BYTES {
192 self.persist_meta_locked(&mut st)?;
196 }
197 drop(st);
198 self.enforce_event_cap()
199 }
200
201 fn enforce_event_cap(&self) -> Result<(), SessionStoreError> {
205 if self.max_events == 0 {
206 return Ok(());
207 }
208 let mut st = self.state.lock().map_err(poison)?;
209 if st.manifest.event_count <= self.max_events as u64 {
210 return Ok(());
211 }
212
213 self.flush_write_buf_locked(&mut st)?;
217
218 let _excess = st.manifest.event_count as i64 - self.max_events as i64;
219 let mut evicted_event_count = 0u64;
220 let mut truncate_offset = 0u64;
221
222 while st.manifest.event_count > self.max_events as u64
223 && let Some(oldest) = st.index.entries.front()
224 {
225 truncate_offset = oldest.end_offset;
226 evicted_event_count += oldest.event_count;
227 st.index.entries.pop_front();
228 }
229
230 if truncate_offset == 0 {
231 return Ok(());
232 }
233
234 {
235 let mut file = self.file.lock().map_err(poison)?;
236 file.seek(SeekFrom::Start(truncate_offset))
237 .map_err(|e| SessionStoreError::io(&self.events_path, e))?;
238 let mut remaining = Vec::new();
239 file.read_to_end(&mut remaining)
240 .map_err(|e| SessionStoreError::io(&self.events_path, e))?;
241 file.set_len(0).map_err(|e| SessionStoreError::io(&self.events_path, e))?;
242 file.seek(SeekFrom::Start(0))
243 .map_err(|e| SessionStoreError::io(&self.events_path, e))?;
244 file.write_all(&remaining)
245 .map_err(|e| SessionStoreError::io(&self.events_path, e))?;
246 file.flush().map_err(|e| SessionStoreError::io(&self.events_path, e))?;
247 }
248
249 for entry in &mut st.index.entries {
250 entry.start_offset -= truncate_offset;
251 entry.end_offset -= truncate_offset;
252 }
253 st.next_offset -= truncate_offset;
254 st.manifest.event_count = st.manifest.event_count.saturating_sub(evicted_event_count);
255 self.persist_meta_locked(&mut st)?;
258 Ok(())
259 }
260
261 pub(crate) fn reconstruct_turn(&self, turn: u64) -> Result<Vec<ThreadEvent>, SessionStoreError> {
263 let entry = {
264 let st = self.state.lock().map_err(poison)?;
265 st.index
266 .entries
267 .iter()
268 .find(|e| e.turn_number == turn)
269 .cloned()
270 .ok_or(SessionStoreError::TurnNotFound { session: st.manifest.session_id.clone(), turn })?
271 };
272 {
273 let mut st = self.state.lock().map_err(poison)?;
274 self.flush_write_buf_locked(&mut st)?;
275 }
276 let buf = {
277 let mut file = self.file.lock().map_err(poison)?;
278 file.seek(SeekFrom::Start(entry.start_offset))
279 .map_err(|e| SessionStoreError::io(&self.events_path, e))?;
280 let len = (entry.end_offset - entry.start_offset) as usize;
281 let mut buf = vec![0u8; len];
282 file.read_exact(&mut buf)
283 .map_err(|e| SessionStoreError::io(&self.events_path, e))?;
284 buf
285 };
286 let text = String::from_utf8_lossy(&buf);
287 let mut events = Vec::new();
288 for line in text.lines() {
289 let line = line.trim();
290 if line.is_empty() {
291 continue;
292 }
293 let v: VersionedThreadEvent = match serde_json::from_str(line) {
299 Ok(v) => v,
300 Err(_) => continue,
301 };
302 events.push(v.into_event());
303 }
304 Ok(events)
305 }
306
307 #[must_use]
309 pub(crate) fn turn_count(&self) -> u64 {
310 self.state.lock().map_err(poison).map_or(0, |s| s.manifest.turn_count)
311 }
312
313 #[must_use]
315 pub fn event_count(&self) -> u64 {
316 self.state.lock().map_err(poison).map_or(0, |s| s.manifest.event_count)
317 }
318
319 pub fn flush(&self) -> Result<(), SessionStoreError> {
321 let mut st = self.state.lock().map_err(poison)?;
322 self.persist_meta_locked(&mut st)
323 }
324
325 #[must_use]
327 pub fn manifest(&self) -> SessionManifest {
328 self.state
329 .lock()
330 .map_err(poison)
331 .map(|s| s.manifest.clone())
332 .unwrap_or_else(|_| SessionManifest::new(""))
333 }
334
335 #[must_use]
337 pub fn turn_index(&self) -> TurnIndex {
338 self.state.lock().map_err(poison).map(|s| s.index.clone()).unwrap_or_default()
339 }
340
341 pub(crate) fn complete(&self) -> Result<(), SessionStoreError> {
343 let mut st = self.state.lock().map_err(poison)?;
344 st.manifest.status = "completed".to_string();
345 st.manifest.updated_at = now_rfc3339();
346 self.persist_meta_locked(&mut st)
347 }
348
349 fn scan(&self) -> Result<(), SessionStoreError> {
355 let mut st = self.state.lock().map_err(poison)?;
356 if !self.events_path.exists() {
357 return Ok(());
358 }
359 let file = File::open(&self.events_path).map_err(|e| SessionStoreError::io(&self.events_path, e))?;
360 let mut reader = std::io::BufReader::new(file);
361 let mut buf = Vec::new();
362 let mut pos = 0u64;
363 let mut first_ts: Option<String> = None;
364 let mut in_turn = false;
365 loop {
366 buf.clear();
367 let n = reader
368 .read_until(b'\n', &mut buf)
369 .map_err(|e| SessionStoreError::io(&self.events_path, e))?;
370 if n == 0 {
371 break;
372 }
373 let line_end = pos + n as u64;
374 let trimmed = std::str::from_utf8(&buf).unwrap_or("").trim();
375 if !trimmed.is_empty()
376 && let Ok(v) = serde_json::from_str::<VersionedEventKind<'_>>(trimmed)
377 {
378 let kind = v.event.kind;
379 if requires_full_lifecycle_validation(kind)
380 && serde_json::from_str::<VersionedThreadEvent>(trimmed).is_err()
381 {
382 pos = line_end;
383 continue;
384 }
385 st.manifest.event_count += 1;
386 match kind {
387 "thread.started" => {
388 if first_ts.is_none() {
389 first_ts = Some(now_rfc3339());
390 }
391 }
392 "turn.started" => {
393 in_turn = true;
394 let n = st.manifest.turn_count + 1;
395 st.index.entries.push_back(TurnIndexEntry {
396 turn_number: n,
397 start_offset: pos,
398 end_offset: line_end,
399 event_count: 1,
400 ts: now_rfc3339(),
401 });
402 }
403 "turn.completed" | "turn.failed" => {
404 if in_turn {
405 if let Some(entry) = st.index.entries.back_mut() {
406 entry.end_offset = line_end;
407 entry.event_count += 1;
408 }
409 in_turn = false;
410 st.manifest.turn_count = st.index.entries.len() as u64;
411 }
412 match kind {
413 "turn.completed" => {
414 st.manifest.status = "completed".to_string();
415 }
416 "turn.failed" => {
417 st.manifest.status = "failed".to_string();
418 }
419 _ => {}
420 }
421 }
422 _ => {
423 if in_turn && let Some(entry) = st.index.entries.back_mut() {
424 entry.end_offset = line_end;
425 entry.event_count += 1;
426 }
427 }
428 }
429 }
430 pos = line_end;
431 }
432 if let Some(ts) = first_ts
433 && st.manifest.created_at.is_empty()
434 {
435 st.manifest.created_at = ts;
436 }
437 Ok(())
438 }
439
440 fn persist_meta_locked(&self, st: &mut LogState) -> Result<(), SessionStoreError> {
441 self.flush_write_buf_locked(st)?;
442 self.manifest_store.write_manifest(&st.manifest)?;
443 self.manifest_store.write_turn_index(&st.index)?;
444 Ok(())
445 }
446
447 fn flush_write_buf_locked(&self, st: &mut LogState) -> Result<(), SessionStoreError> {
449 if st.write_buf.is_empty() {
450 return Ok(());
451 }
452 let mut file = self.file.lock().map_err(poison)?;
453 file.write_all(&st.write_buf)
454 .map_err(|e| SessionStoreError::io(&self.events_path, e))?;
455 st.write_buf.clear();
456 Ok(())
457 }
458}
459
460fn requires_full_lifecycle_validation(kind: &str) -> bool {
461 matches!(kind, "thread.started" | "turn.started" | "turn.completed" | "turn.failed")
462}
463
464impl Drop for SessionEventLog {
465 fn drop(&mut self) {
466 if let Ok(mut st) = self.state.lock() {
467 let _ = self.flush_write_buf_locked(&mut st);
473 }
474 }
475}
476
477fn poison<T>(_e: std::sync::PoisonError<T>) -> SessionStoreError {
479 SessionStoreError::Io {
480 path: PathBuf::new(),
481 source: std::io::Error::other("session store lock poisoned"),
482 }
483}
484
485fn now_rfc3339() -> String {
486 Utc::now().to_rfc3339()
487}
488
489#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
491pub struct SessionManifest {
492 pub session_id: String,
494 schema_version: u32,
496 pub created_at: String,
498 pub updated_at: String,
500 pub turn_count: u64,
502 pub event_count: u64,
504 pub status: String,
506}
507
508impl SessionManifest {
509 #[must_use]
511 pub(crate) fn new(session_id: &str) -> Self {
512 let ts = now_rfc3339();
513 Self {
514 session_id: session_id.to_string(),
515 schema_version: crate::SESSION_STORE_SCHEMA_VERSION,
516 created_at: ts.clone(),
517 updated_at: ts,
518 turn_count: 0,
519 event_count: 0,
520 status: "active".to_string(),
521 }
522 }
523}
524
525#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
527pub struct TurnIndexEntry {
528 turn_number: u64,
530 start_offset: u64,
532 end_offset: u64,
534 event_count: u64,
536 ts: String,
538}
539
540#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
542pub struct TurnIndex {
543 entries: VecDeque<TurnIndexEntry>,
545}
546
547impl TurnIndex {
548 #[must_use]
550 pub fn len(&self) -> usize {
551 self.entries.len()
552 }
553
554 #[must_use]
556 pub fn is_empty(&self) -> bool {
557 self.entries.is_empty()
558 }
559}