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::{EVENT_SCHEMA_VERSION, 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
44#[derive(Serialize)]
51struct BorrowedVersionedEvent<'a> {
52 schema_version: &'a str,
53 event: &'a ThreadEvent,
54}
55
56#[derive(Debug, Clone, Copy, PartialEq, Eq)]
61enum LifecycleKind {
62 ThreadStarted,
63 ThreadCompleted,
64 TurnStarted,
65 TurnCompleted,
66 TurnFailed,
67 Other,
68}
69
70impl LifecycleKind {
71 #[inline]
73 fn from_event(event: &ThreadEvent) -> Self {
74 match event {
75 ThreadEvent::ThreadStarted(_) => Self::ThreadStarted,
76 ThreadEvent::ThreadCompleted(_) => Self::ThreadCompleted,
77 ThreadEvent::TurnStarted(_) => Self::TurnStarted,
78 ThreadEvent::TurnCompleted(_) => Self::TurnCompleted,
79 ThreadEvent::TurnFailed(_) => Self::TurnFailed,
80 _ => Self::Other,
81 }
82 }
83
84 #[inline]
86 fn from_kind(kind: &str) -> Self {
87 match kind {
88 "thread.started" => Self::ThreadStarted,
89 "thread.completed" => Self::ThreadCompleted,
90 "turn.started" => Self::TurnStarted,
91 "turn.completed" => Self::TurnCompleted,
92 "turn.failed" => Self::TurnFailed,
93 _ => Self::Other,
94 }
95 }
96}
97
98struct LogState {
101 manifest: SessionManifest,
102 index: TurnIndex,
103 in_turn: bool,
107 next_offset: u64,
111 write_buf: Vec<u8>,
114}
115
116impl LogState {
117 fn new(session_id: &str) -> Self {
118 Self {
119 manifest: SessionManifest::new(session_id),
120 index: TurnIndex::default(),
121 in_turn: false,
122 next_offset: 0,
123 write_buf: Vec::with_capacity(65536),
124 }
125 }
126
127 fn serialize_event(&mut self, event: &ThreadEvent) -> Result<(u64, u64), SessionStoreError> {
136 let start = self.next_offset;
137 let buf_len_before = self.write_buf.len();
138 if let Err(err) = serde_json::to_writer(
139 &mut self.write_buf,
140 &BorrowedVersionedEvent { schema_version: EVENT_SCHEMA_VERSION, event },
141 ) {
142 self.write_buf.truncate(buf_len_before);
143 return Err(err.into());
144 }
145 self.write_buf.push(b'\n');
146 let written = self.write_buf.len() - buf_len_before;
147 let end = start + written as u64;
148 self.next_offset = end;
149 Ok((start, end))
150 }
151
152 fn apply_lifecycle_event(&mut self, kind: LifecycleKind, start: u64, end: u64) -> bool {
165 match kind {
166 LifecycleKind::ThreadStarted => {
167 self.manifest.status = "active".to_string();
168 false
169 }
170 LifecycleKind::ThreadCompleted => {
171 self.manifest.status = "completed".to_string();
172 true
173 }
174 LifecycleKind::TurnStarted => {
175 self.manifest.status = "active".to_string();
176 self.in_turn = true;
177 let n = self.manifest.turn_count + 1;
178 self.index.entries.push_back(TurnIndexEntry {
179 turn_number: n,
180 start_offset: start,
181 end_offset: end,
182 event_count: 1,
183 ts: now_rfc3339(),
184 });
185 false
186 }
187 LifecycleKind::TurnCompleted | LifecycleKind::TurnFailed => {
188 if self.in_turn {
189 if let Some(entry) = self.index.entries.back_mut() {
190 entry.end_offset = end;
191 entry.event_count += 1;
192 }
193 self.in_turn = false;
194 self.manifest.turn_count = self.index.entries.len() as u64;
195 }
196 true
197 }
198 LifecycleKind::Other => {
199 if self.in_turn
200 && let Some(entry) = self.index.entries.back_mut()
201 {
202 entry.end_offset = end;
203 entry.event_count += 1;
204 }
205 false
206 }
207 }
208 }
209
210 fn plan_cap_eviction(&mut self, max_events: usize) -> Option<(u64, u64)> {
218 if max_events == 0 || self.manifest.event_count <= max_events as u64 {
219 return None;
220 }
221 let mut evicted_event_count = 0u64;
222 let mut truncate_offset = 0u64;
223 while self.manifest.event_count - evicted_event_count > max_events as u64
224 && let Some(oldest) = self.index.entries.front()
225 {
226 truncate_offset = oldest.end_offset;
227 evicted_event_count += oldest.event_count;
228 self.index.entries.pop_front();
229 }
230 if truncate_offset == 0 {
231 None
232 } else {
233 Some((truncate_offset, evicted_event_count))
234 }
235 }
236}
237
238pub struct SessionEventLog {
244 events_path: PathBuf,
245 manifest_store: ManifestStore,
246 file: Mutex<File>,
247 state: Mutex<LogState>,
248 max_events: usize,
249}
250
251impl SessionEventLog {
252 pub(crate) fn open(workspace: &Path, session_id: &str, max_events: usize) -> Result<Self, SessionStoreError> {
255 let dir = session_dir(workspace, session_id);
256 std::fs::create_dir_all(dir.join(crate::DERIVED_DIR))
257 .map_err(|e| SessionStoreError::CreateDir { path: dir.clone(), source: e })?;
258 std::fs::create_dir_all(dir.join("index"))
259 .map_err(|e| SessionStoreError::CreateDir { path: dir.clone(), source: e })?;
260 let events_path = dir.join("events.jsonl");
261 let file = OpenOptions::new()
262 .create(true)
263 .read(true)
264 .append(true)
265 .open(&events_path)
266 .map_err(|e| SessionStoreError::io(events_path.clone(), e))?;
267 let manifest_store = ManifestStore::new(dir.clone());
268 let log = Self {
269 events_path: events_path.clone(),
270 manifest_store,
271 file: Mutex::new(file),
272 state: Mutex::new(LogState::new(session_id)),
273 max_events,
274 };
275 let manifest_opt = log.manifest_store.load_manifest()?;
278 let index_opt = log.manifest_store.load_turn_index()?;
279 let file_len = std::fs::metadata(&events_path)
280 .map_err(|e| SessionStoreError::io(events_path.clone(), e))?
281 .len();
282 match (manifest_opt, index_opt) {
283 (Some(manifest), Some(index)) => {
284 let mut st = log.state.lock().map_err(poison)?;
285 st.manifest = manifest;
286 st.index = index;
287 st.next_offset = file_len;
288 }
289 _ => {
290 log.scan()?;
291 let mut st = log.state.lock().map_err(poison)?;
292 st.next_offset = file_len;
293 }
294 }
295 Ok(log)
296 }
297
298 pub fn append(&self, event: &ThreadEvent) -> Result<(), SessionStoreError> {
300 let mut st = self.state.lock().map_err(poison)?;
301
302 let (start, end) = st.serialize_event(event)?;
306
307 st.manifest.event_count += 1;
308 st.manifest.updated_at = now_rfc3339();
309
310 let is_turn_boundary = st.apply_lifecycle_event(LifecycleKind::from_event(event), start, end);
314 if is_turn_boundary {
315 self.persist_meta_locked(&mut st)?;
316 }
317
318 if st.write_buf.len() >= MAX_WRITE_BUFFER_BYTES {
319 self.persist_meta_locked(&mut st)?;
323 }
324 drop(st);
325 self.enforce_event_cap()
326 }
327
328 fn enforce_event_cap(&self) -> Result<(), SessionStoreError> {
332 let mut st = self.state.lock().map_err(poison)?;
333
334 let Some((truncate_offset, evicted_event_count)) = st.plan_cap_eviction(self.max_events) else {
337 return Ok(());
338 };
339
340 self.flush_write_buf_locked(&mut st)?;
344
345 {
346 let mut file = self.file.lock().map_err(poison)?;
347 file.seek(SeekFrom::Start(truncate_offset))
348 .map_err(|e| SessionStoreError::io(&self.events_path, e))?;
349 let mut remaining = Vec::new();
350 file.read_to_end(&mut remaining)
351 .map_err(|e| SessionStoreError::io(&self.events_path, e))?;
352 file.set_len(0).map_err(|e| SessionStoreError::io(&self.events_path, e))?;
353 file.seek(SeekFrom::Start(0))
354 .map_err(|e| SessionStoreError::io(&self.events_path, e))?;
355 file.write_all(&remaining)
356 .map_err(|e| SessionStoreError::io(&self.events_path, e))?;
357 file.flush().map_err(|e| SessionStoreError::io(&self.events_path, e))?;
358 }
359
360 for entry in &mut st.index.entries {
361 entry.start_offset -= truncate_offset;
362 entry.end_offset -= truncate_offset;
363 }
364 st.next_offset -= truncate_offset;
365 st.manifest.event_count = st.manifest.event_count.saturating_sub(evicted_event_count);
366 self.persist_meta_locked(&mut st)?;
369 Ok(())
370 }
371
372 pub(crate) fn reconstruct_turn(&self, turn: u64) -> Result<Vec<ThreadEvent>, SessionStoreError> {
374 let entry = {
375 let st = self.state.lock().map_err(poison)?;
376 st.index
377 .entries
378 .iter()
379 .find(|e| e.turn_number == turn)
380 .cloned()
381 .ok_or(SessionStoreError::TurnNotFound { session: st.manifest.session_id.clone(), turn })?
382 };
383 {
384 let mut st = self.state.lock().map_err(poison)?;
385 self.flush_write_buf_locked(&mut st)?;
386 }
387 let buf = {
388 let mut file = self.file.lock().map_err(poison)?;
389 file.seek(SeekFrom::Start(entry.start_offset))
390 .map_err(|e| SessionStoreError::io(&self.events_path, e))?;
391 let len = (entry.end_offset - entry.start_offset) as usize;
392 let mut buf = vec![0u8; len];
393 file.read_exact(&mut buf)
394 .map_err(|e| SessionStoreError::io(&self.events_path, e))?;
395 buf
396 };
397 let text = String::from_utf8_lossy(&buf);
398 let mut events = Vec::new();
399 for line in text.lines() {
400 let line = line.trim();
401 if line.is_empty() {
402 continue;
403 }
404 let v: VersionedThreadEvent = match serde_json::from_str(line) {
410 Ok(v) => v,
411 Err(_) => continue,
412 };
413 events.push(v.into_event());
414 }
415 Ok(events)
416 }
417
418 #[must_use]
420 pub(crate) fn turn_count(&self) -> u64 {
421 self.state.lock().map_err(poison).map_or(0, |s| s.manifest.turn_count)
422 }
423
424 #[must_use]
426 pub fn event_count(&self) -> u64 {
427 self.state.lock().map_err(poison).map_or(0, |s| s.manifest.event_count)
428 }
429
430 pub fn flush(&self) -> Result<(), SessionStoreError> {
432 let mut st = self.state.lock().map_err(poison)?;
433 self.persist_meta_locked(&mut st)
434 }
435
436 #[must_use]
438 pub fn manifest(&self) -> SessionManifest {
439 self.state
440 .lock()
441 .map_err(poison)
442 .map(|s| s.manifest.clone())
443 .unwrap_or_else(|_| SessionManifest::new(""))
444 }
445
446 #[must_use]
448 pub fn turn_index(&self) -> TurnIndex {
449 self.state.lock().map_err(poison).map(|s| s.index.clone()).unwrap_or_default()
450 }
451
452 pub(crate) fn complete(&self) -> Result<(), SessionStoreError> {
458 let mut st = self.state.lock().map_err(poison)?;
459 st.manifest.updated_at = now_rfc3339();
460 self.persist_meta_locked(&mut st)
461 }
462
463 fn scan(&self) -> Result<(), SessionStoreError> {
469 let mut st = self.state.lock().map_err(poison)?;
470 if !self.events_path.exists() {
471 return Ok(());
472 }
473 let file = File::open(&self.events_path).map_err(|e| SessionStoreError::io(&self.events_path, e))?;
474 let mut reader = std::io::BufReader::new(file);
475 let mut buf = Vec::new();
476 let mut pos = 0u64;
477 let mut first_ts: Option<String> = None;
478 loop {
479 buf.clear();
480 let n = reader
481 .read_until(b'\n', &mut buf)
482 .map_err(|e| SessionStoreError::io(&self.events_path, e))?;
483 if n == 0 {
484 break;
485 }
486 let line_end = pos + n as u64;
487 let trimmed = std::str::from_utf8(&buf).unwrap_or("").trim();
488 if !trimmed.is_empty()
489 && let Ok(v) = serde_json::from_str::<VersionedEventKind<'_>>(trimmed)
490 {
491 let kind = v.event.kind;
492 if requires_full_lifecycle_validation(kind)
493 && serde_json::from_str::<VersionedThreadEvent>(trimmed).is_err()
494 {
495 pos = line_end;
496 continue;
497 }
498 st.manifest.event_count += 1;
499 if kind == "thread.started" && first_ts.is_none() {
502 first_ts = Some(now_rfc3339());
503 }
504 st.apply_lifecycle_event(LifecycleKind::from_kind(kind), pos, line_end);
507 }
508 pos = line_end;
509 }
510 st.in_turn = false;
515 if let Some(ts) = first_ts
516 && st.manifest.created_at.is_empty()
517 {
518 st.manifest.created_at = ts;
519 }
520 Ok(())
521 }
522
523 fn persist_meta_locked(&self, st: &mut LogState) -> Result<(), SessionStoreError> {
524 self.flush_write_buf_locked(st)?;
525 self.manifest_store.write_manifest(&st.manifest)?;
526 self.manifest_store.write_turn_index(&st.index)?;
527 Ok(())
528 }
529
530 fn flush_write_buf_locked(&self, st: &mut LogState) -> Result<(), SessionStoreError> {
532 if st.write_buf.is_empty() {
533 return Ok(());
534 }
535 let mut file = self.file.lock().map_err(poison)?;
536 file.write_all(&st.write_buf)
537 .map_err(|e| SessionStoreError::io(&self.events_path, e))?;
538 st.write_buf.clear();
539 Ok(())
540 }
541}
542
543fn requires_full_lifecycle_validation(kind: &str) -> bool {
544 matches!(kind, "thread.started" | "thread.completed" | "turn.started" | "turn.completed" | "turn.failed")
545}
546
547impl Drop for SessionEventLog {
548 fn drop(&mut self) {
549 if let Ok(mut st) = self.state.lock() {
550 let _ = self.flush_write_buf_locked(&mut st);
556 }
557 }
558}
559
560fn poison<T>(_e: std::sync::PoisonError<T>) -> SessionStoreError {
562 SessionStoreError::Io {
563 path: PathBuf::new(),
564 source: std::io::Error::other("session store lock poisoned"),
565 }
566}
567
568fn now_rfc3339() -> String {
569 Utc::now().to_rfc3339()
570}
571
572#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
574pub struct SessionManifest {
575 pub session_id: String,
577 schema_version: u32,
579 pub created_at: String,
581 pub updated_at: String,
583 pub turn_count: u64,
585 pub event_count: u64,
587 pub status: String,
589}
590
591impl SessionManifest {
592 #[must_use]
594 pub(crate) fn new(session_id: &str) -> Self {
595 let ts = now_rfc3339();
596 Self {
597 session_id: session_id.to_string(),
598 schema_version: crate::SESSION_STORE_SCHEMA_VERSION,
599 created_at: ts.clone(),
600 updated_at: ts,
601 turn_count: 0,
602 event_count: 0,
603 status: "active".to_string(),
604 }
605 }
606}
607
608#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
610pub struct TurnIndexEntry {
611 turn_number: u64,
613 start_offset: u64,
615 end_offset: u64,
617 event_count: u64,
619 ts: String,
621}
622
623#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
625pub struct TurnIndex {
626 entries: VecDeque<TurnIndexEntry>,
628}
629
630impl TurnIndex {
631 #[must_use]
633 pub fn len(&self) -> usize {
634 self.entries.len()
635 }
636
637 #[must_use]
639 pub fn is_empty(&self) -> bool {
640 self.entries.is_empty()
641 }
642}
643
644#[cfg(test)]
645mod borrowed_envelope_tests {
646 use super::{BorrowedVersionedEvent, EVENT_SCHEMA_VERSION};
647 use vtcode_exec_events::{
648 ThreadEvent, ThreadStartedEvent, TurnCompletedEvent, TurnStartedEvent, Usage, VersionedThreadEvent,
649 };
650
651 #[test]
655 fn borrowed_envelope_matches_versioned_envelope() {
656 for event in [
657 ThreadEvent::ThreadStarted(ThreadStartedEvent { thread_id: "thread".to_string() }),
658 ThreadEvent::TurnStarted(TurnStartedEvent::default()),
659 ThreadEvent::TurnCompleted(TurnCompletedEvent { usage: Usage::default() }),
660 ] {
661 let canonical =
662 serde_json::to_string(&VersionedThreadEvent::new(event.clone())).expect("canonical serialize");
663 let borrowed = serde_json::to_string(&BorrowedVersionedEvent {
664 schema_version: EVENT_SCHEMA_VERSION,
665 event: &event,
666 })
667 .expect("borrowed serialize");
668 assert_eq!(canonical, borrowed, "JSON differs for {event:?}");
669 }
670 }
671}
672
673#[cfg(test)]
674mod lifecycle_state_machine_tests {
675 use super::{LifecycleKind, LogState};
676 use vtcode_exec_events::{
677 ThreadCompletedEvent, ThreadCompletionSubtype, ThreadEvent, ThreadStartedEvent, TurnCompletedEvent,
678 TurnFailedEvent, TurnStartedEvent, Usage,
679 };
680
681 fn fresh_state() -> LogState {
682 LogState::new("test-session")
683 }
684
685 #[test]
686 fn lifecycle_kind_from_event_covers_all_variants() {
687 assert_eq!(
688 LifecycleKind::from_event(&ThreadEvent::TurnStarted(TurnStartedEvent::default())),
689 LifecycleKind::TurnStarted
690 );
691 assert_eq!(
692 LifecycleKind::from_event(&ThreadEvent::TurnCompleted(TurnCompletedEvent { usage: Usage::default() })),
693 LifecycleKind::TurnCompleted
694 );
695 assert_eq!(
696 LifecycleKind::from_event(&ThreadEvent::TurnFailed(TurnFailedEvent {
697 message: "err".to_string(),
698 usage: None,
699 })),
700 LifecycleKind::TurnFailed
701 );
702 assert_eq!(
703 LifecycleKind::from_event(&ThreadEvent::ThreadStarted(ThreadStartedEvent { thread_id: "x".to_string() })),
704 LifecycleKind::ThreadStarted
705 );
706 assert_eq!(
707 LifecycleKind::from_event(&ThreadEvent::ThreadCompleted(ThreadCompletedEvent {
708 thread_id: "x".to_string(),
709 session_id: "x".to_string(),
710 subtype: ThreadCompletionSubtype::Success,
711 outcome_code: "completed".to_string(),
712 result: None,
713 stop_reason: None,
714 usage: Usage::default(),
715 total_cost_usd: None,
716 num_turns: 1,
717 })),
718 LifecycleKind::ThreadCompleted
719 );
720 assert_eq!(LifecycleKind::from_kind("thread.started"), LifecycleKind::ThreadStarted);
721 assert_eq!(LifecycleKind::from_kind("thread.completed"), LifecycleKind::ThreadCompleted);
722 }
723
724 #[test]
725 fn lifecycle_kind_from_str_matches_event_discriminator() {
726 assert_eq!(LifecycleKind::from_kind("turn.started"), LifecycleKind::TurnStarted);
727 assert_eq!(LifecycleKind::from_kind("turn.completed"), LifecycleKind::TurnCompleted);
728 assert_eq!(LifecycleKind::from_kind("turn.failed"), LifecycleKind::TurnFailed);
729 assert_eq!(LifecycleKind::from_kind("tool.called"), LifecycleKind::Other);
730 assert_eq!(LifecycleKind::from_kind("thread.started"), LifecycleKind::ThreadStarted);
731 assert_eq!(LifecycleKind::from_kind("thread.completed"), LifecycleKind::ThreadCompleted);
732 }
733
734 #[test]
735 fn turn_started_pushes_index_entry_and_sets_in_turn() {
736 let mut st = fresh_state();
737 st.manifest.status = "completed".to_string();
738 let is_boundary = st.apply_lifecycle_event(LifecycleKind::TurnStarted, 0, 100);
739 assert!(!is_boundary, "TurnStarted is not a turn boundary");
740 assert!(st.in_turn);
741 assert_eq!(st.manifest.status, "active");
742 assert_eq!(st.index.entries.len(), 1);
743 let entry = &st.index.entries[0];
744 assert_eq!(entry.turn_number, 1);
745 assert_eq!(entry.start_offset, 0);
746 assert_eq!(entry.end_offset, 100);
747 assert_eq!(entry.event_count, 1);
748 }
749
750 #[test]
751 fn intermediate_events_extend_current_turn() {
752 let mut st = fresh_state();
753 st.apply_lifecycle_event(LifecycleKind::TurnStarted, 0, 100);
754 let is_b1 = st.apply_lifecycle_event(LifecycleKind::Other, 100, 200);
756 let is_b2 = st.apply_lifecycle_event(LifecycleKind::Other, 200, 300);
757 assert!(!is_b1 && !is_b2);
758 assert!(st.in_turn);
759 assert_eq!(st.index.entries.len(), 1);
760 let entry = &st.index.entries[0];
761 assert_eq!(entry.end_offset, 300);
762 assert_eq!(entry.event_count, 3);
763 }
764
765 #[test]
766 fn turn_completed_closes_turn_and_returns_boundary() {
767 let mut st = fresh_state();
768 st.apply_lifecycle_event(LifecycleKind::TurnStarted, 0, 100);
769 st.apply_lifecycle_event(LifecycleKind::Other, 100, 200);
770 let is_boundary = st.apply_lifecycle_event(LifecycleKind::TurnCompleted, 200, 300);
771 assert!(is_boundary);
772 assert!(!st.in_turn);
773 assert_eq!(st.manifest.turn_count, 1);
774 assert_eq!(st.manifest.status, "active");
775 st.apply_lifecycle_event(LifecycleKind::ThreadCompleted, 300, 400);
776 assert_eq!(st.manifest.status, "completed");
777 let entry = &st.index.entries[0];
778 assert_eq!(entry.end_offset, 300);
779 assert_eq!(entry.event_count, 3);
780 }
781
782 #[test]
783 fn turn_failed_closes_turn_without_terminal_thread_status() {
784 let mut st = fresh_state();
785 st.apply_lifecycle_event(LifecycleKind::TurnStarted, 0, 100);
786 let is_boundary = st.apply_lifecycle_event(LifecycleKind::TurnFailed, 100, 200);
787 assert!(is_boundary);
788 assert!(!st.in_turn);
789 assert_eq!(st.manifest.turn_count, 1);
790 assert_eq!(st.manifest.status, "active");
791 st.apply_lifecycle_event(LifecycleKind::ThreadCompleted, 200, 300);
792 assert_eq!(st.manifest.status, "completed");
793 }
794
795 #[test]
796 fn turn_completed_without_turn_started_is_idempotent() {
797 let mut st = fresh_state();
798 let is_boundary = st.apply_lifecycle_event(LifecycleKind::TurnCompleted, 0, 100);
802 assert!(is_boundary);
803 assert!(!st.in_turn);
804 assert_eq!(st.manifest.turn_count, 0, "no turn was started");
805 assert_eq!(st.manifest.status, "active");
806 st.apply_lifecycle_event(LifecycleKind::ThreadCompleted, 100, 200);
807 assert_eq!(st.manifest.status, "completed");
808 assert!(st.index.entries.is_empty());
809 }
810
811 #[test]
812 fn multiple_turns_get_incrementing_ordinals() {
813 let mut st = fresh_state();
814 for n in 1..=3 {
815 st.apply_lifecycle_event(LifecycleKind::TurnStarted, n * 100, n * 100 + 50);
816 st.apply_lifecycle_event(LifecycleKind::TurnCompleted, n * 100 + 50, n * 100 + 100);
817 }
818 assert_eq!(st.index.entries.len(), 3);
819 for (i, entry) in st.index.entries.iter().enumerate() {
820 assert_eq!(entry.turn_number, (i + 1) as u64);
821 }
822 assert_eq!(st.manifest.turn_count, 3);
823 }
824}
825
826#[cfg(test)]
827mod cap_eviction_tests {
828 use super::{LogState, TurnIndexEntry};
829
830 fn state_with_turns(turns: usize, events_per_turn: u64) -> LogState {
833 let mut st = LogState::new("cap-test");
834 st.manifest.event_count = (turns as u64) * events_per_turn;
835 let mut offset = 0u64;
836 for n in 1..=turns {
837 st.index.entries.push_back(TurnIndexEntry {
838 turn_number: n as u64,
839 start_offset: offset,
840 end_offset: offset + events_per_turn * 10,
841 event_count: events_per_turn,
842 ts: "2026-01-01T00:00:00Z".to_string(),
843 });
844 offset += events_per_turn * 10;
845 }
846 st
847 }
848
849 #[test]
850 fn no_eviction_when_under_cap() {
851 let mut st = state_with_turns(3, 2); assert!(st.plan_cap_eviction(10).is_none());
853 assert_eq!(st.index.entries.len(), 3, "no turns should be evicted");
854 }
855
856 #[test]
857 fn no_eviction_when_cap_disabled() {
858 let mut st = state_with_turns(5, 2); assert!(st.plan_cap_eviction(0).is_none());
860 assert_eq!(st.index.entries.len(), 5);
861 }
862
863 #[test]
864 fn evicts_oldest_turns_to_meet_cap() {
865 let mut st = state_with_turns(5, 2);
867 let (truncate_offset, evicted) = st.plan_cap_eviction(6).expect("eviction planned");
868 assert_eq!(evicted, 4, "should evict 4 events (2 turns)");
869 assert_eq!(st.index.entries.len(), 3, "should keep 3 turns");
870 assert_eq!(truncate_offset, 40); assert_eq!(st.index.entries[0].turn_number, 3);
874 assert_eq!(st.index.entries[2].turn_number, 5);
875 }
876
877 #[test]
878 fn evicts_all_turns_when_cap_smaller_than_one_turn() {
879 let mut st = state_with_turns(3, 5);
883 let (_truncate_offset, evicted) = st.plan_cap_eviction(3).expect("eviction planned");
884 assert_eq!(evicted, 15, "all events evicted");
885 assert_eq!(st.index.entries.len(), 0);
886 }
887}