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