1use crate::contract::event::AgentEvent;
13use crate::contract::finding::Finding;
14use crate::contract::ids::{AgentId, PhaseId, RunId};
15use serde::{Deserialize, Serialize};
16use std::collections::HashMap;
17use std::fs::{self, File, OpenOptions};
18use std::io::{BufRead, BufReader, Write};
19use std::path::{Path, PathBuf};
20use std::sync::{Arc, RwLock};
21use std::time::{SystemTime, UNIX_EPOCH};
22
23#[derive(Debug, Clone, Serialize, Deserialize)]
25pub struct RunCheckpoint {
26 pub run_id: RunId,
27 pub task: String,
28 pub status: CheckpointStatus,
29 pub current_phase: u32,
30 pub completed_phases: Vec<PhaseSummary>,
31 pub agent_results: HashMap<AgentId, AgentResultCache>,
32 pub findings: Vec<Finding>,
33 pub total_tokens: u64,
34 pub created_at: u64,
35 pub updated_at: u64,
36 #[serde(default)]
37 pub completed_spans: Vec<PhaseSpanSummary>,
38 #[serde(default)]
39 pub workflow_meta: Option<serde_json::Value>,
40 #[serde(default)]
43 pub started_agent_ids: Vec<AgentId>,
44}
45
46#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
47#[serde(rename_all = "lowercase")]
48pub enum CheckpointStatus {
49 Running,
50 Completed,
51 Failed,
52 Cancelled,
53}
54
55impl std::fmt::Display for CheckpointStatus {
56 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
57 let s = match self {
58 CheckpointStatus::Running => "Running",
59 CheckpointStatus::Completed => "Completed",
60 CheckpointStatus::Failed => "Failed",
61 CheckpointStatus::Cancelled => "Cancelled",
62 };
63 f.write_str(s)
64 }
65}
66
67#[derive(Debug, Clone, Serialize, Deserialize)]
68pub struct PhaseSummary {
69 pub phase_id: PhaseId,
70 pub label: String,
71 pub planned: usize,
72 pub ok: usize,
73 pub failed: usize,
74 #[serde(default)]
75 pub description: Option<String>,
76 #[serde(default)]
77 pub role: Option<String>,
78}
79
80#[derive(Debug, Clone, Serialize, Deserialize)]
81pub struct AgentResultCache {
82 pub agent_id: AgentId,
83 pub phase_id: PhaseId,
84 pub status: String,
85 pub output: serde_json::Value,
86 pub findings: Vec<Finding>,
87 pub tokens: u64,
88 pub completed_at: u64,
89 #[serde(default)]
92 pub cache_key_hash: Option<String>,
93 #[serde(default)]
94 pub description: Option<String>,
95 #[serde(default)]
96 pub role: Option<String>,
97}
98
99#[derive(Debug, Clone, Serialize, Deserialize)]
100pub struct PhaseSpanSummary {
101 pub id: u32,
102 pub name: String,
103 pub parent_id: Option<u32>,
104 pub depth: u32,
105 pub elapsed_ms: u64,
106 pub completed_at: u64,
107}
108
109#[derive(Debug)]
111pub struct RunStore {
112 run_dir: PathBuf,
113 checkpoint: RwLock<Option<RunCheckpoint>>,
114 events_file: RwLock<Option<File>>,
115}
116
117impl RunStore {
118 pub fn new(run_dir: &Path) -> Result<Arc<Self>, std::io::Error> {
120 tracing::debug!(path = %run_dir.display(), "creating RunStore");
121 fs::create_dir_all(run_dir)?;
122
123 let store = Arc::new(Self {
124 run_dir: run_dir.to_path_buf(),
125 checkpoint: RwLock::new(None),
126 events_file: RwLock::new(None),
127 });
128
129 Ok(store)
130 }
131
132 pub fn upsert_agent_result(&self, cache: &AgentResultCache) -> Result<(), std::io::Error> {
135 let mut guard = self.checkpoint.write().unwrap();
136 if let Some(ref mut checkpoint) = *guard {
137 checkpoint
138 .agent_results
139 .insert(cache.agent_id, cache.clone());
140 checkpoint.updated_at = current_timestamp();
141 let cp = checkpoint.clone();
142 drop(guard);
143 let cp_path = self.run_dir.join("checkpoint.json");
144 let content = serde_json::to_string_pretty(&cp)
145 .map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e))?;
146 fs::write(&cp_path, content)?;
147 }
148 Ok(())
149 }
150
151 pub fn init_run(&self, run_id: RunId, task: &str) -> Result<(), std::io::Error> {
153 tracing::info!(%run_id, %task, "initializing run store");
154 let checkpoint = RunCheckpoint {
155 run_id,
156 task: task.to_string(),
157 status: CheckpointStatus::Running,
158 current_phase: 0,
159 completed_phases: vec![],
160 agent_results: HashMap::new(),
161 findings: vec![],
162 total_tokens: 0,
163 created_at: current_timestamp(),
164 updated_at: current_timestamp(),
165 completed_spans: vec![],
166 workflow_meta: None,
167 started_agent_ids: vec![],
168 };
169
170 self.save_checkpoint(&checkpoint)?;
172
173 let events_path = self.run_dir.join("events.jsonl");
175 let events_file = OpenOptions::new()
176 .create(true)
177 .append(true)
178 .open(events_path)?;
179
180 let mut checkpoint_guard = self.checkpoint.write().unwrap();
181 *checkpoint_guard = Some(checkpoint);
182
183 let mut events_guard = self.events_file.write().unwrap();
184 *events_guard = Some(events_file);
185
186 Ok(())
187 }
188
189 pub fn init_run_with_meta(
191 &self,
192 run_id: RunId,
193 task: &str,
194 workflow_meta: serde_json::Value,
195 ) -> Result<(), std::io::Error> {
196 tracing::info!(%run_id, %task, "initializing run store with meta");
197 let checkpoint = RunCheckpoint {
198 run_id,
199 task: task.to_string(),
200 status: CheckpointStatus::Running,
201 current_phase: 0,
202 completed_phases: vec![],
203 agent_results: HashMap::new(),
204 findings: vec![],
205 total_tokens: 0,
206 created_at: current_timestamp(),
207 updated_at: current_timestamp(),
208 completed_spans: vec![],
209 workflow_meta: Some(workflow_meta),
210 started_agent_ids: vec![],
211 };
212
213 self.save_checkpoint(&checkpoint)?;
214
215 let events_path = self.run_dir.join("events.jsonl");
216 let events_file = OpenOptions::new()
217 .create(true)
218 .append(true)
219 .open(events_path)?;
220
221 let mut checkpoint_guard = self.checkpoint.write().unwrap();
222 *checkpoint_guard = Some(checkpoint);
223
224 let mut events_guard = self.events_file.write().unwrap();
225 *events_guard = Some(events_file);
226
227 Ok(())
228 }
229
230 pub fn open_run(&self, _run_id: RunId) -> Result<Option<RunCheckpoint>, std::io::Error> {
232 tracing::debug!(%_run_id, "opening existing run");
233 let checkpoint_path = self.run_dir.join("checkpoint.json");
234
235 if !checkpoint_path.exists() {
236 return Ok(None);
237 }
238
239 let content = fs::read_to_string(&checkpoint_path)?;
240 let checkpoint: RunCheckpoint = serde_json::from_str(&content)
241 .map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e))?;
242
243 let events_path = self.run_dir.join("events.jsonl");
248 let events_file = OpenOptions::new()
249 .read(true)
250 .append(true)
251 .open(events_path)?;
252
253 let mut checkpoint_guard = self.checkpoint.write().unwrap();
254 *checkpoint_guard = Some(checkpoint.clone());
255
256 let mut events_guard = self.events_file.write().unwrap();
257 *events_guard = Some(events_file);
258
259 Ok(Some(checkpoint))
260 }
261
262 pub fn append_event(&self, event: &AgentEvent) -> Result<(), std::io::Error> {
264 let json = serde_json::to_string(event)
265 .map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e))?;
266
267 let mut events_guard = self.events_file.write().unwrap();
268 if let Some(ref mut file) = *events_guard {
269 writeln!(file, "{}", json)?;
270 file.flush()?;
271 }
272
273 self.update_from_event(event);
275
276 Ok(())
277 }
278
279 fn update_from_event(&self, event: &AgentEvent) {
281 let mut checkpoint_guard = self.checkpoint.write().unwrap();
282 if let Some(ref mut checkpoint) = *checkpoint_guard {
283 match event {
284 AgentEvent::AgentDone {
285 agent_id,
286 status,
287 tokens,
288 ..
289 } => {
290 let existing = checkpoint.agent_results.get(agent_id);
291 let cache = AgentResultCache {
292 agent_id: *agent_id,
293 phase_id: existing.map(|c| c.phase_id).unwrap_or(0),
294 status: status.as_str().to_string(),
295 output: existing
296 .map(|c| c.output.clone())
297 .unwrap_or(serde_json::Value::Null),
298 findings: existing.map(|c| c.findings.clone()).unwrap_or_default(),
299 tokens: tokens.total(),
300 completed_at: existing
301 .map(|c| c.completed_at)
302 .unwrap_or(current_timestamp()),
303 cache_key_hash: existing.and_then(|c| c.cache_key_hash.clone()),
304 description: existing.and_then(|c| c.description.clone()),
305 role: existing.and_then(|c| c.role.clone()),
306 };
307 checkpoint.agent_results.insert(*agent_id, cache);
308 checkpoint.total_tokens += tokens.total();
309 }
310 AgentEvent::AgentStarted { agent_id, .. } => {
311 if !checkpoint.started_agent_ids.contains(agent_id) {
312 checkpoint.started_agent_ids.push(*agent_id);
313 }
314 }
315 AgentEvent::PhaseDone { phase_id, .. } => {
316 if *phase_id > 0 {
317 checkpoint.current_phase = *phase_id;
318 }
319 }
320 AgentEvent::PhaseSpanDone {
321 span_id,
322 name,
323 parent_id,
324 depth,
325 elapsed_ms,
326 ..
327 } => {
328 checkpoint.completed_spans.push(PhaseSpanSummary {
329 id: *span_id,
330 name: name.clone(),
331 parent_id: *parent_id,
332 depth: *depth,
333 elapsed_ms: *elapsed_ms,
334 completed_at: current_timestamp(),
335 });
336 }
337 AgentEvent::RunDone {
338 status,
339 total_tokens,
340 ..
341 } => {
342 checkpoint.status = match status {
343 crate::contract::event::RunStatus::Completed => {
344 CheckpointStatus::Completed
345 }
346 crate::contract::event::RunStatus::Failed => CheckpointStatus::Failed,
347 crate::contract::event::RunStatus::Cancelled => {
348 CheckpointStatus::Cancelled
349 }
350 crate::contract::event::RunStatus::Partial => {
351 CheckpointStatus::Running
352 }
353 };
354 let t = total_tokens.total();
357 if t > 0 {
358 checkpoint.total_tokens = t;
359 }
360 }
361 _ => {}
362 }
363 checkpoint.updated_at = current_timestamp();
364
365 if let Err(e) = self.write_checkpoint_to_disk(checkpoint) {
367 tracing::warn!(error = %e, "failed to save checkpoint");
368 }
369 }
370 }
371
372 fn write_checkpoint_to_disk(&self, checkpoint: &RunCheckpoint) -> Result<(), std::io::Error> {
374 let checkpoint_path = self.run_dir.join("checkpoint.json");
375 let content = serde_json::to_string_pretty(checkpoint)
376 .map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e))?;
377 fs::write(&checkpoint_path, content)
378 }
379
380 pub fn save_checkpoint(&self, checkpoint: &RunCheckpoint) -> Result<(), std::io::Error> {
382 let checkpoint_path = self.run_dir.join("checkpoint.json");
383 let content = serde_json::to_string_pretty(checkpoint)
384 .map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e))?;
385 fs::write(&checkpoint_path, content)?;
386
387 let mut checkpoint_guard = self.checkpoint.write().unwrap();
388 *checkpoint_guard = Some(checkpoint.clone());
389
390 Ok(())
391 }
392
393 pub fn get_checkpoint(&self) -> Option<RunCheckpoint> {
395 let guard = self.checkpoint.read().unwrap();
396 guard.clone()
397 }
398
399 pub fn get_agent_results(&self) -> HashMap<AgentId, AgentResultCache> {
401 let guard = self.checkpoint.read().unwrap();
402 guard
403 .as_ref()
404 .map(|c| c.agent_results.clone())
405 .unwrap_or_default()
406 }
407
408 pub fn get_findings(&self) -> Vec<Finding> {
410 let guard = self.checkpoint.read().unwrap();
411 guard
412 .as_ref()
413 .map(|c| c.findings.clone())
414 .unwrap_or_default()
415 }
416
417 pub fn get_event_log(&self) -> Result<Vec<AgentEvent>, std::io::Error> {
419 let events_path = self.run_dir.join("events.jsonl");
420 let file = File::open(events_path)?;
421 let reader = BufReader::new(file);
422 let mut events = Vec::new();
423
424 for line in reader.lines() {
425 let line = line?;
426 if line.trim().is_empty() {
427 continue;
428 }
429 let event: AgentEvent = serde_json::from_str(&line)
430 .map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e))?;
431 events.push(event);
432 }
433
434 Ok(events)
435 }
436
437 pub fn can_resume(&self) -> bool {
439 let guard = self.checkpoint.read().unwrap();
440 matches!(
441 guard.as_ref().map(|c| c.status.clone()),
442 Some(CheckpointStatus::Running)
443 )
444 }
445
446 pub fn cancel(&self) -> Result<(), std::io::Error> {
448 tracing::info!("cancelling run");
449 let mut guard = self.checkpoint.write().unwrap();
450 if let Some(ref mut checkpoint) = *guard {
451 checkpoint.status = CheckpointStatus::Cancelled;
452 checkpoint.updated_at = current_timestamp();
453 drop(guard);
454 let guard = self.checkpoint.read().unwrap();
455 if let Some(ref c) = *guard {
456 let checkpoint_path = self.run_dir.join("checkpoint.json");
457 let content = serde_json::to_string_pretty(c)
458 .map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e))?;
459 fs::write(&checkpoint_path, content)?;
460 }
461 }
462 Ok(())
463 }
464}
465
466fn current_timestamp() -> u64 {
468 SystemTime::now()
469 .duration_since(UNIX_EPOCH)
470 .map(|d| d.as_secs())
471 .unwrap_or(0)
472}
473
474use std::sync::OnceLock;
479
480static RUN_STORES: OnceLock<dashmap::DashMap<String, Arc<RunStore>>> = OnceLock::new();
481
482fn get_run_stores() -> &'static dashmap::DashMap<String, Arc<RunStore>> {
484 RUN_STORES.get_or_init(dashmap::DashMap::new)
485}
486
487pub fn get_run_store(run_dir_name: &str, base_dir: &Path) -> Result<Arc<RunStore>, std::io::Error> {
489 let stores = get_run_stores();
490
491 if let Some(store) = stores.get(run_dir_name) {
492 return Ok(store.clone());
493 }
494
495 let run_dir = base_dir.join(run_dir_name);
496 let store = RunStore::new(&run_dir)?;
497 stores.insert(run_dir_name.to_string(), store.clone());
498
499 Ok(store)
500}
501
502pub fn list_runs(base_dir: &Path) -> Result<Vec<String>, std::io::Error> {
504 if !base_dir.exists() {
505 return Ok(vec![]);
506 }
507
508 let mut run_dirs = Vec::new();
509 for entry in fs::read_dir(base_dir)? {
510 let entry = entry?;
511 let path = entry.path();
512 if path.is_dir() {
513 if let Some(name) = path.file_name().and_then(|n| n.to_str()) {
514 run_dirs.push(name.to_string());
515 }
516 }
517 }
518
519 run_dirs.sort();
520 Ok(run_dirs)
521}
522
523#[cfg(test)]
524mod tests {
525 use super::*;
526 use tempfile::tempdir;
527
528 #[test]
529 fn test_run_store_init() {
530 let dir = tempdir().unwrap();
531 let run_id = uuid::Uuid::now_v7();
532 let store = RunStore::new(dir.path()).unwrap();
533 store.init_run(run_id, "Test task").unwrap();
534
535 let checkpoint = store.get_checkpoint().unwrap();
536 assert_eq!(checkpoint.run_id, run_id);
537 assert_eq!(checkpoint.task, "Test task");
538 assert_eq!(checkpoint.status, CheckpointStatus::Running);
539 }
540
541 #[test]
542 fn test_run_store_resume() {
543 let dir = tempdir().unwrap();
544 let run_id = uuid::Uuid::now_v7();
545 let store = RunStore::new(dir.path()).unwrap();
546 store.init_run(run_id, "Test task").unwrap();
547
548 let store2 = RunStore::new(dir.path()).unwrap();
550 let checkpoint = store2.open_run(run_id).unwrap().unwrap();
551 assert_eq!(checkpoint.run_id, run_id);
552 assert_eq!(checkpoint.task, "Test task");
553 }
554
555 #[test]
556 fn test_can_resume() {
557 let dir = tempdir().unwrap();
558 let run_id = uuid::Uuid::now_v7();
559 let store = RunStore::new(dir.path()).unwrap();
560 store.init_run(run_id, "Test task").unwrap();
561
562 assert!(store.can_resume());
563 }
564
565 #[test]
566 fn test_resume_appends_events() {
567 let dir = tempdir().unwrap();
571 let run_id = uuid::Uuid::now_v7();
572 let store = RunStore::new(dir.path()).unwrap();
573 store.init_run(run_id, "Test task").unwrap();
574
575 let store2 = RunStore::new(dir.path()).unwrap();
576 store2.open_run(run_id).unwrap().unwrap();
577
578 let evt = AgentEvent::Log {
580 run_id,
581 agent_id: None,
582 level: crate::contract::event::LogLevel::Info,
583 msg: "resume smoke test".to_string(),
584 };
585 store2
586 .append_event(&evt)
587 .expect("append_event after resume must succeed");
588
589 let log = store2.get_event_log().expect("read events.jsonl");
590 assert!(
591 log.iter().any(|e| matches!(
592 e,
593 AgentEvent::Log { msg, .. } if msg == "resume smoke test"
594 )),
595"event written after open_run must appear in events.jsonl"
596 );
597 }
598
599 use crate::contract::backend::AgentStatus;
610 use crate::contract::ids::TokenUsage;
611 use std::collections::HashSet;
612
613 fn sample_token_usage() -> TokenUsage {
614 TokenUsage {
615 input: 10,
616 output: 5,
617 cache_read: 0,
618 cache_write: 0,
619 }
620 }
621
622 fn build_agent_done(
623 run_id: RunId,
624 agent_id: AgentId,
625 status: AgentStatus,
626 tokens: TokenUsage,
627 ) -> AgentEvent {
628 AgentEvent::AgentDone {
629 run_id,
630 agent_id,
631 status,
632 tokens,
633 elapsed_ms: 0,
634 name: None,
635 agent_seq: 0,
636 output: serde_json::Value::Null,
637 findings: vec![],
638 prompt: String::new(),
639 retry_count: 0,
640 }
641 }
642
643 fn read_raw_checkpoint(run_dir: &Path) -> serde_json::Value {
644 let path = run_dir.join("checkpoint.json");
645 let content = std::fs::read_to_string(&path)
646 .unwrap_or_else(|e| panic!("read checkpoint.json: {e}"));
647 serde_json::from_str(&content)
648 .unwrap_or_else(|e| panic!("parse checkpoint.json: {e}"))
649 }
650
651 #[test]
654 fn upsert_agent_result_persists_to_disk() {
655 let dir = tempdir().unwrap();
659 let run_id = uuid::Uuid::now_v7();
660 let store = RunStore::new(dir.path()).unwrap();
661 store.init_run(run_id, "upsert test").unwrap();
662
663 let agent_id = uuid::Uuid::now_v7();
664 let cache = AgentResultCache {
665 agent_id,
666 phase_id: 1,
667 status: "ok".into(),
668 output: serde_json::json!({"v": 42}),
669 findings: vec![],
670 tokens: 100,
671 completed_at: 1_700_000_000,
672 cache_key_hash: Some("deadbeef".into()),
673 description: None,
674 role: None,
675 };
676 store.upsert_agent_result(&cache).unwrap();
677
678 let cp = store.get_checkpoint().expect("checkpoint present");
680 let cached = cp
681 .agent_results
682 .get(&agent_id)
683 .expect("agent_id indexed after upsert");
684 assert_eq!(cached.tokens, 100);
685 assert_eq!(cached.status, "ok");
686
687 let raw = read_raw_checkpoint(dir.path());
689 let ar = raw
690 .get("agent_results")
691 .and_then(|v| v.as_object())
692 .expect("agent_results object");
693 assert_eq!(ar.len(), 1, "exactly one agent cached on disk");
694 let entry = ar
695 .values()
696 .next()
697 .expect("non-empty agent_results on disk");
698 assert_eq!(entry.get("tokens").and_then(|v| v.as_u64()), Some(100));
699 assert_eq!(entry.get("status").and_then(|v| v.as_str()), Some("ok"));
700 assert_eq!(
701 entry.get("cache_key_hash").and_then(|v| v.as_str()),
702 Some("deadbeef")
703 );
704
705 drop(store);
707 let reopened = RunStore::new(dir.path()).unwrap();
708 let restored = reopened.open_run(run_id).unwrap().unwrap();
709 assert!(
710 restored.agent_results.contains_key(&agent_id),
711 "upserted entry must survive close+reopen"
712 );
713 assert_eq!(restored.agent_results[&agent_id].tokens, 100);
714 }
715
716 #[test]
717 fn upsert_agent_result_updates_existing_entry() {
718 let dir = tempdir().unwrap();
721 let run_id = uuid::Uuid::now_v7();
722 let store = RunStore::new(dir.path()).unwrap();
723 store.init_run(run_id, "overwrite test").unwrap();
724
725 let agent_id = uuid::Uuid::now_v7();
726 let first = AgentResultCache {
727 agent_id,
728 phase_id: 1,
729 status: "ok".into(),
730 output: serde_json::json!("first"),
731 findings: vec![],
732 tokens: 10,
733 completed_at: 1,
734 cache_key_hash: None,
735 description: None,
736 role: None,
737 };
738 let second = AgentResultCache {
739 agent_id,
740 phase_id: 1,
741 status: "error".into(),
742 output: serde_json::json!("second"),
743 findings: vec![],
744 tokens: 99,
745 completed_at: 2,
746 cache_key_hash: None,
747 description: None,
748 role: None,
749 };
750 store.upsert_agent_result(&first).unwrap();
751 store.upsert_agent_result(&second).unwrap();
752
753 let cp = store.get_checkpoint().unwrap();
754 assert_eq!(cp.agent_results.len(), 1, "no duplicate entries");
755 let cached = &cp.agent_results[&agent_id];
756 assert_eq!(cached.status, "error");
757 assert_eq!(cached.tokens, 99);
758 assert_eq!(cached.completed_at, 2);
759
760 let raw = read_raw_checkpoint(dir.path());
762 let ar = raw.get("agent_results").and_then(|v| v.as_object()).unwrap();
763 assert_eq!(ar.len(), 1);
764 let entry = ar.values().next().unwrap();
765 assert_eq!(entry.get("tokens").and_then(|v| v.as_u64()), Some(99));
766 assert_eq!(entry.get("status").and_then(|v| v.as_str()), Some("error"));
767 }
768
769 #[test]
770 fn upsert_agent_result_noop_when_uninitialized() {
771 let dir = tempdir().unwrap();
775 let store = RunStore::new(dir.path()).unwrap();
776 assert!(store.get_checkpoint().is_none());
777 let cp_path = dir.path().join("checkpoint.json");
778 assert!(!cp_path.exists(), "no checkpoint.json before init");
779
780 let cache = AgentResultCache {
781 agent_id: uuid::Uuid::now_v7(),
782 phase_id: 1,
783 status: "ok".into(),
784 output: serde_json::json!(null),
785 findings: vec![],
786 tokens: 0,
787 completed_at: 0,
788 cache_key_hash: None,
789 description: None,
790 role: None,
791 };
792 store.upsert_agent_result(&cache).unwrap();
793 assert!(
794 !cp_path.exists(),
795 "upsert_agent_result must not create checkpoint.json before init_run"
796 );
797 assert!(store.get_checkpoint().is_none());
798 }
799
800 #[test]
801 fn upsert_agent_result_advances_updated_at() {
802 let dir = tempdir().unwrap();
805 let run_id = uuid::Uuid::now_v7();
806 let store = RunStore::new(dir.path()).unwrap();
807 store.init_run(run_id, "ts test").unwrap();
808 let before = store.get_checkpoint().unwrap().updated_at;
809
810 std::thread::sleep(std::time::Duration::from_millis(1100));
811
812 let cache = AgentResultCache {
813 agent_id: uuid::Uuid::now_v7(),
814 phase_id: 1,
815 status: "ok".into(),
816 output: serde_json::json!(null),
817 findings: vec![],
818 tokens: 0,
819 completed_at: 0,
820 cache_key_hash: None,
821 description: None,
822 role: None,
823 };
824 store.upsert_agent_result(&cache).unwrap();
825 let after = store.get_checkpoint().unwrap().updated_at;
826 assert!(
827 after > before,
828 "updated_at must advance after upsert (before={before}, after={after})"
829 );
830 }
831
832 #[test]
835 fn cancel_persists_cancelled_status_to_disk() {
836 let dir = tempdir().unwrap();
841 let run_id = uuid::Uuid::now_v7();
842 let store = RunStore::new(dir.path()).unwrap();
843 store.init_run(run_id, "cancel me").unwrap();
844 assert!(store.can_resume());
845
846 store.cancel().unwrap();
847
848 let cp = store.get_checkpoint().unwrap();
850 assert_eq!(cp.status, CheckpointStatus::Cancelled);
851 assert!(!store.can_resume());
852
853 let raw = read_raw_checkpoint(dir.path());
855 assert_eq!(raw.get("status").and_then(|v| v.as_str()), Some("cancelled"));
856
857 drop(store);
859 let reopened = RunStore::new(dir.path()).unwrap();
860 let restored = reopened.open_run(run_id).unwrap().unwrap();
861 assert_eq!(restored.status, CheckpointStatus::Cancelled);
862 assert!(!reopened.can_resume());
863 }
864
865 #[test]
866 fn cancel_is_idempotent() {
867 let dir = tempdir().unwrap();
872 let run_id = uuid::Uuid::now_v7();
873 let store = RunStore::new(dir.path()).unwrap();
874 store.init_run(run_id, "double cancel").unwrap();
875
876 store.cancel().unwrap();
877 let after_first = store.get_checkpoint().unwrap().updated_at;
878 std::thread::sleep(std::time::Duration::from_millis(1100));
879
880 store.cancel().expect("second cancel must succeed");
881 let after_second = store.get_checkpoint().unwrap().updated_at;
882
883 assert_eq!(
884 store.get_checkpoint().unwrap().status,
885 CheckpointStatus::Cancelled
886 );
887 assert!(
888 after_second >= after_first,
889 "updated_at must not regress (was {after_first}, now {after_second})"
890 );
891
892 let raw = read_raw_checkpoint(dir.path());
893 assert_eq!(raw.get("status").and_then(|v| v.as_str()), Some("cancelled"));
894 }
895
896 #[test]
897 fn cancel_before_init_is_safe_noop() {
898 let dir = tempdir().unwrap();
902 let store = RunStore::new(dir.path()).unwrap();
903 assert!(store.get_checkpoint().is_none());
904 store.cancel().expect("cancel before init must succeed");
905 assert!(store.get_checkpoint().is_none());
906 assert!(
907 !dir.path().join("checkpoint.json").exists(),
908 "cancel before init must not create checkpoint.json"
909 );
910 }
911
912 #[test]
913 fn cancel_preserves_agent_results_and_findings() {
914 let dir = tempdir().unwrap();
918 let run_id = uuid::Uuid::now_v7();
919 let store = RunStore::new(dir.path()).unwrap();
920 store.init_run(run_id, "preserve").unwrap();
921
922 let agent_id = uuid::Uuid::now_v7();
923 let cache = AgentResultCache {
924 agent_id,
925 phase_id: 1,
926 status: "ok".into(),
927 output: serde_json::json!({"x": 1}),
928 findings: vec![],
929 tokens: 250,
930 completed_at: 7,
931 cache_key_hash: Some("hash-1".into()),
932 description: None,
933 role: None,
934 };
935 store.upsert_agent_result(&cache).unwrap();
936 let before = store.get_checkpoint().unwrap();
937
938 store.cancel().unwrap();
939 let after = store.get_checkpoint().unwrap();
940
941 assert_eq!(after.status, CheckpointStatus::Cancelled);
942 assert_eq!(after.agent_results.len(), 1);
943 assert_eq!(after.agent_results[&agent_id].tokens, 250);
944 assert_eq!(
945 after.agent_results[&agent_id].cache_key_hash.as_deref(),
946 Some("hash-1")
947 );
948 assert_eq!(after.total_tokens, before.total_tokens);
949 }
950
951 #[test]
954 fn agent_done_persists_snake_case_status_for_each_variant() {
955 let dir = tempdir().unwrap();
960 let run_id = uuid::Uuid::now_v7();
961 let store = RunStore::new(dir.path()).unwrap();
962 store.init_run(run_id, "F5 variants").unwrap();
963
964 let cases: Vec<(AgentStatus, &str)> = vec![
965 (AgentStatus::Ok, "ok"),
966 (AgentStatus::Error, "error"),
967 (AgentStatus::Cancelled, "cancelled"),
968 (AgentStatus::TimedOut, "timed_out"),
969 ];
970 for (status, expected) in &cases {
971 let agent_id = uuid::Uuid::now_v7();
972 let evt = build_agent_done(run_id, agent_id, status.clone(), sample_token_usage());
973 store.append_event(&evt).unwrap();
974
975 let raw = read_raw_checkpoint(dir.path());
976 let ar = raw
977 .get("agent_results")
978 .and_then(|v| v.as_object())
979 .expect("agent_results object");
980 let entry = ar
981 .values()
982 .find(|v| {
983 v.get("agent_id").and_then(|id| id.as_str())
984 == Some(&agent_id.to_string())
985 })
986 .unwrap_or_else(|| panic!("entry for {agent_id} missing"));
987 let persisted = entry
988 .get("status")
989 .and_then(|v| v.as_str())
990 .unwrap_or_else(|| panic!("status missing for {status:?}"));
991 assert_eq!(
992 persisted, *expected,
993 "AgentDone({status:?}) must persist status={expected:?} (snake_case); \
994 got {persisted:?}. If this fails with \"timedout\" for TimedOut, \
995 F5 has regressed to Debug formatting."
996 );
997 }
998 }
999
1000 #[test]
1001 fn agent_done_timed_out_persists_with_underscore_not_collapsed() {
1002 let dir = tempdir().unwrap();
1007 let run_id = uuid::Uuid::now_v7();
1008 let store = RunStore::new(dir.path()).unwrap();
1009 store.init_run(run_id, "timed-out guard").unwrap();
1010
1011 let agent_id = uuid::Uuid::now_v7();
1012 let evt = build_agent_done(run_id, agent_id, AgentStatus::TimedOut, sample_token_usage());
1013 store.append_event(&evt).unwrap();
1014
1015 let raw = read_raw_checkpoint(dir.path());
1016 let ar = raw.get("agent_results").and_then(|v| v.as_object()).unwrap();
1017 let entry = ar.values().next().expect("entry exists");
1018 let persisted = entry.get("status").and_then(|v| v.as_str()).unwrap();
1019
1020 assert_eq!(
1021 persisted, "timed_out",
1022 "AgentDone(TimedOut) must persist \"timed_out\" with an underscore; got {persisted:?}"
1023 );
1024 assert_ne!(
1025 persisted, "timedout",
1026 "AgentDone(TimedOut) must NOT collapse to Debug-lowercased \"timedout\""
1027 );
1028 }
1029
1030 #[test]
1031 fn agent_done_then_reopen_restores_snake_case_status() {
1032 let dir = tempdir().unwrap();
1037 let run_id = uuid::Uuid::now_v7();
1038 let store = RunStore::new(dir.path()).unwrap();
1039 store.init_run(run_id, "round-trip").unwrap();
1040
1041 let agent_id = uuid::Uuid::now_v7();
1042 let evt = build_agent_done(
1043 run_id,
1044 agent_id,
1045 AgentStatus::Cancelled,
1046 TokenUsage {
1047 input: 1,
1048 output: 2,
1049 cache_read: 0,
1050 cache_write: 0,
1051 },
1052 );
1053 store.append_event(&evt).unwrap();
1054 drop(store);
1055
1056 let reopened = RunStore::new(dir.path()).unwrap();
1057 let cp = reopened.open_run(run_id).unwrap().unwrap();
1058 let cached = cp
1059 .agent_results
1060 .get(&agent_id)
1061 .expect("agent cached on disk");
1062 assert_eq!(cached.status, "cancelled");
1063 assert_eq!(cached.tokens, 3);
1064 }
1065
1066 #[test]
1069 fn open_run_with_corrupt_checkpoint_returns_invalid_data() {
1070 let dir = tempdir().unwrap();
1074 std::fs::create_dir_all(dir.path()).unwrap();
1075 std::fs::write(
1076 dir.path().join("checkpoint.json"),
1077 b"{ this is not valid json",
1078 )
1079 .unwrap();
1080
1081 let store = RunStore::new(dir.path()).unwrap();
1082 let err = store
1083 .open_run(uuid::Uuid::now_v7())
1084 .expect_err("corrupt JSON must surface as an io::Error");
1085 assert_eq!(
1086 err.kind(),
1087 std::io::ErrorKind::InvalidData,
1088 "corrupt checkpoint must map to InvalidData via serde_to_io; got {:?}",
1089 err.kind()
1090 );
1091 }
1092
1093 #[test]
1094 fn open_run_with_wrong_typed_checkpoint_returns_invalid_data() {
1095 let dir = tempdir().unwrap();
1098 std::fs::create_dir_all(dir.path()).unwrap();
1099 std::fs::write(
1102 dir.path().join("checkpoint.json"),
1103 br#"{"run_id":"00000000-0000-0000-0000-000000000000","status":"running"}"#,
1104 )
1105 .unwrap();
1106
1107 let store = RunStore::new(dir.path()).unwrap();
1108 let err = store
1109 .open_run(uuid::Uuid::now_v7())
1110 .expect_err("missing-field JSON must surface as an io::Error");
1111 assert_eq!(err.kind(), std::io::ErrorKind::InvalidData);
1112 }
1113
1114 #[test]
1115 fn get_event_log_with_corrupt_line_returns_invalid_data() {
1116 let dir = tempdir().unwrap();
1121 std::fs::create_dir_all(dir.path()).unwrap();
1122 std::fs::write(dir.path().join("events.jsonl"), b"not-json\n").unwrap();
1123
1124 let store = RunStore::new(dir.path()).unwrap();
1125 let err = store
1126 .get_event_log()
1127 .expect_err("corrupt event line must surface as an io::Error");
1128 assert_eq!(
1129 err.kind(),
1130 std::io::ErrorKind::InvalidData,
1131 "corrupt event line must map to InvalidData via serde_to_io; got {:?}",
1132 err.kind()
1133 );
1134 }
1135
1136 #[test]
1139 fn as_str_variants_round_trip_through_checkpoint_pipeline() {
1140 let dir = tempdir().unwrap();
1145 let run_id = uuid::Uuid::now_v7();
1146 let store = RunStore::new(dir.path()).unwrap();
1147 store.init_run(run_id, "round-trip property").unwrap();
1148
1149 let variants = [
1150 AgentStatus::Ok,
1151 AgentStatus::Error,
1152 AgentStatus::Cancelled,
1153 AgentStatus::TimedOut,
1154 ];
1155 let mut seen: HashSet<String> = HashSet::new();
1156
1157 for variant in &variants {
1158 let agent_id = uuid::Uuid::now_v7();
1159 let evt = build_agent_done(run_id, agent_id, variant.clone(), sample_token_usage());
1160 store.append_event(&evt).unwrap();
1161
1162 let cp = store.get_checkpoint().unwrap();
1163 let cached = cp
1164 .agent_results
1165 .get(&agent_id)
1166 .expect("entry for {agent_id}");
1167 assert_eq!(
1168 cached.status,
1169 variant.as_str(),
1170 "{variant:?}.as_str() must round-trip via append_event→update_from_event"
1171 );
1172 assert!(
1174 seen.insert(cached.status.clone()),
1175 "duplicate status {cached_status:?} persisted for {variant:?}",
1176 cached_status = cached.status
1177 );
1178 }
1179 }
1180}