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 workflow_meta: Option<serde_json::Value>,
38 #[serde(default)]
41 pub started_agent_ids: Vec<AgentId>,
42}
43
44#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
45#[serde(rename_all = "lowercase")]
46pub enum CheckpointStatus {
47 Running,
48 Completed,
49 Failed,
50 Cancelled,
51}
52
53impl std::fmt::Display for CheckpointStatus {
54 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
55 let s = match self {
56 CheckpointStatus::Running => "Running",
57 CheckpointStatus::Completed => "Completed",
58 CheckpointStatus::Failed => "Failed",
59 CheckpointStatus::Cancelled => "Cancelled",
60 };
61 f.write_str(s)
62 }
63}
64
65#[derive(Debug, Clone, Serialize, Deserialize)]
66pub struct PhaseSummary {
67 pub phase_id: PhaseId,
68 pub label: String,
69 pub planned: usize,
70 pub ok: usize,
71 pub failed: usize,
72 #[serde(default)]
73 pub description: Option<String>,
74 #[serde(default)]
75 pub role: Option<String>,
76}
77
78#[derive(Debug, Clone, Serialize, Deserialize)]
79pub struct AgentResultCache {
80 pub agent_id: AgentId,
81 pub phase_id: PhaseId,
82 pub status: String,
83 pub output: serde_json::Value,
84 pub findings: Vec<Finding>,
85 pub tokens: u64,
86 pub completed_at: u64,
87 #[serde(default)]
90 pub cache_key_hash: Option<String>,
91 #[serde(default)]
92 pub description: Option<String>,
93 #[serde(default)]
94 pub role: Option<String>,
95}
96
97#[derive(Debug)]
99pub struct RunStore {
100 run_dir: PathBuf,
101 checkpoint: RwLock<Option<RunCheckpoint>>,
102 events_file: RwLock<Option<File>>,
103}
104
105impl RunStore {
106 pub fn new(run_dir: &Path) -> Result<Arc<Self>, std::io::Error> {
108 tracing::debug!(path = %run_dir.display(), "creating RunStore");
109 fs::create_dir_all(run_dir)?;
110
111 let store = Arc::new(Self {
112 run_dir: run_dir.to_path_buf(),
113 checkpoint: RwLock::new(None),
114 events_file: RwLock::new(None),
115 });
116
117 Ok(store)
118 }
119
120 pub fn upsert_agent_result(&self, cache: &AgentResultCache) -> Result<(), std::io::Error> {
123 let mut guard = self.checkpoint.write().unwrap();
124 if let Some(ref mut checkpoint) = *guard {
125 checkpoint
126 .agent_results
127 .insert(cache.agent_id, cache.clone());
128 checkpoint.updated_at = current_timestamp();
129 let cp = checkpoint.clone();
130 drop(guard);
131 let cp_path = self.run_dir.join("checkpoint.json");
132 let content = serde_json::to_string_pretty(&cp)
133 .map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e))?;
134 fs::write(&cp_path, content)?;
135 }
136 Ok(())
137 }
138
139 pub fn init_run(&self, run_id: RunId, task: &str) -> Result<(), std::io::Error> {
141 tracing::info!(%run_id, %task, "initializing run store");
142 let checkpoint = RunCheckpoint {
143 run_id,
144 task: task.to_string(),
145 status: CheckpointStatus::Running,
146 current_phase: 0,
147 completed_phases: vec![],
148 agent_results: HashMap::new(),
149 findings: vec![],
150 total_tokens: 0,
151 created_at: current_timestamp(),
152 updated_at: current_timestamp(),
153 workflow_meta: None,
154 started_agent_ids: vec![],
155 };
156
157 self.save_checkpoint(&checkpoint)?;
159
160 let events_path = self.run_dir.join("events.jsonl");
162 let events_file = OpenOptions::new()
163 .create(true)
164 .append(true)
165 .open(events_path)?;
166
167 let mut checkpoint_guard = self.checkpoint.write().unwrap();
168 *checkpoint_guard = Some(checkpoint);
169
170 let mut events_guard = self.events_file.write().unwrap();
171 *events_guard = Some(events_file);
172
173 Ok(())
174 }
175
176 pub fn init_run_with_meta(
178 &self,
179 run_id: RunId,
180 task: &str,
181 workflow_meta: serde_json::Value,
182 ) -> Result<(), std::io::Error> {
183 tracing::info!(%run_id, %task, "initializing run store with meta");
184 let checkpoint = RunCheckpoint {
185 run_id,
186 task: task.to_string(),
187 status: CheckpointStatus::Running,
188 current_phase: 0,
189 completed_phases: vec![],
190 agent_results: HashMap::new(),
191 findings: vec![],
192 total_tokens: 0,
193 created_at: current_timestamp(),
194 updated_at: current_timestamp(),
195 workflow_meta: Some(workflow_meta),
196 started_agent_ids: vec![],
197 };
198
199 self.save_checkpoint(&checkpoint)?;
200
201 let events_path = self.run_dir.join("events.jsonl");
202 let events_file = OpenOptions::new()
203 .create(true)
204 .append(true)
205 .open(events_path)?;
206
207 let mut checkpoint_guard = self.checkpoint.write().unwrap();
208 *checkpoint_guard = Some(checkpoint);
209
210 let mut events_guard = self.events_file.write().unwrap();
211 *events_guard = Some(events_file);
212
213 Ok(())
214 }
215
216 pub fn open_run(&self, _run_id: RunId) -> Result<Option<RunCheckpoint>, std::io::Error> {
218 tracing::debug!(%_run_id, "opening existing run");
219 let checkpoint_path = self.run_dir.join("checkpoint.json");
220
221 if !checkpoint_path.exists() {
222 return Ok(None);
223 }
224
225 let content = fs::read_to_string(&checkpoint_path)?;
226 let checkpoint: RunCheckpoint = serde_json::from_str(&content)
227 .map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e))?;
228
229 let events_path = self.run_dir.join("events.jsonl");
234 let events_file = OpenOptions::new()
235 .read(true)
236 .append(true)
237 .open(events_path)?;
238
239 let mut checkpoint_guard = self.checkpoint.write().unwrap();
240 *checkpoint_guard = Some(checkpoint.clone());
241
242 let mut events_guard = self.events_file.write().unwrap();
243 *events_guard = Some(events_file);
244
245 Ok(Some(checkpoint))
246 }
247
248 pub fn append_event(&self, event: &AgentEvent) -> Result<(), std::io::Error> {
250 let json = serde_json::to_string(event)
251 .map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e))?;
252
253 let mut events_guard = self.events_file.write().unwrap();
254 if let Some(ref mut file) = *events_guard {
255 writeln!(file, "{}", json)?;
256 file.flush()?;
257 }
258
259 self.update_from_event(event);
261
262 Ok(())
263 }
264
265 fn update_from_event(&self, event: &AgentEvent) {
267 let mut checkpoint_guard = self.checkpoint.write().unwrap();
268 if let Some(ref mut checkpoint) = *checkpoint_guard {
269 match event {
270 AgentEvent::AgentDone {
271 agent_id,
272 status,
273 tokens,
274 ..
275 } => {
276 let existing = checkpoint.agent_results.get(agent_id);
277 let cache = AgentResultCache {
278 agent_id: *agent_id,
279 phase_id: existing.map(|c| c.phase_id).unwrap_or(0),
280 status: status.as_str().to_string(),
281 output: existing
282 .map(|c| c.output.clone())
283 .unwrap_or(serde_json::Value::Null),
284 findings: existing.map(|c| c.findings.clone()).unwrap_or_default(),
285 tokens: tokens.total(),
286 completed_at: existing
287 .map(|c| c.completed_at)
288 .unwrap_or(current_timestamp()),
289 cache_key_hash: existing.and_then(|c| c.cache_key_hash.clone()),
290 description: existing.and_then(|c| c.description.clone()),
291 role: existing.and_then(|c| c.role.clone()),
292 };
293 checkpoint.agent_results.insert(*agent_id, cache);
294 checkpoint.total_tokens += tokens.total();
295 }
296 AgentEvent::AgentStarted { agent_id, .. } => {
297 if !checkpoint.started_agent_ids.contains(agent_id) {
298 checkpoint.started_agent_ids.push(*agent_id);
299 }
300 }
301 AgentEvent::PhaseDone { phase_id, .. } => {
302 if *phase_id > 0 {
303 checkpoint.current_phase = *phase_id;
304 }
305 }
306 AgentEvent::RunDone {
307 status,
308 total_tokens,
309 ..
310 } => {
311 let cancelled_on_disk =
315 fs::read_to_string(self.run_dir.join("checkpoint.json"))
316 .ok()
317 .and_then(|content| {
318 serde_json::from_str::<RunCheckpoint>(&content).ok()
319 })
320 .is_some_and(|disk_checkpoint| {
321 disk_checkpoint.status == CheckpointStatus::Cancelled
322 });
323 if cancelled_on_disk {
324 checkpoint.status = CheckpointStatus::Cancelled;
325 } else if checkpoint.status != CheckpointStatus::Cancelled {
326 checkpoint.status = match status {
327 crate::contract::event::RunStatus::Completed => {
328 CheckpointStatus::Completed
329 }
330 crate::contract::event::RunStatus::Failed => CheckpointStatus::Failed,
331 crate::contract::event::RunStatus::Cancelled => {
332 CheckpointStatus::Cancelled
333 }
334 crate::contract::event::RunStatus::Partial => CheckpointStatus::Running,
335 };
336 }
337 let t = total_tokens.total();
340 if t > 0 {
341 checkpoint.total_tokens = t;
342 }
343 }
344 _ => {}
345 }
346 checkpoint.updated_at = current_timestamp();
347
348 if let Err(e) = self.write_checkpoint_to_disk(checkpoint) {
350 tracing::warn!(error = %e, "failed to save checkpoint");
351 }
352 }
353 }
354
355 fn write_checkpoint_to_disk(&self, checkpoint: &RunCheckpoint) -> Result<(), std::io::Error> {
357 let checkpoint_path = self.run_dir.join("checkpoint.json");
358 let temp_path = self.run_dir.join("checkpoint.json.tmp");
359 let mut checkpoint_to_write = checkpoint.clone();
364 if let Ok(existing) = std::fs::read_to_string(&checkpoint_path) {
365 if serde_json::from_str::<RunCheckpoint>(&existing)
366 .ok()
367 .is_some_and(|cp| cp.status == CheckpointStatus::Cancelled)
368 {
369 checkpoint_to_write.status = CheckpointStatus::Cancelled;
370 }
371 }
372 let content = serde_json::to_string_pretty(&checkpoint_to_write)
373 .map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e))?;
374 std::fs::write(&temp_path, &content)?;
375 std::fs::rename(&temp_path, &checkpoint_path)?;
376 Ok(())
377 }
378
379 pub fn save_checkpoint(&self, checkpoint: &RunCheckpoint) -> Result<(), std::io::Error> {
381 let checkpoint_path = self.run_dir.join("checkpoint.json");
382 let temp_path = self.run_dir.join("checkpoint.json.tmp");
383 let content = serde_json::to_string_pretty(checkpoint)
384 .map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e))?;
385 std::fs::write(&temp_path, &content)?;
386 std::fs::rename(&temp_path, &checkpoint_path)?;
387
388 let mut checkpoint_guard = self.checkpoint.write().unwrap();
389 *checkpoint_guard = Some(checkpoint.clone());
390
391 Ok(())
392 }
393
394 pub fn get_checkpoint(&self) -> Option<RunCheckpoint> {
396 let guard = self.checkpoint.read().unwrap();
397 guard.clone()
398 }
399
400 pub fn get_findings(&self) -> Vec<Finding> {
402 let guard = self.checkpoint.read().unwrap();
403 guard
404 .as_ref()
405 .map(|c| c.findings.clone())
406 .unwrap_or_default()
407 }
408
409 pub fn get_event_log(&self) -> Result<Vec<AgentEvent>, std::io::Error> {
411 let events_path = self.run_dir.join("events.jsonl");
412 let file = File::open(events_path)?;
413 let reader = BufReader::new(file);
414 let mut events = Vec::new();
415
416 for line in reader.lines() {
417 let line = line?;
418 if line.trim().is_empty() {
419 continue;
420 }
421 let event: AgentEvent = serde_json::from_str(&line)
422 .map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e))?;
423 events.push(event);
424 }
425
426 Ok(events)
427 }
428
429 pub fn can_resume(&self) -> bool {
431 let guard = self.checkpoint.read().unwrap();
432 matches!(
433 guard.as_ref().map(|c| c.status.clone()),
434 Some(CheckpointStatus::Running)
435 )
436 }
437
438 pub fn reset_status_to_running(&self) -> Result<(), std::io::Error> {
443 let mut guard = self.checkpoint.write().unwrap();
444
445 if guard.is_none() {
446 let checkpoint_path = self.run_dir.join("checkpoint.json");
447 if !checkpoint_path.exists() {
448 return Ok(());
449 }
450 let content = fs::read_to_string(&checkpoint_path)?;
451 let cp: RunCheckpoint = serde_json::from_str(&content)
452 .map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e))?;
453 *guard = Some(cp);
454 }
455
456 if let Some(ref mut checkpoint) = *guard {
457 checkpoint.status = CheckpointStatus::Running;
458 checkpoint.updated_at = current_timestamp();
459 let cp_clone = checkpoint.clone();
460 drop(guard);
461 self.write_checkpoint_to_disk(&cp_clone)?;
462 }
463 Ok(())
464 }
465
466 pub fn cancel(&self) -> Result<(), std::io::Error> {
468 tracing::info!("cancelling run");
469 let mut guard = self.checkpoint.write().unwrap();
470
471 if guard.is_none() {
479 let checkpoint_path = self.run_dir.join("checkpoint.json");
480 if !checkpoint_path.exists() {
481 return Ok(());
482 }
483 let content = fs::read_to_string(&checkpoint_path)?;
484 let cp: RunCheckpoint = serde_json::from_str(&content)
485 .map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e))?;
486 *guard = Some(cp);
487 }
488
489 if let Some(ref mut checkpoint) = *guard {
490 checkpoint.status = CheckpointStatus::Cancelled;
491 checkpoint.updated_at = current_timestamp();
492 let checkpoint = checkpoint.clone();
493 drop(guard);
494 self.write_checkpoint_to_disk(&checkpoint)?;
498 }
499 Ok(())
500 }
501}
502
503fn current_timestamp() -> u64 {
505 SystemTime::now()
506 .duration_since(UNIX_EPOCH)
507 .map(|d| d.as_secs())
508 .unwrap_or(0)
509}
510
511use std::sync::OnceLock;
516
517static RUN_STORES: OnceLock<dashmap::DashMap<String, Arc<RunStore>>> = OnceLock::new();
518
519fn get_run_stores() -> &'static dashmap::DashMap<String, Arc<RunStore>> {
521 RUN_STORES.get_or_init(dashmap::DashMap::new)
522}
523
524pub fn get_run_store(run_dir_name: &str, base_dir: &Path) -> Result<Arc<RunStore>, std::io::Error> {
526 let stores = get_run_stores();
527
528 if let Some(store) = stores.get(run_dir_name) {
529 return Ok(store.clone());
530 }
531
532 let run_dir = base_dir.join(run_dir_name);
533 let store = RunStore::new(&run_dir)?;
534 stores.insert(run_dir_name.to_string(), store.clone());
535
536 Ok(store)
537}
538
539pub fn list_runs(base_dir: &Path) -> Result<Vec<String>, std::io::Error> {
541 if !base_dir.exists() {
542 return Ok(vec![]);
543 }
544
545 let mut run_dirs = Vec::new();
546 for entry in fs::read_dir(base_dir)? {
547 let entry = entry?;
548 let path = entry.path();
549 if path.is_dir() {
550 if let Some(name) = path.file_name().and_then(|n| n.to_str()) {
551 run_dirs.push(name.to_string());
552 }
553 }
554 }
555
556 run_dirs.sort();
557 Ok(run_dirs)
558}
559
560#[cfg(test)]
561mod tests {
562 use super::*;
563 use tempfile::tempdir;
564
565 #[test]
566 fn test_run_store_init() {
567 let dir = tempdir().unwrap();
568 let run_id = uuid::Uuid::now_v7();
569 let store = RunStore::new(dir.path()).unwrap();
570 store.init_run(run_id, "Test task").unwrap();
571
572 let checkpoint = store.get_checkpoint().unwrap();
573 assert_eq!(checkpoint.run_id, run_id);
574 assert_eq!(checkpoint.task, "Test task");
575 assert_eq!(checkpoint.status, CheckpointStatus::Running);
576 }
577
578 #[test]
579 fn test_run_store_resume() {
580 let dir = tempdir().unwrap();
581 let run_id = uuid::Uuid::now_v7();
582 let store = RunStore::new(dir.path()).unwrap();
583 store.init_run(run_id, "Test task").unwrap();
584
585 let store2 = RunStore::new(dir.path()).unwrap();
587 let checkpoint = store2.open_run(run_id).unwrap().unwrap();
588 assert_eq!(checkpoint.run_id, run_id);
589 assert_eq!(checkpoint.task, "Test task");
590 }
591
592 #[test]
593 fn test_can_resume() {
594 let dir = tempdir().unwrap();
595 let run_id = uuid::Uuid::now_v7();
596 let store = RunStore::new(dir.path()).unwrap();
597 store.init_run(run_id, "Test task").unwrap();
598
599 assert!(store.can_resume());
600 }
601
602 #[test]
603 fn test_resume_appends_events() {
604 let dir = tempdir().unwrap();
608 let run_id = uuid::Uuid::now_v7();
609 let store = RunStore::new(dir.path()).unwrap();
610 store.init_run(run_id, "Test task").unwrap();
611
612 let store2 = RunStore::new(dir.path()).unwrap();
613 store2.open_run(run_id).unwrap().unwrap();
614
615 let evt = AgentEvent::Log {
617 run_id,
618 agent_id: None,
619 level: crate::contract::event::LogLevel::Info,
620 msg: "resume smoke test".to_string(),
621 };
622 store2
623 .append_event(&evt)
624 .expect("append_event after resume must succeed");
625
626 let log = store2.get_event_log().expect("read events.jsonl");
627 assert!(
628 log.iter().any(|e| matches!(
629 e,
630 AgentEvent::Log { msg, .. } if msg == "resume smoke test"
631 )),
632 "event written after open_run must appear in events.jsonl"
633 );
634 }
635
636 use crate::contract::backend::AgentStatus;
647 use crate::contract::ids::TokenUsage;
648 use std::collections::HashSet;
649
650 fn sample_token_usage() -> TokenUsage {
651 TokenUsage {
652 input: 10,
653 output: 5,
654 cache_read: 0,
655 cache_write: 0,
656 }
657 }
658
659 fn build_agent_done(
660 run_id: RunId,
661 agent_id: AgentId,
662 status: AgentStatus,
663 tokens: TokenUsage,
664 ) -> AgentEvent {
665 AgentEvent::AgentDone {
666 run_id,
667 agent_id,
668 status,
669 tokens,
670 elapsed_ms: 0,
671 name: None,
672 agent_seq: 0,
673 output: serde_json::Value::Null,
674 findings: vec![],
675 prompt: String::new(),
676 retry_count: 0,
677 ts: Default::default(),
678 }
679 }
680
681 fn read_raw_checkpoint(run_dir: &Path) -> serde_json::Value {
682 let path = run_dir.join("checkpoint.json");
683 let content =
684 std::fs::read_to_string(&path).unwrap_or_else(|e| panic!("read checkpoint.json: {e}"));
685 serde_json::from_str(&content).unwrap_or_else(|e| panic!("parse checkpoint.json: {e}"))
686 }
687
688 #[test]
691 fn upsert_agent_result_persists_to_disk() {
692 let dir = tempdir().unwrap();
696 let run_id = uuid::Uuid::now_v7();
697 let store = RunStore::new(dir.path()).unwrap();
698 store.init_run(run_id, "upsert test").unwrap();
699
700 let agent_id = uuid::Uuid::now_v7();
701 let cache = AgentResultCache {
702 agent_id,
703 phase_id: 1,
704 status: "ok".into(),
705 output: serde_json::json!({"v": 42}),
706 findings: vec![],
707 tokens: 100,
708 completed_at: 1_700_000_000,
709 cache_key_hash: Some("deadbeef".into()),
710 description: None,
711 role: None,
712 };
713 store.upsert_agent_result(&cache).unwrap();
714
715 let cp = store.get_checkpoint().expect("checkpoint present");
717 let cached = cp
718 .agent_results
719 .get(&agent_id)
720 .expect("agent_id indexed after upsert");
721 assert_eq!(cached.tokens, 100);
722 assert_eq!(cached.status, "ok");
723
724 let raw = read_raw_checkpoint(dir.path());
726 let ar = raw
727 .get("agent_results")
728 .and_then(|v| v.as_object())
729 .expect("agent_results object");
730 assert_eq!(ar.len(), 1, "exactly one agent cached on disk");
731 let entry = ar.values().next().expect("non-empty agent_results on disk");
732 assert_eq!(entry.get("tokens").and_then(|v| v.as_u64()), Some(100));
733 assert_eq!(entry.get("status").and_then(|v| v.as_str()), Some("ok"));
734 assert_eq!(
735 entry.get("cache_key_hash").and_then(|v| v.as_str()),
736 Some("deadbeef")
737 );
738
739 drop(store);
741 let reopened = RunStore::new(dir.path()).unwrap();
742 let restored = reopened.open_run(run_id).unwrap().unwrap();
743 assert!(
744 restored.agent_results.contains_key(&agent_id),
745 "upserted entry must survive close+reopen"
746 );
747 assert_eq!(restored.agent_results[&agent_id].tokens, 100);
748 }
749
750 #[test]
751 fn upsert_agent_result_updates_existing_entry() {
752 let dir = tempdir().unwrap();
755 let run_id = uuid::Uuid::now_v7();
756 let store = RunStore::new(dir.path()).unwrap();
757 store.init_run(run_id, "overwrite test").unwrap();
758
759 let agent_id = uuid::Uuid::now_v7();
760 let first = AgentResultCache {
761 agent_id,
762 phase_id: 1,
763 status: "ok".into(),
764 output: serde_json::json!("first"),
765 findings: vec![],
766 tokens: 10,
767 completed_at: 1,
768 cache_key_hash: None,
769 description: None,
770 role: None,
771 };
772 let second = AgentResultCache {
773 agent_id,
774 phase_id: 1,
775 status: "error".into(),
776 output: serde_json::json!("second"),
777 findings: vec![],
778 tokens: 99,
779 completed_at: 2,
780 cache_key_hash: None,
781 description: None,
782 role: None,
783 };
784 store.upsert_agent_result(&first).unwrap();
785 store.upsert_agent_result(&second).unwrap();
786
787 let cp = store.get_checkpoint().unwrap();
788 assert_eq!(cp.agent_results.len(), 1, "no duplicate entries");
789 let cached = &cp.agent_results[&agent_id];
790 assert_eq!(cached.status, "error");
791 assert_eq!(cached.tokens, 99);
792 assert_eq!(cached.completed_at, 2);
793
794 let raw = read_raw_checkpoint(dir.path());
796 let ar = raw
797 .get("agent_results")
798 .and_then(|v| v.as_object())
799 .unwrap();
800 assert_eq!(ar.len(), 1);
801 let entry = ar.values().next().unwrap();
802 assert_eq!(entry.get("tokens").and_then(|v| v.as_u64()), Some(99));
803 assert_eq!(entry.get("status").and_then(|v| v.as_str()), Some("error"));
804 }
805
806 #[test]
807 fn upsert_agent_result_noop_when_uninitialized() {
808 let dir = tempdir().unwrap();
812 let store = RunStore::new(dir.path()).unwrap();
813 assert!(store.get_checkpoint().is_none());
814 let cp_path = dir.path().join("checkpoint.json");
815 assert!(!cp_path.exists(), "no checkpoint.json before init");
816
817 let cache = AgentResultCache {
818 agent_id: uuid::Uuid::now_v7(),
819 phase_id: 1,
820 status: "ok".into(),
821 output: serde_json::json!(null),
822 findings: vec![],
823 tokens: 0,
824 completed_at: 0,
825 cache_key_hash: None,
826 description: None,
827 role: None,
828 };
829 store.upsert_agent_result(&cache).unwrap();
830 assert!(
831 !cp_path.exists(),
832 "upsert_agent_result must not create checkpoint.json before init_run"
833 );
834 assert!(store.get_checkpoint().is_none());
835 }
836
837 #[test]
838 fn upsert_agent_result_advances_updated_at() {
839 let dir = tempdir().unwrap();
842 let run_id = uuid::Uuid::now_v7();
843 let store = RunStore::new(dir.path()).unwrap();
844 store.init_run(run_id, "ts test").unwrap();
845 let before = store.get_checkpoint().unwrap().updated_at;
846
847 std::thread::sleep(std::time::Duration::from_millis(1100));
848
849 let cache = AgentResultCache {
850 agent_id: uuid::Uuid::now_v7(),
851 phase_id: 1,
852 status: "ok".into(),
853 output: serde_json::json!(null),
854 findings: vec![],
855 tokens: 0,
856 completed_at: 0,
857 cache_key_hash: None,
858 description: None,
859 role: None,
860 };
861 store.upsert_agent_result(&cache).unwrap();
862 let after = store.get_checkpoint().unwrap().updated_at;
863 assert!(
864 after > before,
865 "updated_at must advance after upsert (before={before}, after={after})"
866 );
867 }
868
869 #[test]
872 fn cancel_persists_cancelled_status_to_disk() {
873 let dir = tempdir().unwrap();
878 let run_id = uuid::Uuid::now_v7();
879 let store = RunStore::new(dir.path()).unwrap();
880 store.init_run(run_id, "cancel me").unwrap();
881 assert!(store.can_resume());
882
883 store.cancel().unwrap();
884
885 let cp = store.get_checkpoint().unwrap();
887 assert_eq!(cp.status, CheckpointStatus::Cancelled);
888 assert!(!store.can_resume());
889
890 let raw = read_raw_checkpoint(dir.path());
892 assert_eq!(
893 raw.get("status").and_then(|v| v.as_str()),
894 Some("cancelled")
895 );
896
897 drop(store);
899 let reopened = RunStore::new(dir.path()).unwrap();
900 let restored = reopened.open_run(run_id).unwrap().unwrap();
901 assert_eq!(restored.status, CheckpointStatus::Cancelled);
902 assert!(!reopened.can_resume());
903 }
904
905 #[test]
906 fn cancel_is_idempotent() {
907 let dir = tempdir().unwrap();
912 let run_id = uuid::Uuid::now_v7();
913 let store = RunStore::new(dir.path()).unwrap();
914 store.init_run(run_id, "double cancel").unwrap();
915
916 store.cancel().unwrap();
917 let after_first = store.get_checkpoint().unwrap().updated_at;
918 std::thread::sleep(std::time::Duration::from_millis(1100));
919
920 store.cancel().expect("second cancel must succeed");
921 let after_second = store.get_checkpoint().unwrap().updated_at;
922
923 assert_eq!(
924 store.get_checkpoint().unwrap().status,
925 CheckpointStatus::Cancelled
926 );
927 assert!(
928 after_second >= after_first,
929 "updated_at must not regress (was {after_first}, now {after_second})"
930 );
931
932 let raw = read_raw_checkpoint(dir.path());
933 assert_eq!(
934 raw.get("status").and_then(|v| v.as_str()),
935 Some("cancelled")
936 );
937 }
938
939 #[test]
940 fn cancel_before_init_is_safe_noop() {
941 let dir = tempdir().unwrap();
945 let store = RunStore::new(dir.path()).unwrap();
946 assert!(store.get_checkpoint().is_none());
947 store.cancel().expect("cancel before init must succeed");
948 assert!(store.get_checkpoint().is_none());
949 assert!(
950 !dir.path().join("checkpoint.json").exists(),
951 "cancel before init must not create checkpoint.json"
952 );
953 }
954
955 #[test]
956 fn cancel_loads_checkpoint_from_disk_when_cache_is_empty() {
957 let dir = tempdir().unwrap();
963 let run_id = uuid::Uuid::now_v7();
964
965 let cp = RunCheckpoint {
968 run_id,
969 task: "started elsewhere".into(),
970 status: CheckpointStatus::Running,
971 current_phase: 0,
972 completed_phases: vec![],
973 agent_results: HashMap::new(),
974 findings: vec![],
975 total_tokens: 0,
976 created_at: current_timestamp(),
977 updated_at: current_timestamp(),
978 workflow_meta: None,
979 started_agent_ids: vec![],
980 };
981 let cp_path = dir.path().join("checkpoint.json");
982 std::fs::write(&cp_path, serde_json::to_string_pretty(&cp).unwrap()).unwrap();
983
984 let store = RunStore::new(dir.path()).unwrap();
986 assert!(store.get_checkpoint().is_none(), "cache must start empty");
987
988 store.cancel().expect("cancel must succeed cross-process");
989
990 let cached = store
992 .get_checkpoint()
993 .expect("cache populated after cancel");
994 assert_eq!(cached.status, CheckpointStatus::Cancelled);
995
996 let raw = read_raw_checkpoint(dir.path());
998 assert_eq!(
999 raw.get("status").and_then(|v| v.as_str()),
1000 Some("cancelled")
1001 );
1002 assert_eq!(
1003 raw.get("run_id").and_then(|v| v.as_str()),
1004 Some(run_id.to_string()).as_deref()
1005 );
1006 }
1007
1008 #[test]
1009 fn cancel_preserves_agent_results_and_findings() {
1010 let dir = tempdir().unwrap();
1014 let run_id = uuid::Uuid::now_v7();
1015 let store = RunStore::new(dir.path()).unwrap();
1016 store.init_run(run_id, "preserve").unwrap();
1017
1018 let agent_id = uuid::Uuid::now_v7();
1019 let cache = AgentResultCache {
1020 agent_id,
1021 phase_id: 1,
1022 status: "ok".into(),
1023 output: serde_json::json!({"x": 1}),
1024 findings: vec![],
1025 tokens: 250,
1026 completed_at: 7,
1027 cache_key_hash: Some("hash-1".into()),
1028 description: None,
1029 role: None,
1030 };
1031 store.upsert_agent_result(&cache).unwrap();
1032 let before = store.get_checkpoint().unwrap();
1033
1034 store.cancel().unwrap();
1035 let after = store.get_checkpoint().unwrap();
1036
1037 assert_eq!(after.status, CheckpointStatus::Cancelled);
1038 assert_eq!(after.agent_results.len(), 1);
1039 assert_eq!(after.agent_results[&agent_id].tokens, 250);
1040 assert_eq!(
1041 after.agent_results[&agent_id].cache_key_hash.as_deref(),
1042 Some("hash-1")
1043 );
1044 assert_eq!(after.total_tokens, before.total_tokens);
1045 }
1046
1047 #[test]
1048 fn late_event_from_another_process_cannot_resurrect_cancelled_run() {
1049 let dir = tempdir().unwrap();
1050 let run_id = uuid::Uuid::now_v7();
1051 let writer = RunStore::new(dir.path()).unwrap();
1052 writer.init_run(run_id, "cross process race").unwrap();
1053
1054 let canceller = RunStore::new(dir.path()).unwrap();
1057 canceller.cancel().unwrap();
1058
1059 writer
1060 .append_event(&AgentEvent::RunDone {
1061 run_id,
1062 status: crate::contract::event::RunStatus::Completed,
1063 total_tokens: TokenUsage::default(),
1064 report: serde_json::Value::Null,
1065 ts: chrono::Utc::now(),
1066 })
1067 .unwrap();
1068
1069 let restored = RunStore::new(dir.path())
1070 .unwrap()
1071 .open_run(run_id)
1072 .unwrap()
1073 .unwrap();
1074 assert_eq!(restored.status, CheckpointStatus::Cancelled);
1075 }
1076
1077 #[test]
1080 fn agent_done_persists_snake_case_status_for_each_variant() {
1081 let dir = tempdir().unwrap();
1086 let run_id = uuid::Uuid::now_v7();
1087 let store = RunStore::new(dir.path()).unwrap();
1088 store.init_run(run_id, "F5 variants").unwrap();
1089
1090 let cases: Vec<(AgentStatus, &str)> = vec![
1091 (AgentStatus::Ok, "ok"),
1092 (AgentStatus::Error, "error"),
1093 (AgentStatus::Cancelled, "cancelled"),
1094 (AgentStatus::TimedOut, "timed_out"),
1095 ];
1096 for (status, expected) in &cases {
1097 let agent_id = uuid::Uuid::now_v7();
1098 let evt = build_agent_done(run_id, agent_id, status.clone(), sample_token_usage());
1099 store.append_event(&evt).unwrap();
1100
1101 let raw = read_raw_checkpoint(dir.path());
1102 let ar = raw
1103 .get("agent_results")
1104 .and_then(|v| v.as_object())
1105 .expect("agent_results object");
1106 let entry = ar
1107 .values()
1108 .find(|v| {
1109 v.get("agent_id").and_then(|id| id.as_str()) == Some(&agent_id.to_string())
1110 })
1111 .unwrap_or_else(|| panic!("entry for {agent_id} missing"));
1112 let persisted = entry
1113 .get("status")
1114 .and_then(|v| v.as_str())
1115 .unwrap_or_else(|| panic!("status missing for {status:?}"));
1116 assert_eq!(
1117 persisted, *expected,
1118 "AgentDone({status:?}) must persist status={expected:?} (snake_case); \
1119 got {persisted:?}. If this fails with \"timedout\" for TimedOut, \
1120 F5 has regressed to Debug formatting."
1121 );
1122 }
1123 }
1124
1125 #[test]
1126 fn agent_done_timed_out_persists_with_underscore_not_collapsed() {
1127 let dir = tempdir().unwrap();
1132 let run_id = uuid::Uuid::now_v7();
1133 let store = RunStore::new(dir.path()).unwrap();
1134 store.init_run(run_id, "timed-out guard").unwrap();
1135
1136 let agent_id = uuid::Uuid::now_v7();
1137 let evt = build_agent_done(
1138 run_id,
1139 agent_id,
1140 AgentStatus::TimedOut,
1141 sample_token_usage(),
1142 );
1143 store.append_event(&evt).unwrap();
1144
1145 let raw = read_raw_checkpoint(dir.path());
1146 let ar = raw
1147 .get("agent_results")
1148 .and_then(|v| v.as_object())
1149 .unwrap();
1150 let entry = ar.values().next().expect("entry exists");
1151 let persisted = entry.get("status").and_then(|v| v.as_str()).unwrap();
1152
1153 assert_eq!(
1154 persisted, "timed_out",
1155 "AgentDone(TimedOut) must persist \"timed_out\" with an underscore; got {persisted:?}"
1156 );
1157 assert_ne!(
1158 persisted, "timedout",
1159 "AgentDone(TimedOut) must NOT collapse to Debug-lowercased \"timedout\""
1160 );
1161 }
1162
1163 #[test]
1164 fn agent_done_then_reopen_restores_snake_case_status() {
1165 let dir = tempdir().unwrap();
1170 let run_id = uuid::Uuid::now_v7();
1171 let store = RunStore::new(dir.path()).unwrap();
1172 store.init_run(run_id, "round-trip").unwrap();
1173
1174 let agent_id = uuid::Uuid::now_v7();
1175 let evt = build_agent_done(
1176 run_id,
1177 agent_id,
1178 AgentStatus::Cancelled,
1179 TokenUsage {
1180 input: 1,
1181 output: 2,
1182 cache_read: 0,
1183 cache_write: 0,
1184 },
1185 );
1186 store.append_event(&evt).unwrap();
1187 drop(store);
1188
1189 let reopened = RunStore::new(dir.path()).unwrap();
1190 let cp = reopened.open_run(run_id).unwrap().unwrap();
1191 let cached = cp
1192 .agent_results
1193 .get(&agent_id)
1194 .expect("agent cached on disk");
1195 assert_eq!(cached.status, "cancelled");
1196 assert_eq!(cached.tokens, 3);
1197 }
1198
1199 #[test]
1202 fn open_run_with_corrupt_checkpoint_returns_invalid_data() {
1203 let dir = tempdir().unwrap();
1207 std::fs::create_dir_all(dir.path()).unwrap();
1208 std::fs::write(
1209 dir.path().join("checkpoint.json"),
1210 b"{ this is not valid json",
1211 )
1212 .unwrap();
1213
1214 let store = RunStore::new(dir.path()).unwrap();
1215 let err = store
1216 .open_run(uuid::Uuid::now_v7())
1217 .expect_err("corrupt JSON must surface as an io::Error");
1218 assert_eq!(
1219 err.kind(),
1220 std::io::ErrorKind::InvalidData,
1221 "corrupt checkpoint must map to InvalidData via serde_to_io; got {:?}",
1222 err.kind()
1223 );
1224 }
1225
1226 #[test]
1227 fn open_run_with_wrong_typed_checkpoint_returns_invalid_data() {
1228 let dir = tempdir().unwrap();
1231 std::fs::create_dir_all(dir.path()).unwrap();
1232 std::fs::write(
1235 dir.path().join("checkpoint.json"),
1236 br#"{"run_id":"00000000-0000-0000-0000-000000000000","status":"running"}"#,
1237 )
1238 .unwrap();
1239
1240 let store = RunStore::new(dir.path()).unwrap();
1241 let err = store
1242 .open_run(uuid::Uuid::now_v7())
1243 .expect_err("missing-field JSON must surface as an io::Error");
1244 assert_eq!(err.kind(), std::io::ErrorKind::InvalidData);
1245 }
1246
1247 #[test]
1248 fn get_event_log_with_corrupt_line_returns_invalid_data() {
1249 let dir = tempdir().unwrap();
1254 std::fs::create_dir_all(dir.path()).unwrap();
1255 std::fs::write(dir.path().join("events.jsonl"), b"not-json\n").unwrap();
1256
1257 let store = RunStore::new(dir.path()).unwrap();
1258 let err = store
1259 .get_event_log()
1260 .expect_err("corrupt event line must surface as an io::Error");
1261 assert_eq!(
1262 err.kind(),
1263 std::io::ErrorKind::InvalidData,
1264 "corrupt event line must map to InvalidData via serde_to_io; got {:?}",
1265 err.kind()
1266 );
1267 }
1268
1269 #[test]
1272 fn as_str_variants_round_trip_through_checkpoint_pipeline() {
1273 let dir = tempdir().unwrap();
1278 let run_id = uuid::Uuid::now_v7();
1279 let store = RunStore::new(dir.path()).unwrap();
1280 store.init_run(run_id, "round-trip property").unwrap();
1281
1282 let variants = [
1283 AgentStatus::Ok,
1284 AgentStatus::Error,
1285 AgentStatus::Cancelled,
1286 AgentStatus::TimedOut,
1287 ];
1288 let mut seen: HashSet<String> = HashSet::new();
1289
1290 for variant in &variants {
1291 let agent_id = uuid::Uuid::now_v7();
1292 let evt = build_agent_done(run_id, agent_id, variant.clone(), sample_token_usage());
1293 store.append_event(&evt).unwrap();
1294
1295 let cp = store.get_checkpoint().unwrap();
1296 let cached = cp
1297 .agent_results
1298 .get(&agent_id)
1299 .expect("entry for {agent_id}");
1300 assert_eq!(
1301 cached.status,
1302 variant.as_str(),
1303 "{variant:?}.as_str() must round-trip via append_event→update_from_event"
1304 );
1305 assert!(
1307 seen.insert(cached.status.clone()),
1308 "duplicate status {cached_status:?} persisted for {variant:?}",
1309 cached_status = cached.status
1310 );
1311 }
1312 }
1313}