1use crate::agent::AgentEvent;
7use serde::{Deserialize, Serialize};
8use std::collections::{HashMap, VecDeque};
9use std::sync::Arc;
10use tokio::sync::{Mutex, RwLock};
11use tokio_util::sync::CancellationToken;
12
13#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
14#[serde(rename_all = "snake_case")]
15pub enum RunStatus {
16 Created,
17 Planning,
18 Executing,
19 Verifying,
20 Completed,
21 Failed,
22 Cancelled,
23}
24
25impl RunStatus {
26 pub fn is_terminal(self) -> bool {
27 matches!(self, Self::Completed | Self::Failed | Self::Cancelled)
28 }
29}
30
31#[derive(Debug, Clone, Serialize, Deserialize)]
32pub struct RunEventRecord {
33 pub sequence: usize,
34 pub timestamp_ms: u64,
35 pub event: AgentEvent,
36}
37
38#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
39pub struct ActiveToolSnapshot {
40 pub id: String,
41 pub name: String,
42 pub started_at_ms: u64,
43}
44
45#[derive(Debug, Clone, Serialize, Deserialize)]
46pub struct RunSnapshot {
47 pub id: String,
48 pub session_id: String,
49 pub status: RunStatus,
50 pub prompt: String,
51 pub created_at_ms: u64,
52 pub updated_at_ms: u64,
53 #[serde(skip_serializing_if = "Option::is_none")]
54 pub result_text: Option<String>,
55 #[serde(skip_serializing_if = "Option::is_none")]
56 pub error: Option<String>,
57 pub event_count: usize,
58}
59
60#[derive(Debug, Clone, Serialize, Deserialize)]
61pub struct RunRecord {
62 pub snapshot: RunSnapshot,
63 pub events: Vec<RunEventRecord>,
64}
65
66#[derive(Debug, Clone)]
68pub enum RunReservation {
69 Created(RunSnapshot),
70 Existing(RunSnapshot),
71}
72
73impl RunReservation {
74 pub fn snapshot(&self) -> &RunSnapshot {
75 match self {
76 Self::Created(snapshot) | Self::Existing(snapshot) => snapshot,
77 }
78 }
79
80 pub const fn replayed(&self) -> bool {
81 matches!(self, Self::Existing(_))
82 }
83}
84
85#[derive(Debug, Clone, Serialize, Deserialize)]
87pub struct RunEventPage {
88 pub events: Vec<RunEventRecord>,
89 pub first_available_sequence: Option<usize>,
91 pub latest_sequence_exclusive: usize,
93 pub next_after_sequence: Option<usize>,
95 pub retention_gap: bool,
97 pub has_more: bool,
98}
99
100#[derive(Debug, Default)]
101struct RetainedRunEvents {
102 records: Vec<RunEventRecord>,
103 serialized_bytes: usize,
104}
105
106impl RunSnapshot {
107 fn new(id: String, session_id: String, prompt: String) -> Self {
108 let now = now_ms();
109 Self {
110 id,
111 session_id,
112 status: RunStatus::Created,
113 prompt,
114 created_at_ms: now,
115 updated_at_ms: now,
116 result_text: None,
117 error: None,
118 event_count: 0,
119 }
120 }
121}
122
123#[derive(Debug, Default)]
124pub struct InMemoryRunStore {
125 runs: RwLock<HashMap<String, RunSnapshot>>,
126 events: RwLock<HashMap<String, RetainedRunEvents>>,
127 insertion_order: RwLock<VecDeque<String>>,
130 max_runs: Option<usize>,
133 max_events_per_run: Option<usize>,
138 max_event_bytes_per_run: Option<usize>,
141}
142
143impl InMemoryRunStore {
144 pub fn new() -> Self {
145 Self::default()
146 }
147
148 pub fn with_retention(max_runs: Option<usize>, max_events_per_run: Option<usize>) -> Self {
151 Self::with_retention_limits(max_runs, max_events_per_run, None)
152 }
153
154 pub fn with_retention_limits(
156 max_runs: Option<usize>,
157 max_events_per_run: Option<usize>,
158 max_event_bytes_per_run: Option<usize>,
159 ) -> Self {
160 Self {
161 runs: RwLock::new(HashMap::new()),
162 events: RwLock::new(HashMap::new()),
163 insertion_order: RwLock::new(VecDeque::new()),
164 max_runs,
165 max_events_per_run,
166 max_event_bytes_per_run,
167 }
168 }
169
170 pub async fn create_run(&self, session_id: &str, prompt: &str) -> RunSnapshot {
171 let id = format!("run-{}", uuid::Uuid::new_v4());
175 self.create_run_with_id(id, session_id, prompt).await
176 }
177
178 pub async fn create_run_with_id(
182 &self,
183 id: String,
184 session_id: &str,
185 prompt: &str,
186 ) -> RunSnapshot {
187 let snapshot = RunSnapshot::new(id.clone(), session_id.to_string(), prompt.to_string());
188 {
197 let mut order = self.insertion_order.write().await;
198 let mut events = self.events.write().await;
199 let mut runs = self.runs.write().await;
200 runs.insert(id.clone(), snapshot.clone());
201 events.insert(id.clone(), RetainedRunEvents::default());
202 order.push_back(id);
203 if let Some(cap) = self.max_runs {
204 while order.len() > cap {
205 if let Some(victim) = order.pop_front() {
206 runs.remove(&victim);
207 events.remove(&victim);
208 }
209 }
210 }
211 }
212 snapshot
213 }
214
215 pub async fn reserve_run_with_id(
219 &self,
220 id: String,
221 session_id: &str,
222 prompt: &str,
223 ) -> RunReservation {
224 let mut order = self.insertion_order.write().await;
228 let mut events = self.events.write().await;
229 let mut runs = self.runs.write().await;
230 if let Some(existing) = runs.get(&id) {
231 return RunReservation::Existing(existing.clone());
232 }
233
234 let snapshot = RunSnapshot::new(id.clone(), session_id.to_string(), prompt.to_string());
235 runs.insert(id.clone(), snapshot.clone());
236 events.insert(id.clone(), RetainedRunEvents::default());
237 order.push_back(id);
238 if let Some(cap) = self.max_runs {
239 while order.len() > cap {
240 if let Some(victim) = order.pop_front() {
241 runs.remove(&victim);
242 events.remove(&victim);
243 }
244 }
245 }
246 RunReservation::Created(snapshot)
247 }
248
249 pub async fn record_event(&self, run_id: &str, event: AgentEvent) -> Option<RunSnapshot> {
250 let mut events = self.events.write().await;
251 let mut runs = self.runs.write().await;
252 let run_events = events.get_mut(run_id)?;
253 let run = runs.get_mut(run_id)?;
254
255 let sequence = run.event_count;
261 let record = RunEventRecord {
262 sequence,
263 timestamp_ms: now_ms(),
264 event: event.clone(),
265 };
266 run_events.serialized_bytes = run_events
267 .serialized_bytes
268 .saturating_add(serialized_event_record_len(&record));
269 run_events.records.push(record);
270 trim_retained_events(
271 run_events,
272 self.max_events_per_run,
273 self.max_event_bytes_per_run,
274 );
275 apply_event_to_snapshot(run, &event);
276 run.event_count += 1;
277 run.updated_at_ms = now_ms();
278 Some(run.clone())
279 }
280
281 pub async fn mark_failed(&self, run_id: &str, error: impl Into<String>) -> Option<RunSnapshot> {
282 let mut runs = self.runs.write().await;
283 let run = runs.get_mut(run_id)?;
284 if run.status == RunStatus::Cancelled {
285 return Some(run.clone());
286 }
287 run.status = RunStatus::Failed;
288 run.error = Some(error.into());
289 run.updated_at_ms = now_ms();
290 Some(run.clone())
291 }
292
293 pub async fn mark_cancelled(&self, run_id: &str) -> Option<RunSnapshot> {
294 let mut runs = self.runs.write().await;
295 let run = runs.get_mut(run_id)?;
296 run.status = RunStatus::Cancelled;
297 run.updated_at_ms = now_ms();
298 Some(run.clone())
299 }
300
301 pub async fn snapshot(&self, run_id: &str) -> Option<RunSnapshot> {
302 self.runs.read().await.get(run_id).cloned()
303 }
304
305 pub async fn events(&self, run_id: &str) -> Vec<RunEventRecord> {
306 self.events
307 .read()
308 .await
309 .get(run_id)
310 .map(|events| events.records.clone())
311 .unwrap_or_default()
312 }
313
314 pub async fn event_page(
319 &self,
320 run_id: &str,
321 after_sequence: Option<usize>,
322 limit: usize,
323 ) -> Option<RunEventPage> {
324 let events = self.events.read().await;
326 let runs = self.runs.read().await;
327 let retained = events.get(run_id)?;
328 let run = runs.get(run_id)?;
329 let first_available_sequence = retained.records.first().map(|event| event.sequence);
330 let requested_start = match after_sequence {
331 Some(sequence) => sequence.saturating_add(1),
332 None => 0,
333 };
334 let retention_gap = if requested_start >= run.event_count {
335 false
336 } else {
337 first_available_sequence
338 .map(|first| requested_start < first)
339 .unwrap_or(true)
340 };
341 let mut matching = retained
342 .records
343 .iter()
344 .filter(|event| after_sequence.is_none_or(|cursor| event.sequence > cursor));
345 let page_events = matching.by_ref().take(limit).cloned().collect::<Vec<_>>();
346 let has_more = matching.next().is_some();
347 let next_after_sequence = page_events
348 .last()
349 .map(|event| event.sequence)
350 .or(after_sequence);
351 Some(RunEventPage {
352 events: page_events,
353 first_available_sequence,
354 latest_sequence_exclusive: run.event_count,
355 next_after_sequence,
356 retention_gap,
357 has_more,
358 })
359 }
360
361 pub async fn list(&self) -> Vec<RunSnapshot> {
362 let order = self.insertion_order.read().await;
363 let runs = self.runs.read().await;
364 order
365 .iter()
366 .filter_map(|run_id| runs.get(run_id).cloned())
367 .collect()
368 }
369
370 pub async fn records(&self) -> Vec<RunRecord> {
371 let order = self.insertion_order.read().await;
377 let events = self.events.read().await;
378 let runs = self.runs.read().await;
379 order
380 .iter()
381 .filter_map(|run_id| {
382 let snapshot = runs.get(run_id)?.clone();
383 Some(RunRecord {
384 events: events
385 .get(run_id)
386 .map(|events| events.records.clone())
387 .unwrap_or_default(),
388 snapshot,
389 })
390 })
391 .collect()
392 }
393
394 pub async fn replace_records(&self, records: Vec<RunRecord>) {
395 let mut sorted = records;
399 sorted.sort_by_key(|r| r.snapshot.created_at_ms);
400 if let Some(cap) = self.max_runs {
401 let excess = sorted.len().saturating_sub(cap);
402 if excess > 0 {
403 sorted.drain(..excess);
404 }
405 }
406 let mut run_map = HashMap::new();
407 let mut event_map = HashMap::new();
408 let mut order = VecDeque::with_capacity(sorted.len());
409 for record in sorted {
410 let id = record.snapshot.id.clone();
411 let mut retained = RetainedRunEvents {
419 serialized_bytes: record
420 .events
421 .iter()
422 .map(serialized_event_record_len)
423 .fold(0usize, usize::saturating_add),
424 records: record.events,
425 };
426 trim_retained_events(
427 &mut retained,
428 self.max_events_per_run,
429 self.max_event_bytes_per_run,
430 );
431 event_map.insert(id.clone(), retained);
432 run_map.insert(id.clone(), record.snapshot);
433 order.push_back(id);
434 }
435 let mut stored_order = self.insertion_order.write().await;
439 let mut stored_events = self.events.write().await;
440 let mut stored_runs = self.runs.write().await;
441 *stored_runs = run_map;
442 *stored_events = event_map;
443 *stored_order = order;
444 }
445}
446
447fn serialized_event_record_len(record: &RunEventRecord) -> usize {
448 serde_json::to_vec(record)
449 .map(|encoded| encoded.len())
450 .unwrap_or(usize::MAX)
451}
452
453fn trim_retained_events(
454 events: &mut RetainedRunEvents,
455 max_events: Option<usize>,
456 max_bytes: Option<usize>,
457) {
458 let count_excess = max_events
459 .map(|cap| events.records.len().saturating_sub(cap))
460 .unwrap_or(0);
461 let mut remove_count = count_excess;
462 let mut remaining_bytes = events.serialized_bytes;
463 for record in events.records.iter().take(remove_count) {
464 remaining_bytes = remaining_bytes.saturating_sub(serialized_event_record_len(record));
465 }
466 while max_bytes.is_some_and(|cap| remaining_bytes > cap) && remove_count < events.records.len()
467 {
468 remaining_bytes = remaining_bytes
469 .saturating_sub(serialized_event_record_len(&events.records[remove_count]));
470 remove_count += 1;
471 }
472 for record in events.records.iter().take(remove_count) {
473 events.serialized_bytes = events
474 .serialized_bytes
475 .saturating_sub(serialized_event_record_len(record));
476 }
477 if remove_count > 0 {
478 events.records.drain(..remove_count);
479 }
480}
481
482#[cfg(test)]
483mod retention_tests {
484 use super::*;
485
486 #[tokio::test(flavor = "multi_thread", worker_threads = 4)]
487 async fn exact_run_reservation_is_atomic_and_never_replaces_the_winner() {
488 let store = Arc::new(InMemoryRunStore::new());
489 let mut reservations = Vec::new();
490 for index in 0..32 {
491 let store = Arc::clone(&store);
492 reservations.push(tokio::spawn(async move {
493 store
494 .reserve_run_with_id(
495 "run-cloud-1".to_string(),
496 "session-cloud-1",
497 &format!("prompt-{index}"),
498 )
499 .await
500 }));
501 }
502
503 let mut created = 0;
504 for reservation in reservations {
505 if !reservation.await.unwrap().replayed() {
506 created += 1;
507 }
508 }
509 assert_eq!(created, 1);
510
511 let winner = store.snapshot("run-cloud-1").await.unwrap();
512 let replay = store
513 .reserve_run_with_id(
514 "run-cloud-1".to_string(),
515 "another-session",
516 "replacement prompt",
517 )
518 .await;
519 assert!(replay.replayed());
520 assert_eq!(replay.snapshot().session_id, winner.session_id);
521 assert_eq!(replay.snapshot().prompt, winner.prompt);
522 assert_eq!(store.list().await.len(), 1);
523 }
524
525 #[tokio::test(flavor = "multi_thread", worker_threads = 4)]
526 async fn concurrent_create_and_record_under_cap_does_not_deadlock() {
527 let store = std::sync::Arc::new(InMemoryRunStore::with_retention(Some(10), None));
531 let mut handles = Vec::new();
532 for i in 0..100 {
533 let s = std::sync::Arc::clone(&store);
534 handles.push(tokio::spawn(async move {
535 let r = s.create_run("sess", &format!("p{i}")).await;
536 for _ in 0..5 {
537 s.record_event(
538 &r.id,
539 AgentEvent::TextDelta {
540 text: "x".to_string(),
541 },
542 )
543 .await;
544 }
545 }));
546 }
547 for h in handles {
548 h.await.unwrap();
549 }
550 assert!(store.list().await.len() <= 10);
553 }
554
555 #[tokio::test]
556 async fn replace_records_preserves_cumulative_event_count_after_trim() {
557 let src = InMemoryRunStore::with_retention(None, Some(3));
559 let run = src.create_run("s", "p").await;
560 for _ in 0..10 {
561 src.record_event(
562 &run.id,
563 AgentEvent::TextDelta {
564 text: "x".to_string(),
565 },
566 )
567 .await;
568 }
569 let records = src.records().await;
570 assert_eq!(records.len(), 1);
572 assert_eq!(records[0].events.len(), 3, "buffer trimmed to cap");
573 assert_eq!(records[0].snapshot.event_count, 10, "cumulative preserved");
574
575 let dst = InMemoryRunStore::new();
577 dst.replace_records(records).await;
578 let restored = dst.snapshot(&run.id).await.unwrap();
579 assert_eq!(
580 restored.event_count, 10,
581 "replace_records must NOT reset event_count to the trimmed buffer length"
582 );
583 assert_eq!(dst.events(&run.id).await.len(), 3);
585 }
586
587 #[tokio::test]
588 async fn replace_records_enforces_run_and_event_caps() {
589 let source = InMemoryRunStore::new();
590 for run_index in 0..4 {
591 let run = source
592 .create_run_with_id(
593 format!("run-{run_index}"),
594 "session-1",
595 &format!("prompt-{run_index}"),
596 )
597 .await;
598 for event_index in 0..5 {
599 source
600 .record_event(
601 &run.id,
602 AgentEvent::TextDelta {
603 text: format!("{run_index}:{event_index}"),
604 },
605 )
606 .await;
607 }
608 }
609
610 let restored = InMemoryRunStore::with_retention(Some(2), Some(2));
611 restored.replace_records(source.records().await).await;
612
613 let records = restored.records().await;
614 assert_eq!(
615 records
616 .iter()
617 .map(|record| record.snapshot.id.as_str())
618 .collect::<Vec<_>>(),
619 vec!["run-2", "run-3"],
620 "restore must keep the newest runs under the same FIFO policy as live writes"
621 );
622 for (run_index, record) in records.iter().enumerate() {
623 assert_eq!(record.snapshot.event_count, 5);
624 assert_eq!(record.events.len(), 2);
625 assert_eq!(record.events[0].sequence, 3);
626 assert_eq!(record.events[1].sequence, 4);
627 assert_eq!(record.snapshot.id, format!("run-{}", run_index + 2));
628 }
629 }
630
631 #[tokio::test]
632 async fn replace_records_honors_zero_caps() {
633 let source = InMemoryRunStore::new();
634 let run = source.create_run("session-1", "prompt").await;
635 source
636 .record_event(
637 &run.id,
638 AgentEvent::TextDelta {
639 text: "event".to_string(),
640 },
641 )
642 .await;
643
644 let no_runs = InMemoryRunStore::with_retention(Some(0), Some(0));
645 no_runs.replace_records(source.records().await).await;
646 assert!(no_runs.records().await.is_empty());
647
648 let no_events = InMemoryRunStore::with_retention(None, Some(0));
649 no_events.replace_records(source.records().await).await;
650 let records = no_events.records().await;
651 assert_eq!(records.len(), 1);
652 assert!(records[0].events.is_empty());
653 assert_eq!(records[0].snapshot.event_count, 1);
654 }
655
656 #[tokio::test]
657 async fn max_runs_evicts_oldest() {
658 let store = InMemoryRunStore::with_retention(Some(2), None);
659 let _ = store.create_run("session-1", "prompt-1").await;
660 let r2 = store.create_run("session-1", "prompt-2").await;
661 let r3 = store.create_run("session-1", "prompt-3").await;
662
663 assert_eq!(store.list().await.len(), 2);
665 let ids: Vec<String> = store.list().await.into_iter().map(|r| r.id).collect();
666 assert!(ids.contains(&r2.id));
667 assert!(ids.contains(&r3.id));
668 assert!(store.events(&r2.id).await.is_empty());
669 let surviving_event_count: usize =
671 store.events(&r2.id).await.len() + store.events(&r3.id).await.len();
672 assert_eq!(surviving_event_count, 0);
673 }
674
675 #[tokio::test]
676 async fn max_events_per_run_caps_event_buffer() {
677 let store = InMemoryRunStore::with_retention(None, Some(3));
678 let run = store.create_run("session-1", "prompt").await;
679 for _ in 0..10 {
680 store
681 .record_event(
682 &run.id,
683 AgentEvent::TextDelta {
684 text: "x".to_string(),
685 },
686 )
687 .await;
688 }
689 let events = store.events(&run.id).await;
690 assert_eq!(
691 events.len(),
692 3,
693 "buffer must be capped at max_events_per_run"
694 );
695 let snap = store.snapshot(&run.id).await.unwrap();
698 assert_eq!(snap.event_count, 10);
699 }
700
701 #[tokio::test]
702 async fn max_event_bytes_per_run_drops_oversized_live_event_but_advances_cursor() {
703 let store = InMemoryRunStore::with_retention_limits(None, None, Some(0));
704 let run = store.create_run("session-1", "prompt").await;
705
706 store
707 .record_event(
708 &run.id,
709 AgentEvent::TextDelta {
710 text: "oversized".to_string(),
711 },
712 )
713 .await;
714
715 assert!(store.events(&run.id).await.is_empty());
716 let snapshot = store.snapshot(&run.id).await.unwrap();
717 assert_eq!(snapshot.event_count, 1);
718 }
719
720 #[tokio::test]
721 async fn replace_records_enforces_serialized_event_byte_cap_fifo() {
722 let source = InMemoryRunStore::new();
723 let run = source.create_run("session-1", "prompt").await;
724 for text in ["old", "middle", "new"] {
725 source
726 .record_event(
727 &run.id,
728 AgentEvent::TextDelta {
729 text: text.to_string(),
730 },
731 )
732 .await;
733 }
734 let source_records = source.records().await;
735 let retained_bytes = source_records[0].events[1..]
736 .iter()
737 .map(serialized_event_record_len)
738 .sum();
739
740 let restored = InMemoryRunStore::with_retention_limits(None, None, Some(retained_bytes));
741 restored.replace_records(source_records).await;
742
743 let records = restored.records().await;
744 assert_eq!(records[0].snapshot.event_count, 3);
745 assert_eq!(
746 records[0]
747 .events
748 .iter()
749 .map(|event| event.sequence)
750 .collect::<Vec<_>>(),
751 vec![1, 2]
752 );
753 }
754
755 #[tokio::test]
756 async fn retained_event_sequences_remain_monotonic_after_fifo_trim() {
757 let store = InMemoryRunStore::with_retention(None, Some(3));
758 let run = store.create_run("session-1", "prompt").await;
759
760 for index in 0..10 {
761 store
762 .record_event(
763 &run.id,
764 AgentEvent::TextDelta {
765 text: index.to_string(),
766 },
767 )
768 .await;
769 }
770
771 let sequences = store
772 .events(&run.id)
773 .await
774 .into_iter()
775 .map(|record| record.sequence)
776 .collect::<Vec<_>>();
777 assert_eq!(sequences, vec![7, 8, 9]);
778 assert!(sequences.windows(2).all(|pair| pair[0] < pair[1]));
779 }
780
781 #[tokio::test]
782 async fn restored_run_continues_sequence_from_cumulative_event_count() {
783 let source = InMemoryRunStore::with_retention(None, Some(3));
784 let run = source.create_run("session-1", "prompt").await;
785 for index in 0..10 {
786 source
787 .record_event(
788 &run.id,
789 AgentEvent::TextDelta {
790 text: index.to_string(),
791 },
792 )
793 .await;
794 }
795
796 let restored = InMemoryRunStore::with_retention(None, Some(3));
797 restored.replace_records(source.records().await).await;
798 restored
799 .record_event(
800 &run.id,
801 AgentEvent::TextDelta {
802 text: "after restore".to_string(),
803 },
804 )
805 .await;
806
807 let sequences = restored
808 .events(&run.id)
809 .await
810 .into_iter()
811 .map(|record| record.sequence)
812 .collect::<Vec<_>>();
813 assert_eq!(sequences, vec![8, 9, 10]);
814 assert_eq!(restored.snapshot(&run.id).await.unwrap().event_count, 11);
815 }
816
817 #[tokio::test]
818 async fn event_page_reports_retention_gap_and_paginates_from_cursor() {
819 let store = InMemoryRunStore::with_retention(None, Some(3));
820 let run = store.create_run("session-1", "prompt").await;
821 for index in 0..6 {
822 store
823 .record_event(
824 &run.id,
825 AgentEvent::TextDelta {
826 text: index.to_string(),
827 },
828 )
829 .await;
830 }
831
832 let first = store.event_page(&run.id, None, 2).await.unwrap();
833 assert_eq!(first.first_available_sequence, Some(3));
834 assert_eq!(first.latest_sequence_exclusive, 6);
835 assert!(first.retention_gap);
836 assert!(first.has_more);
837 assert_eq!(first.next_after_sequence, Some(4));
838 assert_eq!(
839 first
840 .events
841 .iter()
842 .map(|event| event.sequence)
843 .collect::<Vec<_>>(),
844 vec![3, 4]
845 );
846
847 let second = store
848 .event_page(&run.id, first.next_after_sequence, 2)
849 .await
850 .unwrap();
851 assert!(!second.retention_gap);
852 assert!(!second.has_more);
853 assert_eq!(second.next_after_sequence, Some(5));
854 assert_eq!(second.events[0].sequence, 5);
855 assert!(store.event_page("missing", None, 10).await.is_none());
856 }
857
858 #[tokio::test]
859 async fn event_page_reports_gap_when_retention_keeps_no_events() {
860 let store = InMemoryRunStore::with_retention(None, Some(0));
861 let run = store.create_run("session-1", "prompt").await;
862 store
863 .record_event(
864 &run.id,
865 AgentEvent::TextDelta {
866 text: "gone".to_string(),
867 },
868 )
869 .await;
870
871 let page = store.event_page(&run.id, None, 10).await.unwrap();
872 assert!(page.events.is_empty());
873 assert_eq!(page.first_available_sequence, None);
874 assert_eq!(page.latest_sequence_exclusive, 1);
875 assert!(page.retention_gap);
876 assert!(!page.has_more);
877 }
878
879 #[tokio::test]
880 async fn unlimited_retention_is_the_default() {
881 let store = InMemoryRunStore::new();
882 for i in 0..50 {
883 let r = store.create_run("s", &format!("p{i}")).await;
884 for _ in 0..20 {
885 store
886 .record_event(
887 &r.id,
888 AgentEvent::TextDelta {
889 text: "y".to_string(),
890 },
891 )
892 .await;
893 }
894 }
895 assert_eq!(store.list().await.len(), 50);
896 }
897}
898
899#[derive(Clone)]
900pub struct RunHandle {
901 id: String,
902 session_id: String,
903 store: Arc<InMemoryRunStore>,
904 cancel_token: Arc<Mutex<Option<CancellationToken>>>,
905 current_run_id: Arc<Mutex<Option<String>>>,
906 hook_executor: Option<Arc<dyn crate::hooks::HookExecutor>>,
907}
908
909impl RunHandle {
910 pub(crate) fn new(
911 id: String,
912 session_id: String,
913 store: Arc<InMemoryRunStore>,
914 cancel_token: Arc<Mutex<Option<CancellationToken>>>,
915 current_run_id: Arc<Mutex<Option<String>>>,
916 hook_executor: Option<Arc<dyn crate::hooks::HookExecutor>>,
917 ) -> Self {
918 Self {
919 id,
920 session_id,
921 store,
922 cancel_token,
923 current_run_id,
924 hook_executor,
925 }
926 }
927
928 pub fn id(&self) -> &str {
929 &self.id
930 }
931
932 pub fn session_id(&self) -> &str {
933 &self.session_id
934 }
935
936 pub async fn snapshot(&self) -> Option<RunSnapshot> {
937 self.store.snapshot(&self.id).await
938 }
939
940 pub async fn events(&self) -> Vec<RunEventRecord> {
941 self.store.events(&self.id).await
942 }
943
944 pub async fn status(&self) -> Option<RunStatus> {
945 self.snapshot().await.map(|snapshot| snapshot.status)
946 }
947
948 pub async fn cancel(&self) -> bool {
949 let current_run_id = self.current_run_id.lock().await.clone();
950 if current_run_id.as_deref() != Some(self.id.as_str()) {
951 return false;
952 }
953
954 let token = self.cancel_token.lock().await.clone();
955 if let Some(token) = token {
956 token.cancel();
957 let _ = self.store.mark_cancelled(&self.id).await;
958 if let Some(executor) = &self.hook_executor {
959 executor
960 .record_run_cancelled(&self.id, &self.session_id, Some("cancelled by host"))
961 .await;
962 }
963 true
964 } else {
965 false
966 }
967 }
968}
969
970fn apply_event_to_snapshot(run: &mut RunSnapshot, event: &AgentEvent) {
971 if run.status.is_terminal() {
975 return;
976 }
977
978 match event {
979 AgentEvent::Start { prompt } => {
980 run.status = RunStatus::Executing;
981 if run.prompt.is_empty() {
982 run.prompt = prompt.clone();
983 }
984 }
985 AgentEvent::PlanningStart { .. } => {
986 run.status = RunStatus::Planning;
987 }
988 AgentEvent::StepStart { .. }
989 | AgentEvent::ToolStart { .. }
990 | AgentEvent::ToolExecutionStart { .. }
991 | AgentEvent::TurnStart { .. }
992 if !matches!(run.status, RunStatus::Planning) =>
993 {
994 run.status = RunStatus::Executing;
995 }
996 AgentEvent::End { text, .. } => {
997 run.status = RunStatus::Completed;
998 run.result_text = Some(text.clone());
999 run.error = None;
1000 }
1001 AgentEvent::Error { message } => {
1002 run.status = RunStatus::Failed;
1003 run.error = Some(message.clone());
1004 }
1005 _ => {}
1006 }
1007}
1008
1009fn now_ms() -> u64 {
1010 std::time::SystemTime::now()
1011 .duration_since(std::time::UNIX_EPOCH)
1012 .map(|duration| duration.as_millis() as u64)
1013 .unwrap_or(0)
1014}
1015
1016#[cfg(test)]
1017mod tests {
1018 use super::*;
1019
1020 #[tokio::test]
1021 async fn run_store_tracks_status_and_events() {
1022 let store = InMemoryRunStore::new();
1023 let run = store.create_run("session-1", "fix tests").await;
1024
1025 store
1026 .record_event(
1027 &run.id,
1028 AgentEvent::Start {
1029 prompt: "fix tests".to_string(),
1030 },
1031 )
1032 .await;
1033 store
1034 .record_event(
1035 &run.id,
1036 AgentEvent::End {
1037 text: "done".to_string(),
1038 usage: Default::default(),
1039 verification_summary: Box::new(
1040 crate::verification::VerificationSummary::from_reports(&[]),
1041 ),
1042 meta: None,
1043 },
1044 )
1045 .await;
1046
1047 let snapshot = store.snapshot(&run.id).await.unwrap();
1048 assert_eq!(snapshot.status, RunStatus::Completed);
1049 assert_eq!(snapshot.result_text.as_deref(), Some("done"));
1050 assert_eq!(snapshot.event_count, 2);
1051 assert_eq!(store.events(&run.id).await.len(), 2);
1052 }
1053
1054 #[tokio::test]
1055 async fn run_store_replaces_persisted_records() {
1056 let source = InMemoryRunStore::new();
1057 let run = source.create_run("session-1", "persist").await;
1058 source
1059 .record_event(
1060 &run.id,
1061 AgentEvent::Start {
1062 prompt: "persist".to_string(),
1063 },
1064 )
1065 .await;
1066
1067 let target = InMemoryRunStore::new();
1068 target.replace_records(source.records().await).await;
1069
1070 assert_eq!(target.list().await.len(), 1);
1071 assert_eq!(target.events(&run.id).await.len(), 1);
1072 assert_eq!(target.snapshot(&run.id).await.unwrap().event_count, 1);
1073 }
1074
1075 #[tokio::test]
1076 async fn run_handle_only_cancels_current_run() {
1077 let store = Arc::new(InMemoryRunStore::new());
1078 let run = store.create_run("session-1", "fix tests").await;
1079 let cancel_token = Arc::new(Mutex::new(Some(CancellationToken::new())));
1080 let current_run_id = Arc::new(Mutex::new(Some(run.id.clone())));
1081 let handle = RunHandle::new(
1082 run.id.clone(),
1083 run.session_id.clone(),
1084 store.clone(),
1085 cancel_token,
1086 current_run_id.clone(),
1087 None,
1088 );
1089
1090 assert!(handle.cancel().await);
1091 assert_eq!(handle.status().await, Some(RunStatus::Cancelled));
1092
1093 *current_run_id.lock().await = Some("other-run".to_string());
1094 assert!(!handle.cancel().await);
1095 }
1096
1097 #[tokio::test]
1098 async fn late_events_cannot_regress_a_terminal_run_status() {
1099 let store = InMemoryRunStore::new();
1100 let cancelled = store.create_run("session-1", "cancelled").await;
1101 store.mark_cancelled(&cancelled.id).await;
1102 store
1103 .record_event(&cancelled.id, AgentEvent::TurnStart { turn: 2 })
1104 .await;
1105 assert_eq!(
1106 store.snapshot(&cancelled.id).await.unwrap().status,
1107 RunStatus::Cancelled
1108 );
1109
1110 let completed = store.create_run("session-1", "completed").await;
1111 store
1112 .record_event(
1113 &completed.id,
1114 AgentEvent::End {
1115 text: "done".to_string(),
1116 usage: Default::default(),
1117 verification_summary: Box::new(
1118 crate::verification::VerificationSummary::from_reports(&[]),
1119 ),
1120 meta: None,
1121 },
1122 )
1123 .await;
1124 store
1125 .record_event(
1126 &completed.id,
1127 AgentEvent::ToolExecutionStart {
1128 id: "late-tool".to_string(),
1129 name: "bash".to_string(),
1130 args: serde_json::json!({}),
1131 },
1132 )
1133 .await;
1134 assert_eq!(
1135 store.snapshot(&completed.id).await.unwrap().status,
1136 RunStatus::Completed
1137 );
1138 }
1139}