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
98#[derive(Debug)]
100pub struct RunStore {
101 run_dir: PathBuf,
102 checkpoint: RwLock<Option<RunCheckpoint>>,
103 events_file: RwLock<Option<File>>,
104}
105
106impl RunStore {
107 pub fn new(run_dir: &Path) -> Result<Arc<Self>, std::io::Error> {
109 tracing::debug!(path = %run_dir.display(), "creating RunStore");
110 fs::create_dir_all(run_dir)?;
111
112 let store = Arc::new(Self {
113 run_dir: run_dir.to_path_buf(),
114 checkpoint: RwLock::new(None),
115 events_file: RwLock::new(None),
116 });
117
118 Ok(store)
119 }
120
121 pub fn upsert_agent_result(&self, cache: &AgentResultCache) -> Result<(), std::io::Error> {
124 let mut guard = self.checkpoint.write().unwrap();
125 if let Some(ref mut checkpoint) = *guard {
126 checkpoint
127 .agent_results
128 .insert(cache.agent_id, cache.clone());
129 checkpoint.updated_at = current_timestamp();
130 let cp = checkpoint.clone();
131 drop(guard);
132 let cp_path = self.run_dir.join("checkpoint.json");
133 let content = serde_json::to_string_pretty(&cp)
134 .map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e))?;
135 fs::write(&cp_path, content)?;
136 }
137 Ok(())
138 }
139
140 pub fn init_run(&self, run_id: RunId, task: &str) -> Result<(), std::io::Error> {
142 tracing::info!(%run_id, %task, "initializing run store");
143 let checkpoint = RunCheckpoint {
144 run_id,
145 task: task.to_string(),
146 status: CheckpointStatus::Running,
147 current_phase: 0,
148 completed_phases: vec![],
149 agent_results: HashMap::new(),
150 findings: vec![],
151 total_tokens: 0,
152 created_at: current_timestamp(),
153 updated_at: current_timestamp(),
154 workflow_meta: None,
155 started_agent_ids: vec![],
156 };
157
158 self.save_checkpoint(&checkpoint)?;
160
161 let events_path = self.run_dir.join("events.jsonl");
163 let events_file = OpenOptions::new()
164 .create(true)
165 .append(true)
166 .open(events_path)?;
167
168 let mut checkpoint_guard = self.checkpoint.write().unwrap();
169 *checkpoint_guard = Some(checkpoint);
170
171 let mut events_guard = self.events_file.write().unwrap();
172 *events_guard = Some(events_file);
173
174 Ok(())
175 }
176
177 pub fn init_run_with_meta(
179 &self,
180 run_id: RunId,
181 task: &str,
182 workflow_meta: serde_json::Value,
183 ) -> Result<(), std::io::Error> {
184 tracing::info!(%run_id, %task, "initializing run store with meta");
185 let checkpoint = RunCheckpoint {
186 run_id,
187 task: task.to_string(),
188 status: CheckpointStatus::Running,
189 current_phase: 0,
190 completed_phases: vec![],
191 agent_results: HashMap::new(),
192 findings: vec![],
193 total_tokens: 0,
194 created_at: current_timestamp(),
195 updated_at: current_timestamp(),
196 workflow_meta: Some(workflow_meta),
197 started_agent_ids: vec![],
198 };
199
200 self.save_checkpoint(&checkpoint)?;
201
202 let events_path = self.run_dir.join("events.jsonl");
203 let events_file = OpenOptions::new()
204 .create(true)
205 .append(true)
206 .open(events_path)?;
207
208 let mut checkpoint_guard = self.checkpoint.write().unwrap();
209 *checkpoint_guard = Some(checkpoint);
210
211 let mut events_guard = self.events_file.write().unwrap();
212 *events_guard = Some(events_file);
213
214 Ok(())
215 }
216
217 pub fn open_run(&self, _run_id: RunId) -> Result<Option<RunCheckpoint>, std::io::Error> {
219 tracing::debug!(%_run_id, "opening existing run");
220 let checkpoint_path = self.run_dir.join("checkpoint.json");
221
222 if !checkpoint_path.exists() {
223 return Ok(None);
224 }
225
226 let content = fs::read_to_string(&checkpoint_path)?;
227 let checkpoint: RunCheckpoint = serde_json::from_str(&content)
228 .map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e))?;
229
230 let events_path = self.run_dir.join("events.jsonl");
235 let events_file = OpenOptions::new()
236 .read(true)
237 .append(true)
238 .open(events_path)?;
239
240 let mut checkpoint_guard = self.checkpoint.write().unwrap();
241 *checkpoint_guard = Some(checkpoint.clone());
242
243 let mut events_guard = self.events_file.write().unwrap();
244 *events_guard = Some(events_file);
245
246 Ok(Some(checkpoint))
247 }
248
249 pub fn append_event(&self, event: &AgentEvent) -> Result<(), std::io::Error> {
251 let json = serde_json::to_string(event)
252 .map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e))?;
253
254 let mut events_guard = self.events_file.write().unwrap();
255 if let Some(ref mut file) = *events_guard {
256 writeln!(file, "{}", json)?;
257 file.flush()?;
258 }
259
260 self.update_from_event(event);
262
263 Ok(())
264 }
265
266 fn update_from_event(&self, event: &AgentEvent) {
268 let mut checkpoint_guard = self.checkpoint.write().unwrap();
269 if let Some(ref mut checkpoint) = *checkpoint_guard {
270 match event {
271 AgentEvent::AgentDone {
272 agent_id,
273 status,
274 tokens,
275 ..
276 } => {
277 let existing = checkpoint.agent_results.get(agent_id);
278 let cache = AgentResultCache {
279 agent_id: *agent_id,
280 phase_id: existing.map(|c| c.phase_id).unwrap_or(0),
281 status: status.as_str().to_string(),
282 output: existing
283 .map(|c| c.output.clone())
284 .unwrap_or(serde_json::Value::Null),
285 findings: existing.map(|c| c.findings.clone()).unwrap_or_default(),
286 tokens: tokens.total(),
287 completed_at: existing
288 .map(|c| c.completed_at)
289 .unwrap_or(current_timestamp()),
290 cache_key_hash: existing.and_then(|c| c.cache_key_hash.clone()),
291 description: existing.and_then(|c| c.description.clone()),
292 role: existing.and_then(|c| c.role.clone()),
293 };
294 checkpoint.agent_results.insert(*agent_id, cache);
295 checkpoint.total_tokens += tokens.total();
296 }
297 AgentEvent::AgentStarted { agent_id, .. } => {
298 if !checkpoint.started_agent_ids.contains(agent_id) {
299 checkpoint.started_agent_ids.push(*agent_id);
300 }
301 }
302 AgentEvent::PhaseDone { phase_id, .. } => {
303 if *phase_id > 0 {
304 checkpoint.current_phase = *phase_id;
305 }
306 }
307 AgentEvent::RunDone {
308 status,
309 total_tokens,
310 ..
311 } => {
312 checkpoint.status = match status {
313 crate::contract::event::RunStatus::Completed => CheckpointStatus::Completed,
314 crate::contract::event::RunStatus::Failed => CheckpointStatus::Failed,
315 crate::contract::event::RunStatus::Cancelled => CheckpointStatus::Cancelled,
316 crate::contract::event::RunStatus::Partial => CheckpointStatus::Running,
317 };
318 let t = total_tokens.total();
321 if t > 0 {
322 checkpoint.total_tokens = t;
323 }
324 }
325 _ => {}
326 }
327 checkpoint.updated_at = current_timestamp();
328
329 if let Err(e) = self.write_checkpoint_to_disk(checkpoint) {
331 tracing::warn!(error = %e, "failed to save checkpoint");
332 }
333 }
334 }
335
336 fn write_checkpoint_to_disk(&self, checkpoint: &RunCheckpoint) -> Result<(), std::io::Error> {
338 let checkpoint_path = self.run_dir.join("checkpoint.json");
339 let temp_path = self.run_dir.join("checkpoint.json.tmp");
340 let content = serde_json::to_string_pretty(checkpoint)
341 .map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e))?;
342 std::fs::write(&temp_path, &content)?;
343 std::fs::rename(&temp_path, &checkpoint_path)?;
344 Ok(())
345 }
346
347 pub fn save_checkpoint(&self, checkpoint: &RunCheckpoint) -> Result<(), std::io::Error> {
349 let checkpoint_path = self.run_dir.join("checkpoint.json");
350 let temp_path = self.run_dir.join("checkpoint.json.tmp");
351 let content = serde_json::to_string_pretty(checkpoint)
352 .map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e))?;
353 std::fs::write(&temp_path, &content)?;
354 std::fs::rename(&temp_path, &checkpoint_path)?;
355
356 let mut checkpoint_guard = self.checkpoint.write().unwrap();
357 *checkpoint_guard = Some(checkpoint.clone());
358
359 Ok(())
360 }
361
362 pub fn get_checkpoint(&self) -> Option<RunCheckpoint> {
364 let guard = self.checkpoint.read().unwrap();
365 guard.clone()
366 }
367
368 pub fn get_findings(&self) -> Vec<Finding> {
370 let guard = self.checkpoint.read().unwrap();
371 guard
372 .as_ref()
373 .map(|c| c.findings.clone())
374 .unwrap_or_default()
375 }
376
377 pub fn get_event_log(&self) -> Result<Vec<AgentEvent>, std::io::Error> {
379 let events_path = self.run_dir.join("events.jsonl");
380 let file = File::open(events_path)?;
381 let reader = BufReader::new(file);
382 let mut events = Vec::new();
383
384 for line in reader.lines() {
385 let line = line?;
386 if line.trim().is_empty() {
387 continue;
388 }
389 let event: AgentEvent = serde_json::from_str(&line)
390 .map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e))?;
391 events.push(event);
392 }
393
394 Ok(events)
395 }
396
397 pub fn can_resume(&self) -> bool {
399 let guard = self.checkpoint.read().unwrap();
400 matches!(
401 guard.as_ref().map(|c| c.status.clone()),
402 Some(CheckpointStatus::Running)
403 )
404 }
405
406 pub fn reset_status_to_running(&self) -> Result<(), std::io::Error> {
411 let mut guard = self.checkpoint.write().unwrap();
412
413 if guard.is_none() {
414 let checkpoint_path = self.run_dir.join("checkpoint.json");
415 if !checkpoint_path.exists() {
416 return Ok(());
417 }
418 let content = fs::read_to_string(&checkpoint_path)?;
419 let cp: RunCheckpoint = serde_json::from_str(&content)
420 .map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e))?;
421 *guard = Some(cp);
422 }
423
424 if let Some(ref mut checkpoint) = *guard {
425 checkpoint.status = CheckpointStatus::Running;
426 checkpoint.updated_at = current_timestamp();
427 let cp_clone = checkpoint.clone();
428 drop(guard);
429 self.write_checkpoint_to_disk(&cp_clone)?;
430 }
431 Ok(())
432 }
433
434 pub fn cancel(&self) -> Result<(), std::io::Error> {
436 tracing::info!("cancelling run");
437 let mut guard = self.checkpoint.write().unwrap();
438
439 if guard.is_none() {
447 let checkpoint_path = self.run_dir.join("checkpoint.json");
448 if !checkpoint_path.exists() {
449 return Ok(());
450 }
451 let content = fs::read_to_string(&checkpoint_path)?;
452 let cp: RunCheckpoint = serde_json::from_str(&content)
453 .map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e))?;
454 *guard = Some(cp);
455 }
456
457 if let Some(ref mut checkpoint) = *guard {
458 checkpoint.status = CheckpointStatus::Cancelled;
459 checkpoint.updated_at = current_timestamp();
460 drop(guard);
461 let guard = self.checkpoint.read().unwrap();
462 if let Some(ref c) = *guard {
463 let checkpoint_path = self.run_dir.join("checkpoint.json");
464 let content = serde_json::to_string_pretty(c)
465 .map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e))?;
466 fs::write(&checkpoint_path, content)?;
467 }
468 }
469 Ok(())
470 }
471}
472
473fn current_timestamp() -> u64 {
475 SystemTime::now()
476 .duration_since(UNIX_EPOCH)
477 .map(|d| d.as_secs())
478 .unwrap_or(0)
479}
480
481use std::sync::OnceLock;
486
487static RUN_STORES: OnceLock<dashmap::DashMap<String, Arc<RunStore>>> = OnceLock::new();
488
489fn get_run_stores() -> &'static dashmap::DashMap<String, Arc<RunStore>> {
491 RUN_STORES.get_or_init(dashmap::DashMap::new)
492}
493
494pub fn get_run_store(run_dir_name: &str, base_dir: &Path) -> Result<Arc<RunStore>, std::io::Error> {
496 let stores = get_run_stores();
497
498 if let Some(store) = stores.get(run_dir_name) {
499 return Ok(store.clone());
500 }
501
502 let run_dir = base_dir.join(run_dir_name);
503 let store = RunStore::new(&run_dir)?;
504 stores.insert(run_dir_name.to_string(), store.clone());
505
506 Ok(store)
507}
508
509pub fn list_runs(base_dir: &Path) -> Result<Vec<String>, std::io::Error> {
511 if !base_dir.exists() {
512 return Ok(vec![]);
513 }
514
515 let mut run_dirs = Vec::new();
516 for entry in fs::read_dir(base_dir)? {
517 let entry = entry?;
518 let path = entry.path();
519 if path.is_dir() {
520 if let Some(name) = path.file_name().and_then(|n| n.to_str()) {
521 run_dirs.push(name.to_string());
522 }
523 }
524 }
525
526 run_dirs.sort();
527 Ok(run_dirs)
528}
529
530#[cfg(test)]
531mod tests {
532 use super::*;
533 use tempfile::tempdir;
534
535 #[test]
536 fn test_run_store_init() {
537 let dir = tempdir().unwrap();
538 let run_id = uuid::Uuid::now_v7();
539 let store = RunStore::new(dir.path()).unwrap();
540 store.init_run(run_id, "Test task").unwrap();
541
542 let checkpoint = store.get_checkpoint().unwrap();
543 assert_eq!(checkpoint.run_id, run_id);
544 assert_eq!(checkpoint.task, "Test task");
545 assert_eq!(checkpoint.status, CheckpointStatus::Running);
546 }
547
548 #[test]
549 fn test_run_store_resume() {
550 let dir = tempdir().unwrap();
551 let run_id = uuid::Uuid::now_v7();
552 let store = RunStore::new(dir.path()).unwrap();
553 store.init_run(run_id, "Test task").unwrap();
554
555 let store2 = RunStore::new(dir.path()).unwrap();
557 let checkpoint = store2.open_run(run_id).unwrap().unwrap();
558 assert_eq!(checkpoint.run_id, run_id);
559 assert_eq!(checkpoint.task, "Test task");
560 }
561
562 #[test]
563 fn test_can_resume() {
564 let dir = tempdir().unwrap();
565 let run_id = uuid::Uuid::now_v7();
566 let store = RunStore::new(dir.path()).unwrap();
567 store.init_run(run_id, "Test task").unwrap();
568
569 assert!(store.can_resume());
570 }
571
572 #[test]
573 fn test_resume_appends_events() {
574 let dir = tempdir().unwrap();
578 let run_id = uuid::Uuid::now_v7();
579 let store = RunStore::new(dir.path()).unwrap();
580 store.init_run(run_id, "Test task").unwrap();
581
582 let store2 = RunStore::new(dir.path()).unwrap();
583 store2.open_run(run_id).unwrap().unwrap();
584
585 let evt = AgentEvent::Log {
587 run_id,
588 agent_id: None,
589 level: crate::contract::event::LogLevel::Info,
590 msg: "resume smoke test".to_string(),
591 };
592 store2
593 .append_event(&evt)
594 .expect("append_event after resume must succeed");
595
596 let log = store2.get_event_log().expect("read events.jsonl");
597 assert!(
598 log.iter().any(|e| matches!(
599 e,
600 AgentEvent::Log { msg, .. } if msg == "resume smoke test"
601 )),
602 "event written after open_run must appear in events.jsonl"
603 );
604 }
605
606 use crate::contract::backend::AgentStatus;
617 use crate::contract::ids::TokenUsage;
618 use std::collections::HashSet;
619
620 fn sample_token_usage() -> TokenUsage {
621 TokenUsage {
622 input: 10,
623 output: 5,
624 cache_read: 0,
625 cache_write: 0,
626 }
627 }
628
629 fn build_agent_done(
630 run_id: RunId,
631 agent_id: AgentId,
632 status: AgentStatus,
633 tokens: TokenUsage,
634 ) -> AgentEvent {
635 AgentEvent::AgentDone {
636 run_id,
637 agent_id,
638 status,
639 tokens,
640 elapsed_ms: 0,
641 name: None,
642 agent_seq: 0,
643 output: serde_json::Value::Null,
644 findings: vec![],
645 prompt: String::new(),
646 retry_count: 0,
647 ts: Default::default(),
648 }
649 }
650
651 fn read_raw_checkpoint(run_dir: &Path) -> serde_json::Value {
652 let path = run_dir.join("checkpoint.json");
653 let content =
654 std::fs::read_to_string(&path).unwrap_or_else(|e| panic!("read checkpoint.json: {e}"));
655 serde_json::from_str(&content).unwrap_or_else(|e| panic!("parse checkpoint.json: {e}"))
656 }
657
658 #[test]
661 fn upsert_agent_result_persists_to_disk() {
662 let dir = tempdir().unwrap();
666 let run_id = uuid::Uuid::now_v7();
667 let store = RunStore::new(dir.path()).unwrap();
668 store.init_run(run_id, "upsert test").unwrap();
669
670 let agent_id = uuid::Uuid::now_v7();
671 let cache = AgentResultCache {
672 agent_id,
673 phase_id: 1,
674 status: "ok".into(),
675 output: serde_json::json!({"v": 42}),
676 findings: vec![],
677 tokens: 100,
678 completed_at: 1_700_000_000,
679 cache_key_hash: Some("deadbeef".into()),
680 description: None,
681 role: None,
682 };
683 store.upsert_agent_result(&cache).unwrap();
684
685 let cp = store.get_checkpoint().expect("checkpoint present");
687 let cached = cp
688 .agent_results
689 .get(&agent_id)
690 .expect("agent_id indexed after upsert");
691 assert_eq!(cached.tokens, 100);
692 assert_eq!(cached.status, "ok");
693
694 let raw = read_raw_checkpoint(dir.path());
696 let ar = raw
697 .get("agent_results")
698 .and_then(|v| v.as_object())
699 .expect("agent_results object");
700 assert_eq!(ar.len(), 1, "exactly one agent cached on disk");
701 let entry = ar.values().next().expect("non-empty agent_results on disk");
702 assert_eq!(entry.get("tokens").and_then(|v| v.as_u64()), Some(100));
703 assert_eq!(entry.get("status").and_then(|v| v.as_str()), Some("ok"));
704 assert_eq!(
705 entry.get("cache_key_hash").and_then(|v| v.as_str()),
706 Some("deadbeef")
707 );
708
709 drop(store);
711 let reopened = RunStore::new(dir.path()).unwrap();
712 let restored = reopened.open_run(run_id).unwrap().unwrap();
713 assert!(
714 restored.agent_results.contains_key(&agent_id),
715 "upserted entry must survive close+reopen"
716 );
717 assert_eq!(restored.agent_results[&agent_id].tokens, 100);
718 }
719
720 #[test]
721 fn upsert_agent_result_updates_existing_entry() {
722 let dir = tempdir().unwrap();
725 let run_id = uuid::Uuid::now_v7();
726 let store = RunStore::new(dir.path()).unwrap();
727 store.init_run(run_id, "overwrite test").unwrap();
728
729 let agent_id = uuid::Uuid::now_v7();
730 let first = AgentResultCache {
731 agent_id,
732 phase_id: 1,
733 status: "ok".into(),
734 output: serde_json::json!("first"),
735 findings: vec![],
736 tokens: 10,
737 completed_at: 1,
738 cache_key_hash: None,
739 description: None,
740 role: None,
741 };
742 let second = AgentResultCache {
743 agent_id,
744 phase_id: 1,
745 status: "error".into(),
746 output: serde_json::json!("second"),
747 findings: vec![],
748 tokens: 99,
749 completed_at: 2,
750 cache_key_hash: None,
751 description: None,
752 role: None,
753 };
754 store.upsert_agent_result(&first).unwrap();
755 store.upsert_agent_result(&second).unwrap();
756
757 let cp = store.get_checkpoint().unwrap();
758 assert_eq!(cp.agent_results.len(), 1, "no duplicate entries");
759 let cached = &cp.agent_results[&agent_id];
760 assert_eq!(cached.status, "error");
761 assert_eq!(cached.tokens, 99);
762 assert_eq!(cached.completed_at, 2);
763
764 let raw = read_raw_checkpoint(dir.path());
766 let ar = raw
767 .get("agent_results")
768 .and_then(|v| v.as_object())
769 .unwrap();
770 assert_eq!(ar.len(), 1);
771 let entry = ar.values().next().unwrap();
772 assert_eq!(entry.get("tokens").and_then(|v| v.as_u64()), Some(99));
773 assert_eq!(entry.get("status").and_then(|v| v.as_str()), Some("error"));
774 }
775
776 #[test]
777 fn upsert_agent_result_noop_when_uninitialized() {
778 let dir = tempdir().unwrap();
782 let store = RunStore::new(dir.path()).unwrap();
783 assert!(store.get_checkpoint().is_none());
784 let cp_path = dir.path().join("checkpoint.json");
785 assert!(!cp_path.exists(), "no checkpoint.json before init");
786
787 let cache = AgentResultCache {
788 agent_id: uuid::Uuid::now_v7(),
789 phase_id: 1,
790 status: "ok".into(),
791 output: serde_json::json!(null),
792 findings: vec![],
793 tokens: 0,
794 completed_at: 0,
795 cache_key_hash: None,
796 description: None,
797 role: None,
798 };
799 store.upsert_agent_result(&cache).unwrap();
800 assert!(
801 !cp_path.exists(),
802 "upsert_agent_result must not create checkpoint.json before init_run"
803 );
804 assert!(store.get_checkpoint().is_none());
805 }
806
807 #[test]
808 fn upsert_agent_result_advances_updated_at() {
809 let dir = tempdir().unwrap();
812 let run_id = uuid::Uuid::now_v7();
813 let store = RunStore::new(dir.path()).unwrap();
814 store.init_run(run_id, "ts test").unwrap();
815 let before = store.get_checkpoint().unwrap().updated_at;
816
817 std::thread::sleep(std::time::Duration::from_millis(1100));
818
819 let cache = AgentResultCache {
820 agent_id: uuid::Uuid::now_v7(),
821 phase_id: 1,
822 status: "ok".into(),
823 output: serde_json::json!(null),
824 findings: vec![],
825 tokens: 0,
826 completed_at: 0,
827 cache_key_hash: None,
828 description: None,
829 role: None,
830 };
831 store.upsert_agent_result(&cache).unwrap();
832 let after = store.get_checkpoint().unwrap().updated_at;
833 assert!(
834 after > before,
835 "updated_at must advance after upsert (before={before}, after={after})"
836 );
837 }
838
839 #[test]
842 fn cancel_persists_cancelled_status_to_disk() {
843 let dir = tempdir().unwrap();
848 let run_id = uuid::Uuid::now_v7();
849 let store = RunStore::new(dir.path()).unwrap();
850 store.init_run(run_id, "cancel me").unwrap();
851 assert!(store.can_resume());
852
853 store.cancel().unwrap();
854
855 let cp = store.get_checkpoint().unwrap();
857 assert_eq!(cp.status, CheckpointStatus::Cancelled);
858 assert!(!store.can_resume());
859
860 let raw = read_raw_checkpoint(dir.path());
862 assert_eq!(
863 raw.get("status").and_then(|v| v.as_str()),
864 Some("cancelled")
865 );
866
867 drop(store);
869 let reopened = RunStore::new(dir.path()).unwrap();
870 let restored = reopened.open_run(run_id).unwrap().unwrap();
871 assert_eq!(restored.status, CheckpointStatus::Cancelled);
872 assert!(!reopened.can_resume());
873 }
874
875 #[test]
876 fn cancel_is_idempotent() {
877 let dir = tempdir().unwrap();
882 let run_id = uuid::Uuid::now_v7();
883 let store = RunStore::new(dir.path()).unwrap();
884 store.init_run(run_id, "double cancel").unwrap();
885
886 store.cancel().unwrap();
887 let after_first = store.get_checkpoint().unwrap().updated_at;
888 std::thread::sleep(std::time::Duration::from_millis(1100));
889
890 store.cancel().expect("second cancel must succeed");
891 let after_second = store.get_checkpoint().unwrap().updated_at;
892
893 assert_eq!(
894 store.get_checkpoint().unwrap().status,
895 CheckpointStatus::Cancelled
896 );
897 assert!(
898 after_second >= after_first,
899 "updated_at must not regress (was {after_first}, now {after_second})"
900 );
901
902 let raw = read_raw_checkpoint(dir.path());
903 assert_eq!(
904 raw.get("status").and_then(|v| v.as_str()),
905 Some("cancelled")
906 );
907 }
908
909 #[test]
910 fn cancel_before_init_is_safe_noop() {
911 let dir = tempdir().unwrap();
915 let store = RunStore::new(dir.path()).unwrap();
916 assert!(store.get_checkpoint().is_none());
917 store.cancel().expect("cancel before init must succeed");
918 assert!(store.get_checkpoint().is_none());
919 assert!(
920 !dir.path().join("checkpoint.json").exists(),
921 "cancel before init must not create checkpoint.json"
922 );
923 }
924
925 #[test]
926 fn cancel_loads_checkpoint_from_disk_when_cache_is_empty() {
927 let dir = tempdir().unwrap();
933 let run_id = uuid::Uuid::now_v7();
934
935 let cp = RunCheckpoint {
938 run_id,
939 task: "started elsewhere".into(),
940 status: CheckpointStatus::Running,
941 current_phase: 0,
942 completed_phases: vec![],
943 agent_results: HashMap::new(),
944 findings: vec![],
945 total_tokens: 0,
946 created_at: current_timestamp(),
947 updated_at: current_timestamp(),
948 workflow_meta: None,
949 started_agent_ids: vec![],
950 };
951 let cp_path = dir.path().join("checkpoint.json");
952 std::fs::write(
953 &cp_path,
954 serde_json::to_string_pretty(&cp).unwrap(),
955 )
956 .unwrap();
957
958 let store = RunStore::new(dir.path()).unwrap();
960 assert!(store.get_checkpoint().is_none(), "cache must start empty");
961
962 store.cancel().expect("cancel must succeed cross-process");
963
964 let cached = store.get_checkpoint().expect("cache populated after cancel");
966 assert_eq!(cached.status, CheckpointStatus::Cancelled);
967
968 let raw = read_raw_checkpoint(dir.path());
970 assert_eq!(
971 raw.get("status").and_then(|v| v.as_str()),
972 Some("cancelled")
973 );
974 assert_eq!(
975 raw.get("run_id").and_then(|v| v.as_str()),
976 Some(run_id.to_string()).as_deref()
977 );
978 }
979
980 #[test]
981 fn cancel_preserves_agent_results_and_findings() {
982 let dir = tempdir().unwrap();
986 let run_id = uuid::Uuid::now_v7();
987 let store = RunStore::new(dir.path()).unwrap();
988 store.init_run(run_id, "preserve").unwrap();
989
990 let agent_id = uuid::Uuid::now_v7();
991 let cache = AgentResultCache {
992 agent_id,
993 phase_id: 1,
994 status: "ok".into(),
995 output: serde_json::json!({"x": 1}),
996 findings: vec![],
997 tokens: 250,
998 completed_at: 7,
999 cache_key_hash: Some("hash-1".into()),
1000 description: None,
1001 role: None,
1002 };
1003 store.upsert_agent_result(&cache).unwrap();
1004 let before = store.get_checkpoint().unwrap();
1005
1006 store.cancel().unwrap();
1007 let after = store.get_checkpoint().unwrap();
1008
1009 assert_eq!(after.status, CheckpointStatus::Cancelled);
1010 assert_eq!(after.agent_results.len(), 1);
1011 assert_eq!(after.agent_results[&agent_id].tokens, 250);
1012 assert_eq!(
1013 after.agent_results[&agent_id].cache_key_hash.as_deref(),
1014 Some("hash-1")
1015 );
1016 assert_eq!(after.total_tokens, before.total_tokens);
1017 }
1018
1019 #[test]
1022 fn agent_done_persists_snake_case_status_for_each_variant() {
1023 let dir = tempdir().unwrap();
1028 let run_id = uuid::Uuid::now_v7();
1029 let store = RunStore::new(dir.path()).unwrap();
1030 store.init_run(run_id, "F5 variants").unwrap();
1031
1032 let cases: Vec<(AgentStatus, &str)> = vec![
1033 (AgentStatus::Ok, "ok"),
1034 (AgentStatus::Error, "error"),
1035 (AgentStatus::Cancelled, "cancelled"),
1036 (AgentStatus::TimedOut, "timed_out"),
1037 ];
1038 for (status, expected) in &cases {
1039 let agent_id = uuid::Uuid::now_v7();
1040 let evt = build_agent_done(run_id, agent_id, status.clone(), sample_token_usage());
1041 store.append_event(&evt).unwrap();
1042
1043 let raw = read_raw_checkpoint(dir.path());
1044 let ar = raw
1045 .get("agent_results")
1046 .and_then(|v| v.as_object())
1047 .expect("agent_results object");
1048 let entry = ar
1049 .values()
1050 .find(|v| {
1051 v.get("agent_id").and_then(|id| id.as_str()) == Some(&agent_id.to_string())
1052 })
1053 .unwrap_or_else(|| panic!("entry for {agent_id} missing"));
1054 let persisted = entry
1055 .get("status")
1056 .and_then(|v| v.as_str())
1057 .unwrap_or_else(|| panic!("status missing for {status:?}"));
1058 assert_eq!(
1059 persisted, *expected,
1060 "AgentDone({status:?}) must persist status={expected:?} (snake_case); \
1061 got {persisted:?}. If this fails with \"timedout\" for TimedOut, \
1062 F5 has regressed to Debug formatting."
1063 );
1064 }
1065 }
1066
1067 #[test]
1068 fn agent_done_timed_out_persists_with_underscore_not_collapsed() {
1069 let dir = tempdir().unwrap();
1074 let run_id = uuid::Uuid::now_v7();
1075 let store = RunStore::new(dir.path()).unwrap();
1076 store.init_run(run_id, "timed-out guard").unwrap();
1077
1078 let agent_id = uuid::Uuid::now_v7();
1079 let evt = build_agent_done(
1080 run_id,
1081 agent_id,
1082 AgentStatus::TimedOut,
1083 sample_token_usage(),
1084 );
1085 store.append_event(&evt).unwrap();
1086
1087 let raw = read_raw_checkpoint(dir.path());
1088 let ar = raw
1089 .get("agent_results")
1090 .and_then(|v| v.as_object())
1091 .unwrap();
1092 let entry = ar.values().next().expect("entry exists");
1093 let persisted = entry.get("status").and_then(|v| v.as_str()).unwrap();
1094
1095 assert_eq!(
1096 persisted, "timed_out",
1097 "AgentDone(TimedOut) must persist \"timed_out\" with an underscore; got {persisted:?}"
1098 );
1099 assert_ne!(
1100 persisted, "timedout",
1101 "AgentDone(TimedOut) must NOT collapse to Debug-lowercased \"timedout\""
1102 );
1103 }
1104
1105 #[test]
1106 fn agent_done_then_reopen_restores_snake_case_status() {
1107 let dir = tempdir().unwrap();
1112 let run_id = uuid::Uuid::now_v7();
1113 let store = RunStore::new(dir.path()).unwrap();
1114 store.init_run(run_id, "round-trip").unwrap();
1115
1116 let agent_id = uuid::Uuid::now_v7();
1117 let evt = build_agent_done(
1118 run_id,
1119 agent_id,
1120 AgentStatus::Cancelled,
1121 TokenUsage {
1122 input: 1,
1123 output: 2,
1124 cache_read: 0,
1125 cache_write: 0,
1126 },
1127 );
1128 store.append_event(&evt).unwrap();
1129 drop(store);
1130
1131 let reopened = RunStore::new(dir.path()).unwrap();
1132 let cp = reopened.open_run(run_id).unwrap().unwrap();
1133 let cached = cp
1134 .agent_results
1135 .get(&agent_id)
1136 .expect("agent cached on disk");
1137 assert_eq!(cached.status, "cancelled");
1138 assert_eq!(cached.tokens, 3);
1139 }
1140
1141 #[test]
1144 fn open_run_with_corrupt_checkpoint_returns_invalid_data() {
1145 let dir = tempdir().unwrap();
1149 std::fs::create_dir_all(dir.path()).unwrap();
1150 std::fs::write(
1151 dir.path().join("checkpoint.json"),
1152 b"{ this is not valid json",
1153 )
1154 .unwrap();
1155
1156 let store = RunStore::new(dir.path()).unwrap();
1157 let err = store
1158 .open_run(uuid::Uuid::now_v7())
1159 .expect_err("corrupt JSON must surface as an io::Error");
1160 assert_eq!(
1161 err.kind(),
1162 std::io::ErrorKind::InvalidData,
1163 "corrupt checkpoint must map to InvalidData via serde_to_io; got {:?}",
1164 err.kind()
1165 );
1166 }
1167
1168 #[test]
1169 fn open_run_with_wrong_typed_checkpoint_returns_invalid_data() {
1170 let dir = tempdir().unwrap();
1173 std::fs::create_dir_all(dir.path()).unwrap();
1174 std::fs::write(
1177 dir.path().join("checkpoint.json"),
1178 br#"{"run_id":"00000000-0000-0000-0000-000000000000","status":"running"}"#,
1179 )
1180 .unwrap();
1181
1182 let store = RunStore::new(dir.path()).unwrap();
1183 let err = store
1184 .open_run(uuid::Uuid::now_v7())
1185 .expect_err("missing-field JSON must surface as an io::Error");
1186 assert_eq!(err.kind(), std::io::ErrorKind::InvalidData);
1187 }
1188
1189 #[test]
1190 fn get_event_log_with_corrupt_line_returns_invalid_data() {
1191 let dir = tempdir().unwrap();
1196 std::fs::create_dir_all(dir.path()).unwrap();
1197 std::fs::write(dir.path().join("events.jsonl"), b"not-json\n").unwrap();
1198
1199 let store = RunStore::new(dir.path()).unwrap();
1200 let err = store
1201 .get_event_log()
1202 .expect_err("corrupt event line must surface as an io::Error");
1203 assert_eq!(
1204 err.kind(),
1205 std::io::ErrorKind::InvalidData,
1206 "corrupt event line must map to InvalidData via serde_to_io; got {:?}",
1207 err.kind()
1208 );
1209 }
1210
1211 #[test]
1214 fn as_str_variants_round_trip_through_checkpoint_pipeline() {
1215 let dir = tempdir().unwrap();
1220 let run_id = uuid::Uuid::now_v7();
1221 let store = RunStore::new(dir.path()).unwrap();
1222 store.init_run(run_id, "round-trip property").unwrap();
1223
1224 let variants = [
1225 AgentStatus::Ok,
1226 AgentStatus::Error,
1227 AgentStatus::Cancelled,
1228 AgentStatus::TimedOut,
1229 ];
1230 let mut seen: HashSet<String> = HashSet::new();
1231
1232 for variant in &variants {
1233 let agent_id = uuid::Uuid::now_v7();
1234 let evt = build_agent_done(run_id, agent_id, variant.clone(), sample_token_usage());
1235 store.append_event(&evt).unwrap();
1236
1237 let cp = store.get_checkpoint().unwrap();
1238 let cached = cp
1239 .agent_results
1240 .get(&agent_id)
1241 .expect("entry for {agent_id}");
1242 assert_eq!(
1243 cached.status,
1244 variant.as_str(),
1245 "{variant:?}.as_str() must round-trip via append_event→update_from_event"
1246 );
1247 assert!(
1249 seen.insert(cached.status.clone()),
1250 "duplicate status {cached_status:?} persisted for {variant:?}",
1251 cached_status = cached.status
1252 );
1253 }
1254 }
1255}