1use std::fs::{File, OpenOptions};
4use std::io::{BufRead, Read, Seek, SeekFrom, Write};
5use std::path::{Path, PathBuf};
6use std::sync::Mutex;
7
8use chrono::Utc;
9use serde::{Deserialize, Serialize};
10use vtcode_exec_events::{ThreadEvent, VersionedThreadEvent};
11
12use crate::error::SessionStoreError;
13use crate::session_dir;
14
15pub const DEFAULT_MAX_EVENTS: usize = 10_000;
18
19struct LogState {
22 manifest: SessionManifest,
23 index: TurnIndex,
24 in_turn: bool,
28 next_offset: u64,
32}
33
34impl LogState {
35 fn new(session_id: &str) -> Self {
36 Self {
37 manifest: SessionManifest::new(session_id),
38 index: TurnIndex::default(),
39 in_turn: false,
40 next_offset: 0,
41 }
42 }
43}
44
45pub struct SessionEventLog {
51 session_dir: PathBuf,
52 events_path: PathBuf,
53 file: Mutex<File>,
54 state: Mutex<LogState>,
55 max_events: usize,
56}
57
58impl SessionEventLog {
59 pub fn open(
62 workspace: &Path,
63 session_id: &str,
64 max_events: usize,
65 ) -> Result<Self, SessionStoreError> {
66 let dir = session_dir(workspace, session_id);
67 std::fs::create_dir_all(dir.join(crate::DERIVED_DIR))
68 .map_err(|e| SessionStoreError::CreateDir { path: dir.clone(), source: e })?;
69 std::fs::create_dir_all(dir.join("index"))
70 .map_err(|e| SessionStoreError::CreateDir { path: dir.clone(), source: e })?;
71 let events_path = dir.join("events.jsonl");
72 let file = OpenOptions::new()
73 .create(true)
74 .read(true)
75 .append(true)
76 .open(&events_path)
77 .map_err(|e| SessionStoreError::io(events_path.clone(), e))?;
78 let log = Self {
79 session_dir: dir,
80 events_path,
81 file: Mutex::new(file),
82 state: Mutex::new(LogState::new(session_id)),
83 max_events,
84 };
85 log.scan()?;
86 {
89 let mut st = log.state.lock().map_err(poison)?;
90 let len = log
91 .file
92 .lock()
93 .map_err(poison)?
94 .metadata()
95 .map_err(|e| SessionStoreError::io(&log.events_path, e))?
96 .len();
97 st.next_offset = len;
98 }
99 Ok(log)
100 }
101
102 pub fn append(&self, event: &ThreadEvent) -> Result<(), SessionStoreError> {
104 let line = serde_json::to_string(&VersionedThreadEvent::new(event.clone()))?;
105 let written = line.len() + 1;
106 let mut st = self.state.lock().map_err(poison)?;
107 let start = st.next_offset;
108 {
109 let mut file = self.file.lock().map_err(poison)?;
110 writeln!(file, "{line}").map_err(|e| SessionStoreError::io(&self.events_path, e))?;
111 }
112 let end = start + written as u64;
113 st.next_offset = end;
114
115 st.manifest.event_count += 1;
116 st.manifest.updated_at = now_rfc3339();
117 match event {
118 ThreadEvent::TurnStarted(_) => {
119 st.in_turn = true;
120 let n = st.manifest.turn_count + 1;
121 st.index.entries.push(TurnIndexEntry {
122 turn_number: n,
123 start_offset: start,
124 end_offset: end,
125 event_count: 1,
126 ts: now_rfc3339(),
127 });
128 }
129 ThreadEvent::TurnCompleted(_) => {
130 if st.in_turn {
131 if let Some(entry) = st.index.entries.last_mut() {
132 entry.end_offset = end;
133 entry.event_count += 1;
134 }
135 st.in_turn = false;
136 st.manifest.turn_count = st.index.entries.len() as u64;
137 }
138 st.manifest.status = "completed".to_string();
139 self.persist_meta_locked(&st)?;
140 }
141 ThreadEvent::TurnFailed(_) => {
142 if st.in_turn {
143 if let Some(entry) = st.index.entries.last_mut() {
144 entry.end_offset = end;
145 entry.event_count += 1;
146 }
147 st.in_turn = false;
148 st.manifest.turn_count = st.index.entries.len() as u64;
149 }
150 st.manifest.status = "failed".to_string();
151 self.persist_meta_locked(&st)?;
152 }
153 _ => {
154 if st.in_turn
155 && let Some(entry) = st.index.entries.last_mut()
156 {
157 entry.end_offset = end;
158 entry.event_count += 1;
159 }
160 }
161 }
162 drop(st);
163 let _ = self.enforce_event_cap();
164 Ok(())
165 }
166
167 fn enforce_event_cap(&self) -> Result<(), SessionStoreError> {
171 if self.max_events == 0 {
172 return Ok(());
173 }
174 let mut st = self.state.lock().map_err(poison)?;
175 if st.manifest.event_count <= self.max_events as u64 {
176 return Ok(());
177 }
178 while st.manifest.event_count > self.max_events as u64
179 && let Some(oldest) = st.index.entries.first()
180 {
181 let truncate_offset = oldest.end_offset;
182 let _evicted = oldest.event_count;
183 {
184 let mut file = self.file.lock().map_err(poison)?;
185 file.seek(SeekFrom::Start(truncate_offset))
186 .map_err(|e| SessionStoreError::io(&self.events_path, e))?;
187 let mut remaining = Vec::new();
188 file.read_to_end(&mut remaining)
189 .map_err(|e| SessionStoreError::io(&self.events_path, e))?;
190 file.set_len(0).map_err(|e| SessionStoreError::io(&self.events_path, e))?;
191 file.seek(SeekFrom::Start(0))
192 .map_err(|e| SessionStoreError::io(&self.events_path, e))?;
193 file.write_all(&remaining)
194 .map_err(|e| SessionStoreError::io(&self.events_path, e))?;
195 file.flush().map_err(|e| SessionStoreError::io(&self.events_path, e))?;
196 }
197 st.index.entries.remove(0);
198 for entry in &mut st.index.entries {
199 entry.start_offset -= truncate_offset;
200 entry.end_offset -= truncate_offset;
201 }
202 st.next_offset -= truncate_offset;
203 st.manifest.event_count = st.index.entries.iter().map(|e| e.event_count).sum();
204 let _ = st.manifest.event_count; }
206 Ok(())
207 }
208
209 pub fn reconstruct_turn(&self, turn: u64) -> Result<Vec<ThreadEvent>, SessionStoreError> {
211 let entry = {
212 let st = self.state.lock().map_err(poison)?;
213 st.index.entries.iter().find(|e| e.turn_number == turn).cloned().ok_or(
214 SessionStoreError::TurnNotFound { session: st.manifest.session_id.clone(), turn },
215 )?
216 };
217 let buf = {
218 let mut file = self.file.lock().map_err(poison)?;
219 file.seek(SeekFrom::Start(entry.start_offset))
220 .map_err(|e| SessionStoreError::io(&self.events_path, e))?;
221 let len = (entry.end_offset - entry.start_offset) as usize;
222 let mut buf = vec![0u8; len];
223 file.read_exact(&mut buf)
224 .map_err(|e| SessionStoreError::io(&self.events_path, e))?;
225 buf
226 };
227 let text = String::from_utf8_lossy(&buf);
228 let mut events = Vec::new();
229 for line in text.lines() {
230 let line = line.trim();
231 if line.is_empty() {
232 continue;
233 }
234 let v: VersionedThreadEvent =
235 serde_json::from_str(line).map_err(SessionStoreError::Json)?;
236 events.push(v.into_event());
237 }
238 Ok(events)
239 }
240
241 #[must_use]
243 pub fn turn_count(&self) -> u64 {
244 self.state.lock().map_err(poison).map_or(0, |s| s.manifest.turn_count)
245 }
246
247 #[must_use]
249 pub fn event_count(&self) -> u64 {
250 self.state.lock().map_err(poison).map_or(0, |s| s.manifest.event_count)
251 }
252
253 #[must_use]
255 pub fn manifest(&self) -> SessionManifest {
256 self.state
257 .lock()
258 .map_err(poison)
259 .map(|s| s.manifest.clone())
260 .unwrap_or_else(|_| SessionManifest::new(""))
261 }
262
263 #[must_use]
265 pub fn turn_index(&self) -> TurnIndex {
266 self.state.lock().map_err(poison).map(|s| s.index.clone()).unwrap_or_default()
267 }
268
269 pub fn complete(&self) -> Result<(), SessionStoreError> {
271 let mut st = self.state.lock().map_err(poison)?;
272 st.manifest.status = "completed".to_string();
273 st.manifest.updated_at = now_rfc3339();
274 self.persist_meta_locked(&st)
275 }
276
277 fn scan(&self) -> Result<(), SessionStoreError> {
283 let mut st = self.state.lock().map_err(poison)?;
284 if !self.events_path.exists() {
285 return Ok(());
286 }
287 let file = File::open(&self.events_path)
288 .map_err(|e| SessionStoreError::io(&self.events_path, e))?;
289 let mut reader = std::io::BufReader::new(file);
290 let mut buf = Vec::new();
291 let mut pos = 0u64;
292 let mut first_ts: Option<String> = None;
293 let mut in_turn = false;
294 loop {
295 buf.clear();
296 let n = reader
297 .read_until(b'\n', &mut buf)
298 .map_err(|e| SessionStoreError::io(&self.events_path, e))?;
299 if n == 0 {
300 break;
301 }
302 let line_end = pos + n as u64;
303 let trimmed = std::str::from_utf8(&buf).unwrap_or("").trim();
304 if !trimmed.is_empty()
305 && let Ok(v) = serde_json::from_str::<VersionedThreadEvent>(trimmed)
306 {
307 let event = v.into_event();
308 st.manifest.event_count += 1;
309 match &event {
310 ThreadEvent::ThreadStarted(_) => {
311 if first_ts.is_none() {
312 first_ts = Some(now_rfc3339());
313 }
314 }
315 ThreadEvent::TurnStarted(_) => {
316 in_turn = true;
317 let n = st.manifest.turn_count + 1;
318 st.index.entries.push(TurnIndexEntry {
319 turn_number: n,
320 start_offset: pos,
321 end_offset: line_end,
322 event_count: 1,
323 ts: now_rfc3339(),
324 });
325 }
326 ThreadEvent::TurnCompleted(_) | ThreadEvent::TurnFailed(_) => {
327 if in_turn {
328 if let Some(entry) = st.index.entries.last_mut() {
329 entry.end_offset = line_end;
330 entry.event_count += 1;
331 }
332 in_turn = false;
333 st.manifest.turn_count = st.index.entries.len() as u64;
334 }
335 match &event {
336 ThreadEvent::TurnCompleted(_) => {
337 st.manifest.status = "completed".to_string();
338 }
339 ThreadEvent::TurnFailed(_) => {
340 st.manifest.status = "failed".to_string();
341 }
342 _ => {}
343 }
344 }
345 _ => {
346 if in_turn && let Some(entry) = st.index.entries.last_mut() {
347 entry.end_offset = line_end;
348 entry.event_count += 1;
349 }
350 }
351 }
352 }
353 pos = line_end;
354 }
355 if let Some(ts) = first_ts
356 && st.manifest.created_at.is_empty()
357 {
358 st.manifest.created_at = ts;
359 }
360 Ok(())
361 }
362
363 fn persist_meta_locked(&self, st: &LogState) -> Result<(), SessionStoreError> {
364 let mpath = self.session_dir.join("manifest.json");
365 let bytes = serde_json::to_string_pretty(&st.manifest)?;
366 std::fs::write(&mpath, bytes).map_err(|e| SessionStoreError::io(mpath, e))?;
367 let ipath = self.session_dir.join("index").join("turns.json");
368 let bytes = serde_json::to_string_pretty(&st.index)?;
369 std::fs::write(&ipath, bytes).map_err(|e| SessionStoreError::io(ipath, e))?;
370 Ok(())
371 }
372}
373
374fn poison<T>(_e: std::sync::PoisonError<T>) -> SessionStoreError {
376 SessionStoreError::Io {
377 path: PathBuf::new(),
378 source: std::io::Error::other("session store lock poisoned"),
379 }
380}
381
382fn now_rfc3339() -> String {
383 Utc::now().to_rfc3339()
384}
385
386#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
388pub struct SessionManifest {
389 pub session_id: String,
391 pub schema_version: u32,
393 pub created_at: String,
395 pub updated_at: String,
397 pub turn_count: u64,
399 pub event_count: u64,
401 pub status: String,
403}
404
405impl SessionManifest {
406 #[must_use]
408 pub fn new(session_id: &str) -> Self {
409 let ts = now_rfc3339();
410 Self {
411 session_id: session_id.to_string(),
412 schema_version: crate::SESSION_STORE_SCHEMA_VERSION,
413 created_at: ts.clone(),
414 updated_at: ts,
415 turn_count: 0,
416 event_count: 0,
417 status: "active".to_string(),
418 }
419 }
420}
421
422#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
424pub struct TurnIndexEntry {
425 pub turn_number: u64,
427 pub start_offset: u64,
429 pub end_offset: u64,
431 pub event_count: u64,
433 pub ts: String,
435}
436
437#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
439pub struct TurnIndex {
440 pub entries: Vec<TurnIndexEntry>,
442}
443
444impl TurnIndex {
445 #[must_use]
447 pub fn len(&self) -> usize {
448 self.entries.len()
449 }
450
451 #[must_use]
453 pub fn is_empty(&self) -> bool {
454 self.entries.is_empty()
455 }
456}