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 => CheckpointStatus::Completed,
344 crate::contract::event::RunStatus::Failed => CheckpointStatus::Failed,
345 crate::contract::event::RunStatus::Cancelled => CheckpointStatus::Cancelled,
346 crate::contract::event::RunStatus::Partial => CheckpointStatus::Running,
347 };
348 let t = total_tokens.total();
351 if t > 0 {
352 checkpoint.total_tokens = t;
353 }
354 }
355 _ => {}
356 }
357 checkpoint.updated_at = current_timestamp();
358
359 if let Err(e) = self.write_checkpoint_to_disk(checkpoint) {
361 tracing::warn!(error = %e, "failed to save checkpoint");
362 }
363 }
364 }
365
366 fn write_checkpoint_to_disk(&self, checkpoint: &RunCheckpoint) -> Result<(), std::io::Error> {
368 let checkpoint_path = self.run_dir.join("checkpoint.json");
369 let temp_path = self.run_dir.join("checkpoint.json.tmp");
370 let content = serde_json::to_string_pretty(checkpoint)
371 .map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e))?;
372 std::fs::write(&temp_path, &content)?;
373 std::fs::rename(&temp_path, &checkpoint_path)?;
374 Ok(())
375 }
376
377 pub fn save_checkpoint(&self, checkpoint: &RunCheckpoint) -> Result<(), std::io::Error> {
379 let checkpoint_path = self.run_dir.join("checkpoint.json");
380 let temp_path = self.run_dir.join("checkpoint.json.tmp");
381 let content = serde_json::to_string_pretty(checkpoint)
382 .map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e))?;
383 std::fs::write(&temp_path, &content)?;
384 std::fs::rename(&temp_path, &checkpoint_path)?;
385
386 let mut checkpoint_guard = self.checkpoint.write().unwrap();
387 *checkpoint_guard = Some(checkpoint.clone());
388
389 Ok(())
390 }
391
392 pub fn get_checkpoint(&self) -> Option<RunCheckpoint> {
394 let guard = self.checkpoint.read().unwrap();
395 guard.clone()
396 }
397
398 pub fn get_agent_results(&self) -> HashMap<AgentId, AgentResultCache> {
400 let guard = self.checkpoint.read().unwrap();
401 guard
402 .as_ref()
403 .map(|c| c.agent_results.clone())
404 .unwrap_or_default()
405 }
406
407 pub fn get_findings(&self) -> Vec<Finding> {
409 let guard = self.checkpoint.read().unwrap();
410 guard
411 .as_ref()
412 .map(|c| c.findings.clone())
413 .unwrap_or_default()
414 }
415
416 pub fn get_event_log(&self) -> Result<Vec<AgentEvent>, std::io::Error> {
418 let events_path = self.run_dir.join("events.jsonl");
419 let file = File::open(events_path)?;
420 let reader = BufReader::new(file);
421 let mut events = Vec::new();
422
423 for line in reader.lines() {
424 let line = line?;
425 if line.trim().is_empty() {
426 continue;
427 }
428 let event: AgentEvent = serde_json::from_str(&line)
429 .map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e))?;
430 events.push(event);
431 }
432
433 Ok(events)
434 }
435
436 pub fn can_resume(&self) -> bool {
438 let guard = self.checkpoint.read().unwrap();
439 matches!(
440 guard.as_ref().map(|c| c.status.clone()),
441 Some(CheckpointStatus::Running)
442 )
443 }
444
445 pub fn cancel(&self) -> Result<(), std::io::Error> {
447 tracing::info!("cancelling run");
448 let mut guard = self.checkpoint.write().unwrap();
449 if let Some(ref mut checkpoint) = *guard {
450 checkpoint.status = CheckpointStatus::Cancelled;
451 checkpoint.updated_at = current_timestamp();
452 drop(guard);
453 let guard = self.checkpoint.read().unwrap();
454 if let Some(ref c) = *guard {
455 let checkpoint_path = self.run_dir.join("checkpoint.json");
456 let content = serde_json::to_string_pretty(c)
457 .map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e))?;
458 fs::write(&checkpoint_path, content)?;
459 }
460 }
461 Ok(())
462 }
463}
464
465fn current_timestamp() -> u64 {
467 SystemTime::now()
468 .duration_since(UNIX_EPOCH)
469 .map(|d| d.as_secs())
470 .unwrap_or(0)
471}
472
473use std::sync::OnceLock;
478
479static RUN_STORES: OnceLock<dashmap::DashMap<String, Arc<RunStore>>> = OnceLock::new();
480
481fn get_run_stores() -> &'static dashmap::DashMap<String, Arc<RunStore>> {
483 RUN_STORES.get_or_init(dashmap::DashMap::new)
484}
485
486pub fn get_run_store(run_dir_name: &str, base_dir: &Path) -> Result<Arc<RunStore>, std::io::Error> {
488 let stores = get_run_stores();
489
490 if let Some(store) = stores.get(run_dir_name) {
491 return Ok(store.clone());
492 }
493
494 let run_dir = base_dir.join(run_dir_name);
495 let store = RunStore::new(&run_dir)?;
496 stores.insert(run_dir_name.to_string(), store.clone());
497
498 Ok(store)
499}
500
501pub fn list_runs(base_dir: &Path) -> Result<Vec<String>, std::io::Error> {
503 if !base_dir.exists() {
504 return Ok(vec![]);
505 }
506
507 let mut run_dirs = Vec::new();
508 for entry in fs::read_dir(base_dir)? {
509 let entry = entry?;
510 let path = entry.path();
511 if path.is_dir() {
512 if let Some(name) = path.file_name().and_then(|n| n.to_str()) {
513 run_dirs.push(name.to_string());
514 }
515 }
516 }
517
518 run_dirs.sort();
519 Ok(run_dirs)
520}
521
522#[cfg(test)]
523mod tests {
524 use super::*;
525 use tempfile::tempdir;
526
527 #[test]
528 fn test_run_store_init() {
529 let dir = tempdir().unwrap();
530 let run_id = uuid::Uuid::now_v7();
531 let store = RunStore::new(dir.path()).unwrap();
532 store.init_run(run_id, "Test task").unwrap();
533
534 let checkpoint = store.get_checkpoint().unwrap();
535 assert_eq!(checkpoint.run_id, run_id);
536 assert_eq!(checkpoint.task, "Test task");
537 assert_eq!(checkpoint.status, CheckpointStatus::Running);
538 }
539
540 #[test]
541 fn test_run_store_resume() {
542 let dir = tempdir().unwrap();
543 let run_id = uuid::Uuid::now_v7();
544 let store = RunStore::new(dir.path()).unwrap();
545 store.init_run(run_id, "Test task").unwrap();
546
547 let store2 = RunStore::new(dir.path()).unwrap();
549 let checkpoint = store2.open_run(run_id).unwrap().unwrap();
550 assert_eq!(checkpoint.run_id, run_id);
551 assert_eq!(checkpoint.task, "Test task");
552 }
553
554 #[test]
555 fn test_can_resume() {
556 let dir = tempdir().unwrap();
557 let run_id = uuid::Uuid::now_v7();
558 let store = RunStore::new(dir.path()).unwrap();
559 store.init_run(run_id, "Test task").unwrap();
560
561 assert!(store.can_resume());
562 }
563
564 #[test]
565 fn test_resume_appends_events() {
566 let dir = tempdir().unwrap();
570 let run_id = uuid::Uuid::now_v7();
571 let store = RunStore::new(dir.path()).unwrap();
572 store.init_run(run_id, "Test task").unwrap();
573
574 let store2 = RunStore::new(dir.path()).unwrap();
575 store2.open_run(run_id).unwrap().unwrap();
576
577 let evt = AgentEvent::Log {
579 run_id,
580 agent_id: None,
581 level: crate::contract::event::LogLevel::Info,
582 msg: "resume smoke test".to_string(),
583 };
584 store2
585 .append_event(&evt)
586 .expect("append_event after resume must succeed");
587
588 let log = store2.get_event_log().expect("read events.jsonl");
589 assert!(
590 log.iter().any(|e| matches!(
591 e,
592 AgentEvent::Log { msg, .. } if msg == "resume smoke test"
593 )),
594 "event written after open_run must appear in events.jsonl"
595 );
596 }
597
598 use crate::contract::backend::AgentStatus;
609 use crate::contract::ids::TokenUsage;
610 use std::collections::HashSet;
611
612 fn sample_token_usage() -> TokenUsage {
613 TokenUsage {
614 input: 10,
615 output: 5,
616 cache_read: 0,
617 cache_write: 0,
618 }
619 }
620
621 fn build_agent_done(
622 run_id: RunId,
623 agent_id: AgentId,
624 status: AgentStatus,
625 tokens: TokenUsage,
626 ) -> AgentEvent {
627 AgentEvent::AgentDone {
628 run_id,
629 agent_id,
630 status,
631 tokens,
632 elapsed_ms: 0,
633 name: None,
634 agent_seq: 0,
635 output: serde_json::Value::Null,
636 findings: vec![],
637 prompt: String::new(),
638 retry_count: 0,
639 ts: Default::default(),
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 =
646 std::fs::read_to_string(&path).unwrap_or_else(|e| panic!("read checkpoint.json: {e}"));
647 serde_json::from_str(&content).unwrap_or_else(|e| panic!("parse checkpoint.json: {e}"))
648 }
649
650 #[test]
653 fn upsert_agent_result_persists_to_disk() {
654 let dir = tempdir().unwrap();
658 let run_id = uuid::Uuid::now_v7();
659 let store = RunStore::new(dir.path()).unwrap();
660 store.init_run(run_id, "upsert test").unwrap();
661
662 let agent_id = uuid::Uuid::now_v7();
663 let cache = AgentResultCache {
664 agent_id,
665 phase_id: 1,
666 status: "ok".into(),
667 output: serde_json::json!({"v": 42}),
668 findings: vec![],
669 tokens: 100,
670 completed_at: 1_700_000_000,
671 cache_key_hash: Some("deadbeef".into()),
672 description: None,
673 role: None,
674 };
675 store.upsert_agent_result(&cache).unwrap();
676
677 let cp = store.get_checkpoint().expect("checkpoint present");
679 let cached = cp
680 .agent_results
681 .get(&agent_id)
682 .expect("agent_id indexed after upsert");
683 assert_eq!(cached.tokens, 100);
684 assert_eq!(cached.status, "ok");
685
686 let raw = read_raw_checkpoint(dir.path());
688 let ar = raw
689 .get("agent_results")
690 .and_then(|v| v.as_object())
691 .expect("agent_results object");
692 assert_eq!(ar.len(), 1, "exactly one agent cached on disk");
693 let entry = ar.values().next().expect("non-empty agent_results on disk");
694 assert_eq!(entry.get("tokens").and_then(|v| v.as_u64()), Some(100));
695 assert_eq!(entry.get("status").and_then(|v| v.as_str()), Some("ok"));
696 assert_eq!(
697 entry.get("cache_key_hash").and_then(|v| v.as_str()),
698 Some("deadbeef")
699 );
700
701 drop(store);
703 let reopened = RunStore::new(dir.path()).unwrap();
704 let restored = reopened.open_run(run_id).unwrap().unwrap();
705 assert!(
706 restored.agent_results.contains_key(&agent_id),
707 "upserted entry must survive close+reopen"
708 );
709 assert_eq!(restored.agent_results[&agent_id].tokens, 100);
710 }
711
712 #[test]
713 fn upsert_agent_result_updates_existing_entry() {
714 let dir = tempdir().unwrap();
717 let run_id = uuid::Uuid::now_v7();
718 let store = RunStore::new(dir.path()).unwrap();
719 store.init_run(run_id, "overwrite test").unwrap();
720
721 let agent_id = uuid::Uuid::now_v7();
722 let first = AgentResultCache {
723 agent_id,
724 phase_id: 1,
725 status: "ok".into(),
726 output: serde_json::json!("first"),
727 findings: vec![],
728 tokens: 10,
729 completed_at: 1,
730 cache_key_hash: None,
731 description: None,
732 role: None,
733 };
734 let second = AgentResultCache {
735 agent_id,
736 phase_id: 1,
737 status: "error".into(),
738 output: serde_json::json!("second"),
739 findings: vec![],
740 tokens: 99,
741 completed_at: 2,
742 cache_key_hash: None,
743 description: None,
744 role: None,
745 };
746 store.upsert_agent_result(&first).unwrap();
747 store.upsert_agent_result(&second).unwrap();
748
749 let cp = store.get_checkpoint().unwrap();
750 assert_eq!(cp.agent_results.len(), 1, "no duplicate entries");
751 let cached = &cp.agent_results[&agent_id];
752 assert_eq!(cached.status, "error");
753 assert_eq!(cached.tokens, 99);
754 assert_eq!(cached.completed_at, 2);
755
756 let raw = read_raw_checkpoint(dir.path());
758 let ar = raw
759 .get("agent_results")
760 .and_then(|v| v.as_object())
761 .unwrap();
762 assert_eq!(ar.len(), 1);
763 let entry = ar.values().next().unwrap();
764 assert_eq!(entry.get("tokens").and_then(|v| v.as_u64()), Some(99));
765 assert_eq!(entry.get("status").and_then(|v| v.as_str()), Some("error"));
766 }
767
768 #[test]
769 fn upsert_agent_result_noop_when_uninitialized() {
770 let dir = tempdir().unwrap();
774 let store = RunStore::new(dir.path()).unwrap();
775 assert!(store.get_checkpoint().is_none());
776 let cp_path = dir.path().join("checkpoint.json");
777 assert!(!cp_path.exists(), "no checkpoint.json before init");
778
779 let cache = AgentResultCache {
780 agent_id: uuid::Uuid::now_v7(),
781 phase_id: 1,
782 status: "ok".into(),
783 output: serde_json::json!(null),
784 findings: vec![],
785 tokens: 0,
786 completed_at: 0,
787 cache_key_hash: None,
788 description: None,
789 role: None,
790 };
791 store.upsert_agent_result(&cache).unwrap();
792 assert!(
793 !cp_path.exists(),
794 "upsert_agent_result must not create checkpoint.json before init_run"
795 );
796 assert!(store.get_checkpoint().is_none());
797 }
798
799 #[test]
800 fn upsert_agent_result_advances_updated_at() {
801 let dir = tempdir().unwrap();
804 let run_id = uuid::Uuid::now_v7();
805 let store = RunStore::new(dir.path()).unwrap();
806 store.init_run(run_id, "ts test").unwrap();
807 let before = store.get_checkpoint().unwrap().updated_at;
808
809 std::thread::sleep(std::time::Duration::from_millis(1100));
810
811 let cache = AgentResultCache {
812 agent_id: uuid::Uuid::now_v7(),
813 phase_id: 1,
814 status: "ok".into(),
815 output: serde_json::json!(null),
816 findings: vec![],
817 tokens: 0,
818 completed_at: 0,
819 cache_key_hash: None,
820 description: None,
821 role: None,
822 };
823 store.upsert_agent_result(&cache).unwrap();
824 let after = store.get_checkpoint().unwrap().updated_at;
825 assert!(
826 after > before,
827 "updated_at must advance after upsert (before={before}, after={after})"
828 );
829 }
830
831 #[test]
834 fn cancel_persists_cancelled_status_to_disk() {
835 let dir = tempdir().unwrap();
840 let run_id = uuid::Uuid::now_v7();
841 let store = RunStore::new(dir.path()).unwrap();
842 store.init_run(run_id, "cancel me").unwrap();
843 assert!(store.can_resume());
844
845 store.cancel().unwrap();
846
847 let cp = store.get_checkpoint().unwrap();
849 assert_eq!(cp.status, CheckpointStatus::Cancelled);
850 assert!(!store.can_resume());
851
852 let raw = read_raw_checkpoint(dir.path());
854 assert_eq!(
855 raw.get("status").and_then(|v| v.as_str()),
856 Some("cancelled")
857 );
858
859 drop(store);
861 let reopened = RunStore::new(dir.path()).unwrap();
862 let restored = reopened.open_run(run_id).unwrap().unwrap();
863 assert_eq!(restored.status, CheckpointStatus::Cancelled);
864 assert!(!reopened.can_resume());
865 }
866
867 #[test]
868 fn cancel_is_idempotent() {
869 let dir = tempdir().unwrap();
874 let run_id = uuid::Uuid::now_v7();
875 let store = RunStore::new(dir.path()).unwrap();
876 store.init_run(run_id, "double cancel").unwrap();
877
878 store.cancel().unwrap();
879 let after_first = store.get_checkpoint().unwrap().updated_at;
880 std::thread::sleep(std::time::Duration::from_millis(1100));
881
882 store.cancel().expect("second cancel must succeed");
883 let after_second = store.get_checkpoint().unwrap().updated_at;
884
885 assert_eq!(
886 store.get_checkpoint().unwrap().status,
887 CheckpointStatus::Cancelled
888 );
889 assert!(
890 after_second >= after_first,
891 "updated_at must not regress (was {after_first}, now {after_second})"
892 );
893
894 let raw = read_raw_checkpoint(dir.path());
895 assert_eq!(
896 raw.get("status").and_then(|v| v.as_str()),
897 Some("cancelled")
898 );
899 }
900
901 #[test]
902 fn cancel_before_init_is_safe_noop() {
903 let dir = tempdir().unwrap();
907 let store = RunStore::new(dir.path()).unwrap();
908 assert!(store.get_checkpoint().is_none());
909 store.cancel().expect("cancel before init must succeed");
910 assert!(store.get_checkpoint().is_none());
911 assert!(
912 !dir.path().join("checkpoint.json").exists(),
913 "cancel before init must not create checkpoint.json"
914 );
915 }
916
917 #[test]
918 fn cancel_preserves_agent_results_and_findings() {
919 let dir = tempdir().unwrap();
923 let run_id = uuid::Uuid::now_v7();
924 let store = RunStore::new(dir.path()).unwrap();
925 store.init_run(run_id, "preserve").unwrap();
926
927 let agent_id = uuid::Uuid::now_v7();
928 let cache = AgentResultCache {
929 agent_id,
930 phase_id: 1,
931 status: "ok".into(),
932 output: serde_json::json!({"x": 1}),
933 findings: vec![],
934 tokens: 250,
935 completed_at: 7,
936 cache_key_hash: Some("hash-1".into()),
937 description: None,
938 role: None,
939 };
940 store.upsert_agent_result(&cache).unwrap();
941 let before = store.get_checkpoint().unwrap();
942
943 store.cancel().unwrap();
944 let after = store.get_checkpoint().unwrap();
945
946 assert_eq!(after.status, CheckpointStatus::Cancelled);
947 assert_eq!(after.agent_results.len(), 1);
948 assert_eq!(after.agent_results[&agent_id].tokens, 250);
949 assert_eq!(
950 after.agent_results[&agent_id].cache_key_hash.as_deref(),
951 Some("hash-1")
952 );
953 assert_eq!(after.total_tokens, before.total_tokens);
954 }
955
956 #[test]
959 fn agent_done_persists_snake_case_status_for_each_variant() {
960 let dir = tempdir().unwrap();
965 let run_id = uuid::Uuid::now_v7();
966 let store = RunStore::new(dir.path()).unwrap();
967 store.init_run(run_id, "F5 variants").unwrap();
968
969 let cases: Vec<(AgentStatus, &str)> = vec![
970 (AgentStatus::Ok, "ok"),
971 (AgentStatus::Error, "error"),
972 (AgentStatus::Cancelled, "cancelled"),
973 (AgentStatus::TimedOut, "timed_out"),
974 ];
975 for (status, expected) in &cases {
976 let agent_id = uuid::Uuid::now_v7();
977 let evt = build_agent_done(run_id, agent_id, status.clone(), sample_token_usage());
978 store.append_event(&evt).unwrap();
979
980 let raw = read_raw_checkpoint(dir.path());
981 let ar = raw
982 .get("agent_results")
983 .and_then(|v| v.as_object())
984 .expect("agent_results object");
985 let entry = ar
986 .values()
987 .find(|v| {
988 v.get("agent_id").and_then(|id| id.as_str()) == Some(&agent_id.to_string())
989 })
990 .unwrap_or_else(|| panic!("entry for {agent_id} missing"));
991 let persisted = entry
992 .get("status")
993 .and_then(|v| v.as_str())
994 .unwrap_or_else(|| panic!("status missing for {status:?}"));
995 assert_eq!(
996 persisted, *expected,
997 "AgentDone({status:?}) must persist status={expected:?} (snake_case); \
998 got {persisted:?}. If this fails with \"timedout\" for TimedOut, \
999 F5 has regressed to Debug formatting."
1000 );
1001 }
1002 }
1003
1004 #[test]
1005 fn agent_done_timed_out_persists_with_underscore_not_collapsed() {
1006 let dir = tempdir().unwrap();
1011 let run_id = uuid::Uuid::now_v7();
1012 let store = RunStore::new(dir.path()).unwrap();
1013 store.init_run(run_id, "timed-out guard").unwrap();
1014
1015 let agent_id = uuid::Uuid::now_v7();
1016 let evt = build_agent_done(
1017 run_id,
1018 agent_id,
1019 AgentStatus::TimedOut,
1020 sample_token_usage(),
1021 );
1022 store.append_event(&evt).unwrap();
1023
1024 let raw = read_raw_checkpoint(dir.path());
1025 let ar = raw
1026 .get("agent_results")
1027 .and_then(|v| v.as_object())
1028 .unwrap();
1029 let entry = ar.values().next().expect("entry exists");
1030 let persisted = entry.get("status").and_then(|v| v.as_str()).unwrap();
1031
1032 assert_eq!(
1033 persisted, "timed_out",
1034 "AgentDone(TimedOut) must persist \"timed_out\" with an underscore; got {persisted:?}"
1035 );
1036 assert_ne!(
1037 persisted, "timedout",
1038 "AgentDone(TimedOut) must NOT collapse to Debug-lowercased \"timedout\""
1039 );
1040 }
1041
1042 #[test]
1043 fn agent_done_then_reopen_restores_snake_case_status() {
1044 let dir = tempdir().unwrap();
1049 let run_id = uuid::Uuid::now_v7();
1050 let store = RunStore::new(dir.path()).unwrap();
1051 store.init_run(run_id, "round-trip").unwrap();
1052
1053 let agent_id = uuid::Uuid::now_v7();
1054 let evt = build_agent_done(
1055 run_id,
1056 agent_id,
1057 AgentStatus::Cancelled,
1058 TokenUsage {
1059 input: 1,
1060 output: 2,
1061 cache_read: 0,
1062 cache_write: 0,
1063 },
1064 );
1065 store.append_event(&evt).unwrap();
1066 drop(store);
1067
1068 let reopened = RunStore::new(dir.path()).unwrap();
1069 let cp = reopened.open_run(run_id).unwrap().unwrap();
1070 let cached = cp
1071 .agent_results
1072 .get(&agent_id)
1073 .expect("agent cached on disk");
1074 assert_eq!(cached.status, "cancelled");
1075 assert_eq!(cached.tokens, 3);
1076 }
1077
1078 #[test]
1081 fn open_run_with_corrupt_checkpoint_returns_invalid_data() {
1082 let dir = tempdir().unwrap();
1086 std::fs::create_dir_all(dir.path()).unwrap();
1087 std::fs::write(
1088 dir.path().join("checkpoint.json"),
1089 b"{ this is not valid json",
1090 )
1091 .unwrap();
1092
1093 let store = RunStore::new(dir.path()).unwrap();
1094 let err = store
1095 .open_run(uuid::Uuid::now_v7())
1096 .expect_err("corrupt JSON must surface as an io::Error");
1097 assert_eq!(
1098 err.kind(),
1099 std::io::ErrorKind::InvalidData,
1100 "corrupt checkpoint must map to InvalidData via serde_to_io; got {:?}",
1101 err.kind()
1102 );
1103 }
1104
1105 #[test]
1106 fn open_run_with_wrong_typed_checkpoint_returns_invalid_data() {
1107 let dir = tempdir().unwrap();
1110 std::fs::create_dir_all(dir.path()).unwrap();
1111 std::fs::write(
1114 dir.path().join("checkpoint.json"),
1115 br#"{"run_id":"00000000-0000-0000-0000-000000000000","status":"running"}"#,
1116 )
1117 .unwrap();
1118
1119 let store = RunStore::new(dir.path()).unwrap();
1120 let err = store
1121 .open_run(uuid::Uuid::now_v7())
1122 .expect_err("missing-field JSON must surface as an io::Error");
1123 assert_eq!(err.kind(), std::io::ErrorKind::InvalidData);
1124 }
1125
1126 #[test]
1127 fn get_event_log_with_corrupt_line_returns_invalid_data() {
1128 let dir = tempdir().unwrap();
1133 std::fs::create_dir_all(dir.path()).unwrap();
1134 std::fs::write(dir.path().join("events.jsonl"), b"not-json\n").unwrap();
1135
1136 let store = RunStore::new(dir.path()).unwrap();
1137 let err = store
1138 .get_event_log()
1139 .expect_err("corrupt event line must surface as an io::Error");
1140 assert_eq!(
1141 err.kind(),
1142 std::io::ErrorKind::InvalidData,
1143 "corrupt event line must map to InvalidData via serde_to_io; got {:?}",
1144 err.kind()
1145 );
1146 }
1147
1148 #[test]
1151 fn as_str_variants_round_trip_through_checkpoint_pipeline() {
1152 let dir = tempdir().unwrap();
1157 let run_id = uuid::Uuid::now_v7();
1158 let store = RunStore::new(dir.path()).unwrap();
1159 store.init_run(run_id, "round-trip property").unwrap();
1160
1161 let variants = [
1162 AgentStatus::Ok,
1163 AgentStatus::Error,
1164 AgentStatus::Cancelled,
1165 AgentStatus::TimedOut,
1166 ];
1167 let mut seen: HashSet<String> = HashSet::new();
1168
1169 for variant in &variants {
1170 let agent_id = uuid::Uuid::now_v7();
1171 let evt = build_agent_done(run_id, agent_id, variant.clone(), sample_token_usage());
1172 store.append_event(&evt).unwrap();
1173
1174 let cp = store.get_checkpoint().unwrap();
1175 let cached = cp
1176 .agent_results
1177 .get(&agent_id)
1178 .expect("entry for {agent_id}");
1179 assert_eq!(
1180 cached.status,
1181 variant.as_str(),
1182 "{variant:?}.as_str() must round-trip via append_event→update_from_event"
1183 );
1184 assert!(
1186 seen.insert(cached.status.clone()),
1187 "duplicate status {cached_status:?} persisted for {variant:?}",
1188 cached_status = cached.status
1189 );
1190 }
1191 }
1192}