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
21struct LogState {
24 manifest: SessionManifest,
25 index: TurnIndex,
26 in_turn: bool,
30 next_offset: u64,
34 write_buf: Vec<u8>,
37}
38
39impl LogState {
40 fn new(session_id: &str) -> Self {
41 Self {
42 manifest: SessionManifest::new(session_id),
43 index: TurnIndex::default(),
44 in_turn: false,
45 next_offset: 0,
46 write_buf: Vec::with_capacity(65536),
47 }
48 }
49}
50
51pub struct SessionEventLog {
57 events_path: PathBuf,
58 manifest_store: ManifestStore,
59 file: Mutex<File>,
60 state: Mutex<LogState>,
61 max_events: usize,
62}
63
64impl SessionEventLog {
65 pub fn open(workspace: &Path, session_id: &str, max_events: usize) -> Result<Self, SessionStoreError> {
68 let dir = session_dir(workspace, session_id);
69 std::fs::create_dir_all(dir.join(crate::DERIVED_DIR))
70 .map_err(|e| SessionStoreError::CreateDir { path: dir.clone(), source: e })?;
71 std::fs::create_dir_all(dir.join("index"))
72 .map_err(|e| SessionStoreError::CreateDir { path: dir.clone(), source: e })?;
73 let events_path = dir.join("events.jsonl");
74 let file = OpenOptions::new()
75 .create(true)
76 .read(true)
77 .append(true)
78 .open(&events_path)
79 .map_err(|e| SessionStoreError::io(events_path.clone(), e))?;
80 let manifest_store = ManifestStore::new(dir.clone());
81 let log = Self {
82 events_path: events_path.clone(),
83 manifest_store,
84 file: Mutex::new(file),
85 state: Mutex::new(LogState::new(session_id)),
86 max_events,
87 };
88 let manifest_opt = log.manifest_store.load_manifest()?;
91 let index_opt = log.manifest_store.load_turn_index()?;
92 let file_len = std::fs::metadata(&events_path)
93 .map_err(|e| SessionStoreError::io(events_path.clone(), e))?
94 .len();
95 match (manifest_opt, index_opt) {
96 (Some(manifest), Some(index)) => {
97 let mut st = log.state.lock().map_err(poison)?;
98 st.manifest = manifest;
99 st.index = index;
100 st.next_offset = file_len;
101 }
102 _ => {
103 log.scan()?;
104 let mut st = log.state.lock().map_err(poison)?;
105 st.next_offset = file_len;
106 }
107 }
108 Ok(log)
109 }
110
111 pub fn append(&self, event: &ThreadEvent) -> Result<(), SessionStoreError> {
113 let line = serde_json::to_string(&VersionedThreadEvent::new(event.clone()))?;
114 let written = line.len() + 1;
115 let mut st = self.state.lock().map_err(poison)?;
116 let start = st.next_offset;
117 writeln!(&mut st.write_buf, "{line}").map_err(|e| SessionStoreError::io(&self.events_path, e))?;
118 let end = start + written as u64;
119 st.next_offset = end;
120
121 st.manifest.event_count += 1;
122 st.manifest.updated_at = now_rfc3339();
123 match event {
124 ThreadEvent::TurnStarted(_) => {
125 st.in_turn = true;
126 let n = st.manifest.turn_count + 1;
127 st.index.entries.push_back(TurnIndexEntry {
128 turn_number: n,
129 start_offset: start,
130 end_offset: end,
131 event_count: 1,
132 ts: now_rfc3339(),
133 });
134 }
135 ThreadEvent::TurnCompleted(_) => {
136 if st.in_turn {
137 if let Some(entry) = st.index.entries.back_mut() {
138 entry.end_offset = end;
139 entry.event_count += 1;
140 }
141 st.in_turn = false;
142 st.manifest.turn_count = st.index.entries.len() as u64;
143 }
144 st.manifest.status = "completed".to_string();
145 self.persist_meta_locked(&mut st)?;
146 }
147 ThreadEvent::TurnFailed(_) => {
148 if st.in_turn {
149 if let Some(entry) = st.index.entries.back_mut() {
150 entry.end_offset = end;
151 entry.event_count += 1;
152 }
153 st.in_turn = false;
154 st.manifest.turn_count = st.index.entries.len() as u64;
155 }
156 st.manifest.status = "failed".to_string();
157 self.persist_meta_locked(&mut st)?;
158 }
159 _ => {
160 if st.in_turn
161 && let Some(entry) = st.index.entries.back_mut()
162 {
163 entry.end_offset = end;
164 entry.event_count += 1;
165 }
166 }
167 }
168 drop(st);
169 let _ = self.enforce_event_cap();
170 Ok(())
171 }
172
173 fn enforce_event_cap(&self) -> Result<(), SessionStoreError> {
177 if self.max_events == 0 {
178 return Ok(());
179 }
180 let mut st = self.state.lock().map_err(poison)?;
181 self.flush_write_buf_locked(&mut st)?;
182 if st.manifest.event_count <= self.max_events as u64 {
183 return Ok(());
184 }
185
186 let _excess = st.manifest.event_count as i64 - self.max_events as i64;
187 let mut evicted_event_count = 0u64;
188 let mut truncate_offset = 0u64;
189
190 while st.manifest.event_count > self.max_events as u64
191 && let Some(oldest) = st.index.entries.front()
192 {
193 truncate_offset = oldest.end_offset;
194 evicted_event_count += oldest.event_count;
195 st.index.entries.pop_front();
196 }
197
198 if truncate_offset == 0 {
199 return Ok(());
200 }
201
202 {
203 let mut file = self.file.lock().map_err(poison)?;
204 file.seek(SeekFrom::Start(truncate_offset))
205 .map_err(|e| SessionStoreError::io(&self.events_path, e))?;
206 let mut remaining = Vec::new();
207 file.read_to_end(&mut remaining)
208 .map_err(|e| SessionStoreError::io(&self.events_path, e))?;
209 file.set_len(0).map_err(|e| SessionStoreError::io(&self.events_path, e))?;
210 file.seek(SeekFrom::Start(0))
211 .map_err(|e| SessionStoreError::io(&self.events_path, e))?;
212 file.write_all(&remaining)
213 .map_err(|e| SessionStoreError::io(&self.events_path, e))?;
214 file.flush().map_err(|e| SessionStoreError::io(&self.events_path, e))?;
215 }
216
217 for entry in &mut st.index.entries {
218 entry.start_offset -= truncate_offset;
219 entry.end_offset -= truncate_offset;
220 }
221 st.next_offset -= truncate_offset;
222 st.manifest.event_count = st.manifest.event_count.saturating_sub(evicted_event_count);
223 Ok(())
224 }
225
226 pub fn reconstruct_turn(&self, turn: u64) -> Result<Vec<ThreadEvent>, SessionStoreError> {
228 let entry = {
229 let st = self.state.lock().map_err(poison)?;
230 st.index
231 .entries
232 .iter()
233 .find(|e| e.turn_number == turn)
234 .cloned()
235 .ok_or(SessionStoreError::TurnNotFound { session: st.manifest.session_id.clone(), turn })?
236 };
237 {
238 let mut st = self.state.lock().map_err(poison)?;
239 self.flush_write_buf_locked(&mut st)?;
240 }
241 let buf = {
242 let mut file = self.file.lock().map_err(poison)?;
243 file.seek(SeekFrom::Start(entry.start_offset))
244 .map_err(|e| SessionStoreError::io(&self.events_path, e))?;
245 let len = (entry.end_offset - entry.start_offset) as usize;
246 let mut buf = vec![0u8; len];
247 file.read_exact(&mut buf)
248 .map_err(|e| SessionStoreError::io(&self.events_path, e))?;
249 buf
250 };
251 let text = String::from_utf8_lossy(&buf);
252 let mut events = Vec::new();
253 for line in text.lines() {
254 let line = line.trim();
255 if line.is_empty() {
256 continue;
257 }
258 let v: VersionedThreadEvent = serde_json::from_str(line).map_err(SessionStoreError::Json)?;
259 events.push(v.into_event());
260 }
261 Ok(events)
262 }
263
264 #[must_use]
266 pub fn turn_count(&self) -> u64 {
267 self.state.lock().map_err(poison).map_or(0, |s| s.manifest.turn_count)
268 }
269
270 #[must_use]
272 pub fn event_count(&self) -> u64 {
273 self.state.lock().map_err(poison).map_or(0, |s| s.manifest.event_count)
274 }
275
276 #[must_use]
278 pub fn manifest(&self) -> SessionManifest {
279 self.state
280 .lock()
281 .map_err(poison)
282 .map(|s| s.manifest.clone())
283 .unwrap_or_else(|_| SessionManifest::new(""))
284 }
285
286 #[must_use]
288 pub fn turn_index(&self) -> TurnIndex {
289 self.state.lock().map_err(poison).map(|s| s.index.clone()).unwrap_or_default()
290 }
291
292 pub fn complete(&self) -> Result<(), SessionStoreError> {
294 let mut st = self.state.lock().map_err(poison)?;
295 st.manifest.status = "completed".to_string();
296 st.manifest.updated_at = now_rfc3339();
297 self.persist_meta_locked(&mut st)
298 }
299
300 fn scan(&self) -> Result<(), SessionStoreError> {
306 let mut st = self.state.lock().map_err(poison)?;
307 if !self.events_path.exists() {
308 return Ok(());
309 }
310 let file = File::open(&self.events_path).map_err(|e| SessionStoreError::io(&self.events_path, e))?;
311 let mut reader = std::io::BufReader::new(file);
312 let mut buf = Vec::new();
313 let mut pos = 0u64;
314 let mut first_ts: Option<String> = None;
315 let mut in_turn = false;
316 loop {
317 buf.clear();
318 let n = reader
319 .read_until(b'\n', &mut buf)
320 .map_err(|e| SessionStoreError::io(&self.events_path, e))?;
321 if n == 0 {
322 break;
323 }
324 let line_end = pos + n as u64;
325 let trimmed = std::str::from_utf8(&buf).unwrap_or("").trim();
326 if !trimmed.is_empty()
327 && let Ok(v) = serde_json::from_str::<VersionedThreadEvent>(trimmed)
328 {
329 let event = v.into_event();
330 st.manifest.event_count += 1;
331 match &event {
332 ThreadEvent::ThreadStarted(_) => {
333 if first_ts.is_none() {
334 first_ts = Some(now_rfc3339());
335 }
336 }
337 ThreadEvent::TurnStarted(_) => {
338 in_turn = true;
339 let n = st.manifest.turn_count + 1;
340 st.index.entries.push_back(TurnIndexEntry {
341 turn_number: n,
342 start_offset: pos,
343 end_offset: line_end,
344 event_count: 1,
345 ts: now_rfc3339(),
346 });
347 }
348 ThreadEvent::TurnCompleted(_) | ThreadEvent::TurnFailed(_) => {
349 if in_turn {
350 if let Some(entry) = st.index.entries.back_mut() {
351 entry.end_offset = line_end;
352 entry.event_count += 1;
353 }
354 in_turn = false;
355 st.manifest.turn_count = st.index.entries.len() as u64;
356 }
357 match &event {
358 ThreadEvent::TurnCompleted(_) => {
359 st.manifest.status = "completed".to_string();
360 }
361 ThreadEvent::TurnFailed(_) => {
362 st.manifest.status = "failed".to_string();
363 }
364 _ => {}
365 }
366 }
367 _ => {
368 if in_turn && let Some(entry) = st.index.entries.back_mut() {
369 entry.end_offset = line_end;
370 entry.event_count += 1;
371 }
372 }
373 }
374 }
375 pos = line_end;
376 }
377 if let Some(ts) = first_ts
378 && st.manifest.created_at.is_empty()
379 {
380 st.manifest.created_at = ts;
381 }
382 Ok(())
383 }
384
385 fn persist_meta_locked(&self, st: &mut LogState) -> Result<(), SessionStoreError> {
386 self.flush_write_buf_locked(st)?;
387 self.manifest_store.write_manifest(&st.manifest)?;
388 self.manifest_store.write_turn_index(&st.index)?;
389 Ok(())
390 }
391
392 fn flush_write_buf_locked(&self, st: &mut LogState) -> Result<(), SessionStoreError> {
394 if st.write_buf.is_empty() {
395 return Ok(());
396 }
397 let mut file = self.file.lock().map_err(poison)?;
398 file.write_all(&st.write_buf)
399 .map_err(|e| SessionStoreError::io(&self.events_path, e))?;
400 st.write_buf.clear();
401 Ok(())
402 }
403}
404
405impl Drop for SessionEventLog {
406 fn drop(&mut self) {
407 if let Ok(mut st) = self.state.lock() {
408 if !st.write_buf.is_empty() {
409 if let Ok(mut file) = self.file.lock() {
410 let _ = file.write_all(&st.write_buf);
411 let _ = file.flush();
412 st.write_buf.clear();
413 }
414 }
415 }
416 }
417}
418
419fn poison<T>(_e: std::sync::PoisonError<T>) -> SessionStoreError {
421 SessionStoreError::Io {
422 path: PathBuf::new(),
423 source: std::io::Error::other("session store lock poisoned"),
424 }
425}
426
427fn now_rfc3339() -> String {
428 Utc::now().to_rfc3339()
429}
430
431#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
433pub struct SessionManifest {
434 pub session_id: String,
436 pub schema_version: u32,
438 pub created_at: String,
440 pub updated_at: String,
442 pub turn_count: u64,
444 pub event_count: u64,
446 pub status: String,
448}
449
450impl SessionManifest {
451 #[must_use]
453 pub fn new(session_id: &str) -> Self {
454 let ts = now_rfc3339();
455 Self {
456 session_id: session_id.to_string(),
457 schema_version: crate::SESSION_STORE_SCHEMA_VERSION,
458 created_at: ts.clone(),
459 updated_at: ts,
460 turn_count: 0,
461 event_count: 0,
462 status: "active".to_string(),
463 }
464 }
465}
466
467#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
469pub struct TurnIndexEntry {
470 pub turn_number: u64,
472 pub start_offset: u64,
474 pub end_offset: u64,
476 pub event_count: u64,
478 pub ts: String,
480}
481
482#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
484pub struct TurnIndex {
485 pub entries: VecDeque<TurnIndexEntry>,
487}
488
489impl TurnIndex {
490 #[must_use]
492 pub fn len(&self) -> usize {
493 self.entries.len()
494 }
495
496 #[must_use]
498 pub fn is_empty(&self) -> bool {
499 self.entries.is_empty()
500 }
501}