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 cancel(&self) -> Result<(), std::io::Error> {
408 tracing::info!("cancelling run");
409 let mut guard = self.checkpoint.write().unwrap();
410
411 if guard.is_none() {
419 let checkpoint_path = self.run_dir.join("checkpoint.json");
420 if !checkpoint_path.exists() {
421 return Ok(());
422 }
423 let content = fs::read_to_string(&checkpoint_path)?;
424 let cp: RunCheckpoint = serde_json::from_str(&content)
425 .map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e))?;
426 *guard = Some(cp);
427 }
428
429 if let Some(ref mut checkpoint) = *guard {
430 checkpoint.status = CheckpointStatus::Cancelled;
431 checkpoint.updated_at = current_timestamp();
432 drop(guard);
433 let guard = self.checkpoint.read().unwrap();
434 if let Some(ref c) = *guard {
435 let checkpoint_path = self.run_dir.join("checkpoint.json");
436 let content = serde_json::to_string_pretty(c)
437 .map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e))?;
438 fs::write(&checkpoint_path, content)?;
439 }
440 }
441 Ok(())
442 }
443}
444
445fn current_timestamp() -> u64 {
447 SystemTime::now()
448 .duration_since(UNIX_EPOCH)
449 .map(|d| d.as_secs())
450 .unwrap_or(0)
451}
452
453use std::sync::OnceLock;
458
459static RUN_STORES: OnceLock<dashmap::DashMap<String, Arc<RunStore>>> = OnceLock::new();
460
461fn get_run_stores() -> &'static dashmap::DashMap<String, Arc<RunStore>> {
463 RUN_STORES.get_or_init(dashmap::DashMap::new)
464}
465
466pub fn get_run_store(run_dir_name: &str, base_dir: &Path) -> Result<Arc<RunStore>, std::io::Error> {
468 let stores = get_run_stores();
469
470 if let Some(store) = stores.get(run_dir_name) {
471 return Ok(store.clone());
472 }
473
474 let run_dir = base_dir.join(run_dir_name);
475 let store = RunStore::new(&run_dir)?;
476 stores.insert(run_dir_name.to_string(), store.clone());
477
478 Ok(store)
479}
480
481pub fn list_runs(base_dir: &Path) -> Result<Vec<String>, std::io::Error> {
483 if !base_dir.exists() {
484 return Ok(vec![]);
485 }
486
487 let mut run_dirs = Vec::new();
488 for entry in fs::read_dir(base_dir)? {
489 let entry = entry?;
490 let path = entry.path();
491 if path.is_dir() {
492 if let Some(name) = path.file_name().and_then(|n| n.to_str()) {
493 run_dirs.push(name.to_string());
494 }
495 }
496 }
497
498 run_dirs.sort();
499 Ok(run_dirs)
500}
501
502#[cfg(test)]
503mod tests {
504 use super::*;
505 use tempfile::tempdir;
506
507 #[test]
508 fn test_run_store_init() {
509 let dir = tempdir().unwrap();
510 let run_id = uuid::Uuid::now_v7();
511 let store = RunStore::new(dir.path()).unwrap();
512 store.init_run(run_id, "Test task").unwrap();
513
514 let checkpoint = store.get_checkpoint().unwrap();
515 assert_eq!(checkpoint.run_id, run_id);
516 assert_eq!(checkpoint.task, "Test task");
517 assert_eq!(checkpoint.status, CheckpointStatus::Running);
518 }
519
520 #[test]
521 fn test_run_store_resume() {
522 let dir = tempdir().unwrap();
523 let run_id = uuid::Uuid::now_v7();
524 let store = RunStore::new(dir.path()).unwrap();
525 store.init_run(run_id, "Test task").unwrap();
526
527 let store2 = RunStore::new(dir.path()).unwrap();
529 let checkpoint = store2.open_run(run_id).unwrap().unwrap();
530 assert_eq!(checkpoint.run_id, run_id);
531 assert_eq!(checkpoint.task, "Test task");
532 }
533
534 #[test]
535 fn test_can_resume() {
536 let dir = tempdir().unwrap();
537 let run_id = uuid::Uuid::now_v7();
538 let store = RunStore::new(dir.path()).unwrap();
539 store.init_run(run_id, "Test task").unwrap();
540
541 assert!(store.can_resume());
542 }
543
544 #[test]
545 fn test_resume_appends_events() {
546 let dir = tempdir().unwrap();
550 let run_id = uuid::Uuid::now_v7();
551 let store = RunStore::new(dir.path()).unwrap();
552 store.init_run(run_id, "Test task").unwrap();
553
554 let store2 = RunStore::new(dir.path()).unwrap();
555 store2.open_run(run_id).unwrap().unwrap();
556
557 let evt = AgentEvent::Log {
559 run_id,
560 agent_id: None,
561 level: crate::contract::event::LogLevel::Info,
562 msg: "resume smoke test".to_string(),
563 };
564 store2
565 .append_event(&evt)
566 .expect("append_event after resume must succeed");
567
568 let log = store2.get_event_log().expect("read events.jsonl");
569 assert!(
570 log.iter().any(|e| matches!(
571 e,
572 AgentEvent::Log { msg, .. } if msg == "resume smoke test"
573 )),
574 "event written after open_run must appear in events.jsonl"
575 );
576 }
577
578 use crate::contract::backend::AgentStatus;
589 use crate::contract::ids::TokenUsage;
590 use std::collections::HashSet;
591
592 fn sample_token_usage() -> TokenUsage {
593 TokenUsage {
594 input: 10,
595 output: 5,
596 cache_read: 0,
597 cache_write: 0,
598 }
599 }
600
601 fn build_agent_done(
602 run_id: RunId,
603 agent_id: AgentId,
604 status: AgentStatus,
605 tokens: TokenUsage,
606 ) -> AgentEvent {
607 AgentEvent::AgentDone {
608 run_id,
609 agent_id,
610 status,
611 tokens,
612 elapsed_ms: 0,
613 name: None,
614 agent_seq: 0,
615 output: serde_json::Value::Null,
616 findings: vec![],
617 prompt: String::new(),
618 retry_count: 0,
619 ts: Default::default(),
620 }
621 }
622
623 fn read_raw_checkpoint(run_dir: &Path) -> serde_json::Value {
624 let path = run_dir.join("checkpoint.json");
625 let content =
626 std::fs::read_to_string(&path).unwrap_or_else(|e| panic!("read checkpoint.json: {e}"));
627 serde_json::from_str(&content).unwrap_or_else(|e| panic!("parse checkpoint.json: {e}"))
628 }
629
630 #[test]
633 fn upsert_agent_result_persists_to_disk() {
634 let dir = tempdir().unwrap();
638 let run_id = uuid::Uuid::now_v7();
639 let store = RunStore::new(dir.path()).unwrap();
640 store.init_run(run_id, "upsert test").unwrap();
641
642 let agent_id = uuid::Uuid::now_v7();
643 let cache = AgentResultCache {
644 agent_id,
645 phase_id: 1,
646 status: "ok".into(),
647 output: serde_json::json!({"v": 42}),
648 findings: vec![],
649 tokens: 100,
650 completed_at: 1_700_000_000,
651 cache_key_hash: Some("deadbeef".into()),
652 description: None,
653 role: None,
654 };
655 store.upsert_agent_result(&cache).unwrap();
656
657 let cp = store.get_checkpoint().expect("checkpoint present");
659 let cached = cp
660 .agent_results
661 .get(&agent_id)
662 .expect("agent_id indexed after upsert");
663 assert_eq!(cached.tokens, 100);
664 assert_eq!(cached.status, "ok");
665
666 let raw = read_raw_checkpoint(dir.path());
668 let ar = raw
669 .get("agent_results")
670 .and_then(|v| v.as_object())
671 .expect("agent_results object");
672 assert_eq!(ar.len(), 1, "exactly one agent cached on disk");
673 let entry = ar.values().next().expect("non-empty agent_results on disk");
674 assert_eq!(entry.get("tokens").and_then(|v| v.as_u64()), Some(100));
675 assert_eq!(entry.get("status").and_then(|v| v.as_str()), Some("ok"));
676 assert_eq!(
677 entry.get("cache_key_hash").and_then(|v| v.as_str()),
678 Some("deadbeef")
679 );
680
681 drop(store);
683 let reopened = RunStore::new(dir.path()).unwrap();
684 let restored = reopened.open_run(run_id).unwrap().unwrap();
685 assert!(
686 restored.agent_results.contains_key(&agent_id),
687 "upserted entry must survive close+reopen"
688 );
689 assert_eq!(restored.agent_results[&agent_id].tokens, 100);
690 }
691
692 #[test]
693 fn upsert_agent_result_updates_existing_entry() {
694 let dir = tempdir().unwrap();
697 let run_id = uuid::Uuid::now_v7();
698 let store = RunStore::new(dir.path()).unwrap();
699 store.init_run(run_id, "overwrite test").unwrap();
700
701 let agent_id = uuid::Uuid::now_v7();
702 let first = AgentResultCache {
703 agent_id,
704 phase_id: 1,
705 status: "ok".into(),
706 output: serde_json::json!("first"),
707 findings: vec![],
708 tokens: 10,
709 completed_at: 1,
710 cache_key_hash: None,
711 description: None,
712 role: None,
713 };
714 let second = AgentResultCache {
715 agent_id,
716 phase_id: 1,
717 status: "error".into(),
718 output: serde_json::json!("second"),
719 findings: vec![],
720 tokens: 99,
721 completed_at: 2,
722 cache_key_hash: None,
723 description: None,
724 role: None,
725 };
726 store.upsert_agent_result(&first).unwrap();
727 store.upsert_agent_result(&second).unwrap();
728
729 let cp = store.get_checkpoint().unwrap();
730 assert_eq!(cp.agent_results.len(), 1, "no duplicate entries");
731 let cached = &cp.agent_results[&agent_id];
732 assert_eq!(cached.status, "error");
733 assert_eq!(cached.tokens, 99);
734 assert_eq!(cached.completed_at, 2);
735
736 let raw = read_raw_checkpoint(dir.path());
738 let ar = raw
739 .get("agent_results")
740 .and_then(|v| v.as_object())
741 .unwrap();
742 assert_eq!(ar.len(), 1);
743 let entry = ar.values().next().unwrap();
744 assert_eq!(entry.get("tokens").and_then(|v| v.as_u64()), Some(99));
745 assert_eq!(entry.get("status").and_then(|v| v.as_str()), Some("error"));
746 }
747
748 #[test]
749 fn upsert_agent_result_noop_when_uninitialized() {
750 let dir = tempdir().unwrap();
754 let store = RunStore::new(dir.path()).unwrap();
755 assert!(store.get_checkpoint().is_none());
756 let cp_path = dir.path().join("checkpoint.json");
757 assert!(!cp_path.exists(), "no checkpoint.json before init");
758
759 let cache = AgentResultCache {
760 agent_id: uuid::Uuid::now_v7(),
761 phase_id: 1,
762 status: "ok".into(),
763 output: serde_json::json!(null),
764 findings: vec![],
765 tokens: 0,
766 completed_at: 0,
767 cache_key_hash: None,
768 description: None,
769 role: None,
770 };
771 store.upsert_agent_result(&cache).unwrap();
772 assert!(
773 !cp_path.exists(),
774 "upsert_agent_result must not create checkpoint.json before init_run"
775 );
776 assert!(store.get_checkpoint().is_none());
777 }
778
779 #[test]
780 fn upsert_agent_result_advances_updated_at() {
781 let dir = tempdir().unwrap();
784 let run_id = uuid::Uuid::now_v7();
785 let store = RunStore::new(dir.path()).unwrap();
786 store.init_run(run_id, "ts test").unwrap();
787 let before = store.get_checkpoint().unwrap().updated_at;
788
789 std::thread::sleep(std::time::Duration::from_millis(1100));
790
791 let cache = AgentResultCache {
792 agent_id: uuid::Uuid::now_v7(),
793 phase_id: 1,
794 status: "ok".into(),
795 output: serde_json::json!(null),
796 findings: vec![],
797 tokens: 0,
798 completed_at: 0,
799 cache_key_hash: None,
800 description: None,
801 role: None,
802 };
803 store.upsert_agent_result(&cache).unwrap();
804 let after = store.get_checkpoint().unwrap().updated_at;
805 assert!(
806 after > before,
807 "updated_at must advance after upsert (before={before}, after={after})"
808 );
809 }
810
811 #[test]
814 fn cancel_persists_cancelled_status_to_disk() {
815 let dir = tempdir().unwrap();
820 let run_id = uuid::Uuid::now_v7();
821 let store = RunStore::new(dir.path()).unwrap();
822 store.init_run(run_id, "cancel me").unwrap();
823 assert!(store.can_resume());
824
825 store.cancel().unwrap();
826
827 let cp = store.get_checkpoint().unwrap();
829 assert_eq!(cp.status, CheckpointStatus::Cancelled);
830 assert!(!store.can_resume());
831
832 let raw = read_raw_checkpoint(dir.path());
834 assert_eq!(
835 raw.get("status").and_then(|v| v.as_str()),
836 Some("cancelled")
837 );
838
839 drop(store);
841 let reopened = RunStore::new(dir.path()).unwrap();
842 let restored = reopened.open_run(run_id).unwrap().unwrap();
843 assert_eq!(restored.status, CheckpointStatus::Cancelled);
844 assert!(!reopened.can_resume());
845 }
846
847 #[test]
848 fn cancel_is_idempotent() {
849 let dir = tempdir().unwrap();
854 let run_id = uuid::Uuid::now_v7();
855 let store = RunStore::new(dir.path()).unwrap();
856 store.init_run(run_id, "double cancel").unwrap();
857
858 store.cancel().unwrap();
859 let after_first = store.get_checkpoint().unwrap().updated_at;
860 std::thread::sleep(std::time::Duration::from_millis(1100));
861
862 store.cancel().expect("second cancel must succeed");
863 let after_second = store.get_checkpoint().unwrap().updated_at;
864
865 assert_eq!(
866 store.get_checkpoint().unwrap().status,
867 CheckpointStatus::Cancelled
868 );
869 assert!(
870 after_second >= after_first,
871 "updated_at must not regress (was {after_first}, now {after_second})"
872 );
873
874 let raw = read_raw_checkpoint(dir.path());
875 assert_eq!(
876 raw.get("status").and_then(|v| v.as_str()),
877 Some("cancelled")
878 );
879 }
880
881 #[test]
882 fn cancel_before_init_is_safe_noop() {
883 let dir = tempdir().unwrap();
887 let store = RunStore::new(dir.path()).unwrap();
888 assert!(store.get_checkpoint().is_none());
889 store.cancel().expect("cancel before init must succeed");
890 assert!(store.get_checkpoint().is_none());
891 assert!(
892 !dir.path().join("checkpoint.json").exists(),
893 "cancel before init must not create checkpoint.json"
894 );
895 }
896
897 #[test]
898 fn cancel_loads_checkpoint_from_disk_when_cache_is_empty() {
899 let dir = tempdir().unwrap();
905 let run_id = uuid::Uuid::now_v7();
906
907 let cp = RunCheckpoint {
910 run_id,
911 task: "started elsewhere".into(),
912 status: CheckpointStatus::Running,
913 current_phase: 0,
914 completed_phases: vec![],
915 agent_results: HashMap::new(),
916 findings: vec![],
917 total_tokens: 0,
918 created_at: current_timestamp(),
919 updated_at: current_timestamp(),
920 workflow_meta: None,
921 started_agent_ids: vec![],
922 };
923 let cp_path = dir.path().join("checkpoint.json");
924 std::fs::write(
925 &cp_path,
926 serde_json::to_string_pretty(&cp).unwrap(),
927 )
928 .unwrap();
929
930 let store = RunStore::new(dir.path()).unwrap();
932 assert!(store.get_checkpoint().is_none(), "cache must start empty");
933
934 store.cancel().expect("cancel must succeed cross-process");
935
936 let cached = store.get_checkpoint().expect("cache populated after cancel");
938 assert_eq!(cached.status, CheckpointStatus::Cancelled);
939
940 let raw = read_raw_checkpoint(dir.path());
942 assert_eq!(
943 raw.get("status").and_then(|v| v.as_str()),
944 Some("cancelled")
945 );
946 assert_eq!(
947 raw.get("run_id").and_then(|v| v.as_str()),
948 Some(run_id.to_string()).as_deref()
949 );
950 }
951
952 #[test]
953 fn cancel_preserves_agent_results_and_findings() {
954 let dir = tempdir().unwrap();
958 let run_id = uuid::Uuid::now_v7();
959 let store = RunStore::new(dir.path()).unwrap();
960 store.init_run(run_id, "preserve").unwrap();
961
962 let agent_id = uuid::Uuid::now_v7();
963 let cache = AgentResultCache {
964 agent_id,
965 phase_id: 1,
966 status: "ok".into(),
967 output: serde_json::json!({"x": 1}),
968 findings: vec![],
969 tokens: 250,
970 completed_at: 7,
971 cache_key_hash: Some("hash-1".into()),
972 description: None,
973 role: None,
974 };
975 store.upsert_agent_result(&cache).unwrap();
976 let before = store.get_checkpoint().unwrap();
977
978 store.cancel().unwrap();
979 let after = store.get_checkpoint().unwrap();
980
981 assert_eq!(after.status, CheckpointStatus::Cancelled);
982 assert_eq!(after.agent_results.len(), 1);
983 assert_eq!(after.agent_results[&agent_id].tokens, 250);
984 assert_eq!(
985 after.agent_results[&agent_id].cache_key_hash.as_deref(),
986 Some("hash-1")
987 );
988 assert_eq!(after.total_tokens, before.total_tokens);
989 }
990
991 #[test]
994 fn agent_done_persists_snake_case_status_for_each_variant() {
995 let dir = tempdir().unwrap();
1000 let run_id = uuid::Uuid::now_v7();
1001 let store = RunStore::new(dir.path()).unwrap();
1002 store.init_run(run_id, "F5 variants").unwrap();
1003
1004 let cases: Vec<(AgentStatus, &str)> = vec![
1005 (AgentStatus::Ok, "ok"),
1006 (AgentStatus::Error, "error"),
1007 (AgentStatus::Cancelled, "cancelled"),
1008 (AgentStatus::TimedOut, "timed_out"),
1009 ];
1010 for (status, expected) in &cases {
1011 let agent_id = uuid::Uuid::now_v7();
1012 let evt = build_agent_done(run_id, agent_id, status.clone(), sample_token_usage());
1013 store.append_event(&evt).unwrap();
1014
1015 let raw = read_raw_checkpoint(dir.path());
1016 let ar = raw
1017 .get("agent_results")
1018 .and_then(|v| v.as_object())
1019 .expect("agent_results object");
1020 let entry = ar
1021 .values()
1022 .find(|v| {
1023 v.get("agent_id").and_then(|id| id.as_str()) == Some(&agent_id.to_string())
1024 })
1025 .unwrap_or_else(|| panic!("entry for {agent_id} missing"));
1026 let persisted = entry
1027 .get("status")
1028 .and_then(|v| v.as_str())
1029 .unwrap_or_else(|| panic!("status missing for {status:?}"));
1030 assert_eq!(
1031 persisted, *expected,
1032 "AgentDone({status:?}) must persist status={expected:?} (snake_case); \
1033 got {persisted:?}. If this fails with \"timedout\" for TimedOut, \
1034 F5 has regressed to Debug formatting."
1035 );
1036 }
1037 }
1038
1039 #[test]
1040 fn agent_done_timed_out_persists_with_underscore_not_collapsed() {
1041 let dir = tempdir().unwrap();
1046 let run_id = uuid::Uuid::now_v7();
1047 let store = RunStore::new(dir.path()).unwrap();
1048 store.init_run(run_id, "timed-out guard").unwrap();
1049
1050 let agent_id = uuid::Uuid::now_v7();
1051 let evt = build_agent_done(
1052 run_id,
1053 agent_id,
1054 AgentStatus::TimedOut,
1055 sample_token_usage(),
1056 );
1057 store.append_event(&evt).unwrap();
1058
1059 let raw = read_raw_checkpoint(dir.path());
1060 let ar = raw
1061 .get("agent_results")
1062 .and_then(|v| v.as_object())
1063 .unwrap();
1064 let entry = ar.values().next().expect("entry exists");
1065 let persisted = entry.get("status").and_then(|v| v.as_str()).unwrap();
1066
1067 assert_eq!(
1068 persisted, "timed_out",
1069 "AgentDone(TimedOut) must persist \"timed_out\" with an underscore; got {persisted:?}"
1070 );
1071 assert_ne!(
1072 persisted, "timedout",
1073 "AgentDone(TimedOut) must NOT collapse to Debug-lowercased \"timedout\""
1074 );
1075 }
1076
1077 #[test]
1078 fn agent_done_then_reopen_restores_snake_case_status() {
1079 let dir = tempdir().unwrap();
1084 let run_id = uuid::Uuid::now_v7();
1085 let store = RunStore::new(dir.path()).unwrap();
1086 store.init_run(run_id, "round-trip").unwrap();
1087
1088 let agent_id = uuid::Uuid::now_v7();
1089 let evt = build_agent_done(
1090 run_id,
1091 agent_id,
1092 AgentStatus::Cancelled,
1093 TokenUsage {
1094 input: 1,
1095 output: 2,
1096 cache_read: 0,
1097 cache_write: 0,
1098 },
1099 );
1100 store.append_event(&evt).unwrap();
1101 drop(store);
1102
1103 let reopened = RunStore::new(dir.path()).unwrap();
1104 let cp = reopened.open_run(run_id).unwrap().unwrap();
1105 let cached = cp
1106 .agent_results
1107 .get(&agent_id)
1108 .expect("agent cached on disk");
1109 assert_eq!(cached.status, "cancelled");
1110 assert_eq!(cached.tokens, 3);
1111 }
1112
1113 #[test]
1116 fn open_run_with_corrupt_checkpoint_returns_invalid_data() {
1117 let dir = tempdir().unwrap();
1121 std::fs::create_dir_all(dir.path()).unwrap();
1122 std::fs::write(
1123 dir.path().join("checkpoint.json"),
1124 b"{ this is not valid json",
1125 )
1126 .unwrap();
1127
1128 let store = RunStore::new(dir.path()).unwrap();
1129 let err = store
1130 .open_run(uuid::Uuid::now_v7())
1131 .expect_err("corrupt JSON must surface as an io::Error");
1132 assert_eq!(
1133 err.kind(),
1134 std::io::ErrorKind::InvalidData,
1135 "corrupt checkpoint must map to InvalidData via serde_to_io; got {:?}",
1136 err.kind()
1137 );
1138 }
1139
1140 #[test]
1141 fn open_run_with_wrong_typed_checkpoint_returns_invalid_data() {
1142 let dir = tempdir().unwrap();
1145 std::fs::create_dir_all(dir.path()).unwrap();
1146 std::fs::write(
1149 dir.path().join("checkpoint.json"),
1150 br#"{"run_id":"00000000-0000-0000-0000-000000000000","status":"running"}"#,
1151 )
1152 .unwrap();
1153
1154 let store = RunStore::new(dir.path()).unwrap();
1155 let err = store
1156 .open_run(uuid::Uuid::now_v7())
1157 .expect_err("missing-field JSON must surface as an io::Error");
1158 assert_eq!(err.kind(), std::io::ErrorKind::InvalidData);
1159 }
1160
1161 #[test]
1162 fn get_event_log_with_corrupt_line_returns_invalid_data() {
1163 let dir = tempdir().unwrap();
1168 std::fs::create_dir_all(dir.path()).unwrap();
1169 std::fs::write(dir.path().join("events.jsonl"), b"not-json\n").unwrap();
1170
1171 let store = RunStore::new(dir.path()).unwrap();
1172 let err = store
1173 .get_event_log()
1174 .expect_err("corrupt event line must surface as an io::Error");
1175 assert_eq!(
1176 err.kind(),
1177 std::io::ErrorKind::InvalidData,
1178 "corrupt event line must map to InvalidData via serde_to_io; got {:?}",
1179 err.kind()
1180 );
1181 }
1182
1183 #[test]
1186 fn as_str_variants_round_trip_through_checkpoint_pipeline() {
1187 let dir = tempdir().unwrap();
1192 let run_id = uuid::Uuid::now_v7();
1193 let store = RunStore::new(dir.path()).unwrap();
1194 store.init_run(run_id, "round-trip property").unwrap();
1195
1196 let variants = [
1197 AgentStatus::Ok,
1198 AgentStatus::Error,
1199 AgentStatus::Cancelled,
1200 AgentStatus::TimedOut,
1201 ];
1202 let mut seen: HashSet<String> = HashSet::new();
1203
1204 for variant in &variants {
1205 let agent_id = uuid::Uuid::now_v7();
1206 let evt = build_agent_done(run_id, agent_id, variant.clone(), sample_token_usage());
1207 store.append_event(&evt).unwrap();
1208
1209 let cp = store.get_checkpoint().unwrap();
1210 let cached = cp
1211 .agent_results
1212 .get(&agent_id)
1213 .expect("entry for {agent_id}");
1214 assert_eq!(
1215 cached.status,
1216 variant.as_str(),
1217 "{variant:?}.as_str() must round-trip via append_event→update_from_event"
1218 );
1219 assert!(
1221 seen.insert(cached.status.clone()),
1222 "duplicate status {cached_status:?} persisted for {variant:?}",
1223 cached_status = cached.status
1224 );
1225 }
1226 }
1227}