Skip to main content

luft_core/
state.rs

1//! Progress persistence and resume for long-running workflows.
2//!
3//! This module implements checkpointing and recovery for dynamic workflows.
4//! Progress is saved as the run goes, so a job interrupted by a restart can resume.
5//!
6//! Key features:
7//! - Event log persistence (JSONL)
8//! - Agent result caching
9//! - Resume from last checkpoint
10//! - Run state management
11
12use 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/// Run state persisted to disk.
24#[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    /// Every agent_id that has received an `AgentStarted` event, in arrival
41    /// order. Used to compute "running" = started − done.
42    #[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    /// Deterministic cache key hash for resume lookups.
90    /// Populated by JournalStore::cache_agent(); None for legacy checkpoints.
91    #[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/// Persistence store for a single run.
110#[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    /// Create or open a run store at the given path.
119    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    /// Insert or update an agent result in the checkpoint directly.
133    /// Used by JournalStore to persist cache_key_hash before appending the event.
134    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    /// Initialize a new run.
152    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        // Save checkpoint
171        self.save_checkpoint(&checkpoint)?;
172
173        // Open events file
174        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    /// Initialize a new run with declarative workflow metadata.
190    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    /// Open an existing run for resume.
231    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        // Open events file. Resume appends new events (phase_started, agent_started,
244        // log, agent_done) to the same file; opening read-only here would make every
245        // forwarded event fail with Access is denied (os error 5) and silently drop
246        // observability for the entire resumed run.
247        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    /// Append an event to the log.
263    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        // Update checkpoint (this also persists to disk)
274        self.update_from_event(event);
275
276        Ok(())
277    }
278
279    /// Update checkpoint from an event and persist to disk.
280    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                    // Only overwrite if a real total was supplied; otherwise keep
349                    // the figure accumulated from AgentDone events.
350                    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            // Persist updated checkpoint to disk (write-only, no lock needed - already held)
360            if let Err(e) = self.write_checkpoint_to_disk(checkpoint) {
361                tracing::warn!(error = %e, "failed to save checkpoint");
362            }
363        }
364    }
365
366    /// Write checkpoint to disk without acquiring any locks.
367    fn write_checkpoint_to_disk(&self, checkpoint: &RunCheckpoint) -> Result<(), std::io::Error> {
368        let checkpoint_path = self.run_dir.join("checkpoint.json");
369        let temp_path = self.run_dir.join("checkpoint.json.tmp");
370        let content = serde_json::to_string_pretty(checkpoint)
371            .map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e))?;
372        std::fs::write(&temp_path, &content)?;
373        std::fs::rename(&temp_path, &checkpoint_path)?;
374        Ok(())
375    }
376
377    /// Save checkpoint to disk (public API, acquires lock).
378    pub fn save_checkpoint(&self, checkpoint: &RunCheckpoint) -> Result<(), std::io::Error> {
379        let checkpoint_path = self.run_dir.join("checkpoint.json");
380        let temp_path = self.run_dir.join("checkpoint.json.tmp");
381        let content = serde_json::to_string_pretty(checkpoint)
382            .map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e))?;
383        std::fs::write(&temp_path, &content)?;
384        std::fs::rename(&temp_path, &checkpoint_path)?;
385
386        let mut checkpoint_guard = self.checkpoint.write().unwrap();
387        *checkpoint_guard = Some(checkpoint.clone());
388
389        Ok(())
390    }
391
392    /// Get current checkpoint.
393    pub fn get_checkpoint(&self) -> Option<RunCheckpoint> {
394        let guard = self.checkpoint.read().unwrap();
395        guard.clone()
396    }
397
398    /// Get cached agent results.
399    pub fn get_agent_results(&self) -> HashMap<AgentId, AgentResultCache> {
400        let guard = self.checkpoint.read().unwrap();
401        guard
402            .as_ref()
403            .map(|c| c.agent_results.clone())
404            .unwrap_or_default()
405    }
406
407    /// Get all findings collected so far.
408    pub fn get_findings(&self) -> Vec<Finding> {
409        let guard = self.checkpoint.read().unwrap();
410        guard
411            .as_ref()
412            .map(|c| c.findings.clone())
413            .unwrap_or_default()
414    }
415
416    /// Get event log as a vector.
417    pub fn get_event_log(&self) -> Result<Vec<AgentEvent>, std::io::Error> {
418        let events_path = self.run_dir.join("events.jsonl");
419        let file = File::open(events_path)?;
420        let reader = BufReader::new(file);
421        let mut events = Vec::new();
422
423        for line in reader.lines() {
424            let line = line?;
425            if line.trim().is_empty() {
426                continue;
427            }
428            let event: AgentEvent = serde_json::from_str(&line)
429                .map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e))?;
430            events.push(event);
431        }
432
433        Ok(events)
434    }
435
436    /// Check if a run can be resumed.
437    pub fn can_resume(&self) -> bool {
438        let guard = self.checkpoint.read().unwrap();
439        matches!(
440            guard.as_ref().map(|c| c.status.clone()),
441            Some(CheckpointStatus::Running)
442        )
443    }
444
445    /// Mark run as cancelled.
446    pub fn cancel(&self) -> Result<(), std::io::Error> {
447        tracing::info!("cancelling run");
448        let mut guard = self.checkpoint.write().unwrap();
449        if let Some(ref mut checkpoint) = *guard {
450            checkpoint.status = CheckpointStatus::Cancelled;
451            checkpoint.updated_at = current_timestamp();
452            drop(guard);
453            let guard = self.checkpoint.read().unwrap();
454            if let Some(ref c) = *guard {
455                let checkpoint_path = self.run_dir.join("checkpoint.json");
456                let content = serde_json::to_string_pretty(c)
457                    .map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e))?;
458                fs::write(&checkpoint_path, content)?;
459            }
460        }
461        Ok(())
462    }
463}
464
465/// Get current timestamp.
466fn current_timestamp() -> u64 {
467    SystemTime::now()
468        .duration_since(UNIX_EPOCH)
469        .map(|d| d.as_secs())
470        .unwrap_or(0)
471}
472
473// ============================================================================
474// Global store management
475// ============================================================================
476
477use std::sync::OnceLock;
478
479static RUN_STORES: OnceLock<dashmap::DashMap<String, Arc<RunStore>>> = OnceLock::new();
480
481/// Get or create the global run stores.
482fn get_run_stores() -> &'static dashmap::DashMap<String, Arc<RunStore>> {
483    RUN_STORES.get_or_init(dashmap::DashMap::new)
484}
485
486/// Get or create a run store for a run directory.
487pub fn get_run_store(run_dir_name: &str, base_dir: &Path) -> Result<Arc<RunStore>, std::io::Error> {
488    let stores = get_run_stores();
489
490    if let Some(store) = stores.get(run_dir_name) {
491        return Ok(store.clone());
492    }
493
494    let run_dir = base_dir.join(run_dir_name);
495    let store = RunStore::new(&run_dir)?;
496    stores.insert(run_dir_name.to_string(), store.clone());
497
498    Ok(store)
499}
500
501/// List all run directory names (both new-format and legacy UUID).
502pub fn list_runs(base_dir: &Path) -> Result<Vec<String>, std::io::Error> {
503    if !base_dir.exists() {
504        return Ok(vec![]);
505    }
506
507    let mut run_dirs = Vec::new();
508    for entry in fs::read_dir(base_dir)? {
509        let entry = entry?;
510        let path = entry.path();
511        if path.is_dir() {
512            if let Some(name) = path.file_name().and_then(|n| n.to_str()) {
513                run_dirs.push(name.to_string());
514            }
515        }
516    }
517
518    run_dirs.sort();
519    Ok(run_dirs)
520}
521
522#[cfg(test)]
523mod tests {
524    use super::*;
525    use tempfile::tempdir;
526
527    #[test]
528    fn test_run_store_init() {
529        let dir = tempdir().unwrap();
530        let run_id = uuid::Uuid::now_v7();
531        let store = RunStore::new(dir.path()).unwrap();
532        store.init_run(run_id, "Test task").unwrap();
533
534        let checkpoint = store.get_checkpoint().unwrap();
535        assert_eq!(checkpoint.run_id, run_id);
536        assert_eq!(checkpoint.task, "Test task");
537        assert_eq!(checkpoint.status, CheckpointStatus::Running);
538    }
539
540    #[test]
541    fn test_run_store_resume() {
542        let dir = tempdir().unwrap();
543        let run_id = uuid::Uuid::now_v7();
544        let store = RunStore::new(dir.path()).unwrap();
545        store.init_run(run_id, "Test task").unwrap();
546
547        // Open in new store instance
548        let store2 = RunStore::new(dir.path()).unwrap();
549        let checkpoint = store2.open_run(run_id).unwrap().unwrap();
550        assert_eq!(checkpoint.run_id, run_id);
551        assert_eq!(checkpoint.task, "Test task");
552    }
553
554    #[test]
555    fn test_can_resume() {
556        let dir = tempdir().unwrap();
557        let run_id = uuid::Uuid::now_v7();
558        let store = RunStore::new(dir.path()).unwrap();
559        store.init_run(run_id, "Test task").unwrap();
560
561        assert!(store.can_resume());
562    }
563
564    #[test]
565    fn test_resume_appends_events() {
566        // Regression: open_run previously opened events.jsonl read-only, causing
567        // every forwarded event in the resumed run to fail with
568        // `Access is denied (os error 5)` and silently dropping observability.
569        let dir = tempdir().unwrap();
570        let run_id = uuid::Uuid::now_v7();
571        let store = RunStore::new(dir.path()).unwrap();
572        store.init_run(run_id, "Test task").unwrap();
573
574        let store2 = RunStore::new(dir.path()).unwrap();
575        store2.open_run(run_id).unwrap().unwrap();
576
577        // Writing through the resumed store must succeed and persist the event.
578        let evt = AgentEvent::Log {
579            run_id,
580            agent_id: None,
581            level: crate::contract::event::LogLevel::Info,
582            msg: "resume smoke test".to_string(),
583        };
584        store2
585            .append_event(&evt)
586            .expect("append_event after resume must succeed");
587
588        let log = store2.get_event_log().expect("read events.jsonl");
589        assert!(
590            log.iter().any(|e| matches!(
591                e,
592                AgentEvent::Log { msg, .. } if msg == "resume smoke test"
593            )),
594            "event written after open_run must appear in events.jsonl"
595        );
596    }
597
598    // ----------------------------------------------------------------------
599    // Tests for F1 / F4 / F5 / F8 (spec `docs/src/core/state.rs.md`).
600    //
601    // These exercise the consolidated write path
602    // (`write_checkpoint_to_disk`), the lock-dance-free `cancel`, the
603    // snake_case `AgentStatus::as_str()` mapping that no longer depends on
604    // `Debug` formatting, and the `serde_to_io` error mapping helper that
605    // funnels every `serde_json::Error` through `ErrorKind::InvalidData`.
606    // ----------------------------------------------------------------------
607
608    use crate::contract::backend::AgentStatus;
609    use crate::contract::ids::TokenUsage;
610    use std::collections::HashSet;
611
612    fn sample_token_usage() -> TokenUsage {
613        TokenUsage {
614            input: 10,
615            output: 5,
616            cache_read: 0,
617            cache_write: 0,
618        }
619    }
620
621    fn build_agent_done(
622        run_id: RunId,
623        agent_id: AgentId,
624        status: AgentStatus,
625        tokens: TokenUsage,
626    ) -> AgentEvent {
627        AgentEvent::AgentDone {
628            run_id,
629            agent_id,
630            status,
631            tokens,
632            elapsed_ms: 0,
633            name: None,
634            agent_seq: 0,
635            output: serde_json::Value::Null,
636            findings: vec![],
637            prompt: String::new(),
638            retry_count: 0,
639            ts: Default::default(),
640        }
641    }
642
643    fn read_raw_checkpoint(run_dir: &Path) -> serde_json::Value {
644        let path = run_dir.join("checkpoint.json");
645        let content =
646            std::fs::read_to_string(&path).unwrap_or_else(|e| panic!("read checkpoint.json: {e}"));
647        serde_json::from_str(&content).unwrap_or_else(|e| panic!("parse checkpoint.json: {e}"))
648    }
649
650    // ----- upsert_agent_result (F1 delegation) ---------------------------
651
652    #[test]
653    fn upsert_agent_result_persists_to_disk() {
654        // F1: `upsert_agent_result` must persist via the same write path as
655        // `write_checkpoint_to_disk` so that a follow-up `open_run` sees the
656        // inserted entry without any in-process plumbing.
657        let dir = tempdir().unwrap();
658        let run_id = uuid::Uuid::now_v7();
659        let store = RunStore::new(dir.path()).unwrap();
660        store.init_run(run_id, "upsert test").unwrap();
661
662        let agent_id = uuid::Uuid::now_v7();
663        let cache = AgentResultCache {
664            agent_id,
665            phase_id: 1,
666            status: "ok".into(),
667            output: serde_json::json!({"v": 42}),
668            findings: vec![],
669            tokens: 100,
670            completed_at: 1_700_000_000,
671            cache_key_hash: Some("deadbeef".into()),
672            description: None,
673            role: None,
674        };
675        store.upsert_agent_result(&cache).unwrap();
676
677        // 1. In-memory state reflects the upsert.
678        let cp = store.get_checkpoint().expect("checkpoint present");
679        let cached = cp
680            .agent_results
681            .get(&agent_id)
682            .expect("agent_id indexed after upsert");
683        assert_eq!(cached.tokens, 100);
684        assert_eq!(cached.status, "ok");
685
686        // 2. On-disk JSON matches the in-memory state.
687        let raw = read_raw_checkpoint(dir.path());
688        let ar = raw
689            .get("agent_results")
690            .and_then(|v| v.as_object())
691            .expect("agent_results object");
692        assert_eq!(ar.len(), 1, "exactly one agent cached on disk");
693        let entry = ar.values().next().expect("non-empty agent_results on disk");
694        assert_eq!(entry.get("tokens").and_then(|v| v.as_u64()), Some(100));
695        assert_eq!(entry.get("status").and_then(|v| v.as_str()), Some("ok"));
696        assert_eq!(
697            entry.get("cache_key_hash").and_then(|v| v.as_str()),
698            Some("deadbeef")
699        );
700
701        // 3. Re-opening the run restores the entry from disk.
702        drop(store);
703        let reopened = RunStore::new(dir.path()).unwrap();
704        let restored = reopened.open_run(run_id).unwrap().unwrap();
705        assert!(
706            restored.agent_results.contains_key(&agent_id),
707            "upserted entry must survive close+reopen"
708        );
709        assert_eq!(restored.agent_results[&agent_id].tokens, 100);
710    }
711
712    #[test]
713    fn upsert_agent_result_updates_existing_entry() {
714        // F1: re-upserting the same agent_id overwrites the prior entry,
715        // mirroring the HashMap semantics of agent_results.
716        let dir = tempdir().unwrap();
717        let run_id = uuid::Uuid::now_v7();
718        let store = RunStore::new(dir.path()).unwrap();
719        store.init_run(run_id, "overwrite test").unwrap();
720
721        let agent_id = uuid::Uuid::now_v7();
722        let first = AgentResultCache {
723            agent_id,
724            phase_id: 1,
725            status: "ok".into(),
726            output: serde_json::json!("first"),
727            findings: vec![],
728            tokens: 10,
729            completed_at: 1,
730            cache_key_hash: None,
731            description: None,
732            role: None,
733        };
734        let second = AgentResultCache {
735            agent_id,
736            phase_id: 1,
737            status: "error".into(),
738            output: serde_json::json!("second"),
739            findings: vec![],
740            tokens: 99,
741            completed_at: 2,
742            cache_key_hash: None,
743            description: None,
744            role: None,
745        };
746        store.upsert_agent_result(&first).unwrap();
747        store.upsert_agent_result(&second).unwrap();
748
749        let cp = store.get_checkpoint().unwrap();
750        assert_eq!(cp.agent_results.len(), 1, "no duplicate entries");
751        let cached = &cp.agent_results[&agent_id];
752        assert_eq!(cached.status, "error");
753        assert_eq!(cached.tokens, 99);
754        assert_eq!(cached.completed_at, 2);
755
756        // Disk must also reflect the second upsert, not the first.
757        let raw = read_raw_checkpoint(dir.path());
758        let ar = raw
759            .get("agent_results")
760            .and_then(|v| v.as_object())
761            .unwrap();
762        assert_eq!(ar.len(), 1);
763        let entry = ar.values().next().unwrap();
764        assert_eq!(entry.get("tokens").and_then(|v| v.as_u64()), Some(99));
765        assert_eq!(entry.get("status").and_then(|v| v.as_str()), Some("error"));
766    }
767
768    #[test]
769    fn upsert_agent_result_noop_when_uninitialized() {
770        // F1: before init_run the in-memory checkpoint is None and the helper
771        // must not create a checkpoint.json from nothing. This keeps the
772        // behaviour of "upsert only patches an existing checkpoint".
773        let dir = tempdir().unwrap();
774        let store = RunStore::new(dir.path()).unwrap();
775        assert!(store.get_checkpoint().is_none());
776        let cp_path = dir.path().join("checkpoint.json");
777        assert!(!cp_path.exists(), "no checkpoint.json before init");
778
779        let cache = AgentResultCache {
780            agent_id: uuid::Uuid::now_v7(),
781            phase_id: 1,
782            status: "ok".into(),
783            output: serde_json::json!(null),
784            findings: vec![],
785            tokens: 0,
786            completed_at: 0,
787            cache_key_hash: None,
788            description: None,
789            role: None,
790        };
791        store.upsert_agent_result(&cache).unwrap();
792        assert!(
793            !cp_path.exists(),
794            "upsert_agent_result must not create checkpoint.json before init_run"
795        );
796        assert!(store.get_checkpoint().is_none());
797    }
798
799    #[test]
800    fn upsert_agent_result_advances_updated_at() {
801        // F1: the delegated write path must still update the checkpoint's
802        // `updated_at` timestamp the same way the inline implementation did.
803        let dir = tempdir().unwrap();
804        let run_id = uuid::Uuid::now_v7();
805        let store = RunStore::new(dir.path()).unwrap();
806        store.init_run(run_id, "ts test").unwrap();
807        let before = store.get_checkpoint().unwrap().updated_at;
808
809        std::thread::sleep(std::time::Duration::from_millis(1100));
810
811        let cache = AgentResultCache {
812            agent_id: uuid::Uuid::now_v7(),
813            phase_id: 1,
814            status: "ok".into(),
815            output: serde_json::json!(null),
816            findings: vec![],
817            tokens: 0,
818            completed_at: 0,
819            cache_key_hash: None,
820            description: None,
821            role: None,
822        };
823        store.upsert_agent_result(&cache).unwrap();
824        let after = store.get_checkpoint().unwrap().updated_at;
825        assert!(
826            after > before,
827            "updated_at must advance after upsert (before={before}, after={after})"
828        );
829    }
830
831    // ----- cancel (F1 delegation + F4 lock-dance collapse) ---------------
832
833    #[test]
834    fn cancel_persists_cancelled_status_to_disk() {
835        // F1+F4: cancel delegates to write_checkpoint_to_disk with the
836        // already-mutated checkpoint (no redundant read-lock + inline
837        // serialize). The Cancelled status must appear on disk so a follow-up
838        // process sees the terminal state.
839        let dir = tempdir().unwrap();
840        let run_id = uuid::Uuid::now_v7();
841        let store = RunStore::new(dir.path()).unwrap();
842        store.init_run(run_id, "cancel me").unwrap();
843        assert!(store.can_resume());
844
845        store.cancel().unwrap();
846
847        // In-memory: status is Cancelled, can_resume() is false.
848        let cp = store.get_checkpoint().unwrap();
849        assert_eq!(cp.status, CheckpointStatus::Cancelled);
850        assert!(!store.can_resume());
851
852        // On-disk: same status, observable across processes.
853        let raw = read_raw_checkpoint(dir.path());
854        assert_eq!(
855            raw.get("status").and_then(|v| v.as_str()),
856            Some("cancelled")
857        );
858
859        // Reopen: the persisted status survives close+reopen.
860        drop(store);
861        let reopened = RunStore::new(dir.path()).unwrap();
862        let restored = reopened.open_run(run_id).unwrap().unwrap();
863        assert_eq!(restored.status, CheckpointStatus::Cancelled);
864        assert!(!reopened.can_resume());
865    }
866
867    #[test]
868    fn cancel_is_idempotent() {
869        // F4: the new cancel body only mutates under the write lock and
870        // delegates to write_checkpoint_to_disk once. Calling it twice must
871        // not panic, not deadlock, and must leave the persisted state
872        // consistent (Cancelled, monotonically newer updated_at).
873        let dir = tempdir().unwrap();
874        let run_id = uuid::Uuid::now_v7();
875        let store = RunStore::new(dir.path()).unwrap();
876        store.init_run(run_id, "double cancel").unwrap();
877
878        store.cancel().unwrap();
879        let after_first = store.get_checkpoint().unwrap().updated_at;
880        std::thread::sleep(std::time::Duration::from_millis(1100));
881
882        store.cancel().expect("second cancel must succeed");
883        let after_second = store.get_checkpoint().unwrap().updated_at;
884
885        assert_eq!(
886            store.get_checkpoint().unwrap().status,
887            CheckpointStatus::Cancelled
888        );
889        assert!(
890            after_second >= after_first,
891            "updated_at must not regress (was {after_first}, now {after_second})"
892        );
893
894        let raw = read_raw_checkpoint(dir.path());
895        assert_eq!(
896            raw.get("status").and_then(|v| v.as_str()),
897            Some("cancelled")
898        );
899    }
900
901    #[test]
902    fn cancel_before_init_is_safe_noop() {
903        // F4: cancel on an uninitialised store must not panic and must not
904        // create a checkpoint file. The cancelled-status guard requires
905        // `Some(checkpoint)` so the body simply skips.
906        let dir = tempdir().unwrap();
907        let store = RunStore::new(dir.path()).unwrap();
908        assert!(store.get_checkpoint().is_none());
909        store.cancel().expect("cancel before init must succeed");
910        assert!(store.get_checkpoint().is_none());
911        assert!(
912            !dir.path().join("checkpoint.json").exists(),
913            "cancel before init must not create checkpoint.json"
914        );
915    }
916
917    #[test]
918    fn cancel_preserves_agent_results_and_findings() {
919        // F1+F4: cancel only mutates status/updated_at. Pre-existing
920        // agent_results, findings, and total_tokens must be preserved
921        // verbatim across the cancel write.
922        let dir = tempdir().unwrap();
923        let run_id = uuid::Uuid::now_v7();
924        let store = RunStore::new(dir.path()).unwrap();
925        store.init_run(run_id, "preserve").unwrap();
926
927        let agent_id = uuid::Uuid::now_v7();
928        let cache = AgentResultCache {
929            agent_id,
930            phase_id: 1,
931            status: "ok".into(),
932            output: serde_json::json!({"x": 1}),
933            findings: vec![],
934            tokens: 250,
935            completed_at: 7,
936            cache_key_hash: Some("hash-1".into()),
937            description: None,
938            role: None,
939        };
940        store.upsert_agent_result(&cache).unwrap();
941        let before = store.get_checkpoint().unwrap();
942
943        store.cancel().unwrap();
944        let after = store.get_checkpoint().unwrap();
945
946        assert_eq!(after.status, CheckpointStatus::Cancelled);
947        assert_eq!(after.agent_results.len(), 1);
948        assert_eq!(after.agent_results[&agent_id].tokens, 250);
949        assert_eq!(
950            after.agent_results[&agent_id].cache_key_hash.as_deref(),
951            Some("hash-1")
952        );
953        assert_eq!(after.total_tokens, before.total_tokens);
954    }
955
956    // ----- AgentDone -> AgentResultCache.status (F5) ---------------------
957
958    #[test]
959    fn agent_done_persists_snake_case_status_for_each_variant() {
960        // F5 KEY test: the persisted AgentResultCache.status string MUST
961        // come from AgentStatus::as_str() and NOT from Debug formatting.
962        // For TimedOut this is the load-bearing regression: Debug lowercased
963        // yields "timedout" (no underscore) but as_str() yields "timed_out".
964        let dir = tempdir().unwrap();
965        let run_id = uuid::Uuid::now_v7();
966        let store = RunStore::new(dir.path()).unwrap();
967        store.init_run(run_id, "F5 variants").unwrap();
968
969        let cases: Vec<(AgentStatus, &str)> = vec![
970            (AgentStatus::Ok, "ok"),
971            (AgentStatus::Error, "error"),
972            (AgentStatus::Cancelled, "cancelled"),
973            (AgentStatus::TimedOut, "timed_out"),
974        ];
975        for (status, expected) in &cases {
976            let agent_id = uuid::Uuid::now_v7();
977            let evt = build_agent_done(run_id, agent_id, status.clone(), sample_token_usage());
978            store.append_event(&evt).unwrap();
979
980            let raw = read_raw_checkpoint(dir.path());
981            let ar = raw
982                .get("agent_results")
983                .and_then(|v| v.as_object())
984                .expect("agent_results object");
985            let entry = ar
986                .values()
987                .find(|v| {
988                    v.get("agent_id").and_then(|id| id.as_str()) == Some(&agent_id.to_string())
989                })
990                .unwrap_or_else(|| panic!("entry for {agent_id} missing"));
991            let persisted = entry
992                .get("status")
993                .and_then(|v| v.as_str())
994                .unwrap_or_else(|| panic!("status missing for {status:?}"));
995            assert_eq!(
996                persisted, *expected,
997                "AgentDone({status:?}) must persist status={expected:?} (snake_case); \
998                 got {persisted:?}. If this fails with \"timedout\" for TimedOut, \
999                 F5 has regressed to Debug formatting."
1000            );
1001        }
1002    }
1003
1004    #[test]
1005    fn agent_done_timed_out_persists_with_underscore_not_collapsed() {
1006        // Strongest F5 regression guard: the buggy form would persist
1007        // "timedout" (no underscore) for TimedOut. The fixed form persists
1008        // "timed_out". This test fails loudly if anyone reintroduces the
1009        // `format!("{:?}", status).to_lowercase()` shortcut.
1010        let dir = tempdir().unwrap();
1011        let run_id = uuid::Uuid::now_v7();
1012        let store = RunStore::new(dir.path()).unwrap();
1013        store.init_run(run_id, "timed-out guard").unwrap();
1014
1015        let agent_id = uuid::Uuid::now_v7();
1016        let evt = build_agent_done(
1017            run_id,
1018            agent_id,
1019            AgentStatus::TimedOut,
1020            sample_token_usage(),
1021        );
1022        store.append_event(&evt).unwrap();
1023
1024        let raw = read_raw_checkpoint(dir.path());
1025        let ar = raw
1026            .get("agent_results")
1027            .and_then(|v| v.as_object())
1028            .unwrap();
1029        let entry = ar.values().next().expect("entry exists");
1030        let persisted = entry.get("status").and_then(|v| v.as_str()).unwrap();
1031
1032        assert_eq!(
1033            persisted, "timed_out",
1034            "AgentDone(TimedOut) must persist \"timed_out\" with an underscore; got {persisted:?}"
1035        );
1036        assert_ne!(
1037            persisted, "timedout",
1038            "AgentDone(TimedOut) must NOT collapse to Debug-lowercased \"timedout\""
1039        );
1040    }
1041
1042    #[test]
1043    fn agent_done_then_reopen_restores_snake_case_status() {
1044        // The persisted snake_case status must survive a close+reopen cycle,
1045        // since legacy checkpoints with Debug-lowercased "timedout" should be
1046        // distinguished from new checkpoints with "timed_out" — but new
1047        // checkpoints must round-trip cleanly through the JSON pipeline.
1048        let dir = tempdir().unwrap();
1049        let run_id = uuid::Uuid::now_v7();
1050        let store = RunStore::new(dir.path()).unwrap();
1051        store.init_run(run_id, "round-trip").unwrap();
1052
1053        let agent_id = uuid::Uuid::now_v7();
1054        let evt = build_agent_done(
1055            run_id,
1056            agent_id,
1057            AgentStatus::Cancelled,
1058            TokenUsage {
1059                input: 1,
1060                output: 2,
1061                cache_read: 0,
1062                cache_write: 0,
1063            },
1064        );
1065        store.append_event(&evt).unwrap();
1066        drop(store);
1067
1068        let reopened = RunStore::new(dir.path()).unwrap();
1069        let cp = reopened.open_run(run_id).unwrap().unwrap();
1070        let cached = cp
1071            .agent_results
1072            .get(&agent_id)
1073            .expect("agent cached on disk");
1074        assert_eq!(cached.status, "cancelled");
1075        assert_eq!(cached.tokens, 3);
1076    }
1077
1078    // ----- F8 serde_to_io error mapping (indirect) -----------------------
1079
1080    #[test]
1081    fn open_run_with_corrupt_checkpoint_returns_invalid_data() {
1082        // F8: every serde_json::Error → io::Error funnel passes through
1083        // ErrorKind::InvalidData. Verifies the consolidated helper is wired
1084        // into open_run's deserialization path.
1085        let dir = tempdir().unwrap();
1086        std::fs::create_dir_all(dir.path()).unwrap();
1087        std::fs::write(
1088            dir.path().join("checkpoint.json"),
1089            b"{ this is not valid json",
1090        )
1091        .unwrap();
1092
1093        let store = RunStore::new(dir.path()).unwrap();
1094        let err = store
1095            .open_run(uuid::Uuid::now_v7())
1096            .expect_err("corrupt JSON must surface as an io::Error");
1097        assert_eq!(
1098            err.kind(),
1099            std::io::ErrorKind::InvalidData,
1100            "corrupt checkpoint must map to InvalidData via serde_to_io; got {:?}",
1101            err.kind()
1102        );
1103    }
1104
1105    #[test]
1106    fn open_run_with_wrong_typed_checkpoint_returns_invalid_data() {
1107        // F8: even structurally-valid JSON that fails typed deserialisation
1108        // (missing required field) must come back as InvalidData.
1109        let dir = tempdir().unwrap();
1110        std::fs::create_dir_all(dir.path()).unwrap();
1111        // `task` is a required field on RunCheckpoint; omitting it triggers
1112        // a serde error which the helper must classify as InvalidData.
1113        std::fs::write(
1114            dir.path().join("checkpoint.json"),
1115            br#"{"run_id":"00000000-0000-0000-0000-000000000000","status":"running"}"#,
1116        )
1117        .unwrap();
1118
1119        let store = RunStore::new(dir.path()).unwrap();
1120        let err = store
1121            .open_run(uuid::Uuid::now_v7())
1122            .expect_err("missing-field JSON must surface as an io::Error");
1123        assert_eq!(err.kind(), std::io::ErrorKind::InvalidData);
1124    }
1125
1126    #[test]
1127    fn get_event_log_with_corrupt_line_returns_invalid_data() {
1128        // F8: the consolidated helper also covers get_event_log's per-line
1129        // deserialisation. A single bad line must surface as InvalidData
1130        // rather than SomeOtherKind so callers can distinguish "corrupted
1131        // journal" from "missing file".
1132        let dir = tempdir().unwrap();
1133        std::fs::create_dir_all(dir.path()).unwrap();
1134        std::fs::write(dir.path().join("events.jsonl"), b"not-json\n").unwrap();
1135
1136        let store = RunStore::new(dir.path()).unwrap();
1137        let err = store
1138            .get_event_log()
1139            .expect_err("corrupt event line must surface as an io::Error");
1140        assert_eq!(
1141            err.kind(),
1142            std::io::ErrorKind::InvalidData,
1143            "corrupt event line must map to InvalidData via serde_to_io; got {:?}",
1144            err.kind()
1145        );
1146    }
1147
1148    // ----- Cross-cutting safety nets -------------------------------------
1149
1150    #[test]
1151    fn as_str_variants_round_trip_through_checkpoint_pipeline() {
1152        // Property-style test: for every AgentStatus variant, the persisted
1153        // status string must equal AgentStatus::variant.as_str() exactly,
1154        // with no whitespace, no case drift, and no truncation. This catches
1155        // accidental future reverts to Debug-derived strings.
1156        let dir = tempdir().unwrap();
1157        let run_id = uuid::Uuid::now_v7();
1158        let store = RunStore::new(dir.path()).unwrap();
1159        store.init_run(run_id, "round-trip property").unwrap();
1160
1161        let variants = [
1162            AgentStatus::Ok,
1163            AgentStatus::Error,
1164            AgentStatus::Cancelled,
1165            AgentStatus::TimedOut,
1166        ];
1167        let mut seen: HashSet<String> = HashSet::new();
1168
1169        for variant in &variants {
1170            let agent_id = uuid::Uuid::now_v7();
1171            let evt = build_agent_done(run_id, agent_id, variant.clone(), sample_token_usage());
1172            store.append_event(&evt).unwrap();
1173
1174            let cp = store.get_checkpoint().unwrap();
1175            let cached = cp
1176                .agent_results
1177                .get(&agent_id)
1178                .expect("entry for {agent_id}");
1179            assert_eq!(
1180                cached.status,
1181                variant.as_str(),
1182                "{variant:?}.as_str() must round-trip via append_event→update_from_event"
1183            );
1184            // Also confirm uniqueness is preserved on disk.
1185            assert!(
1186                seen.insert(cached.status.clone()),
1187                "duplicate status {cached_status:?} persisted for {variant:?}",
1188                cached_status = cached.status
1189            );
1190        }
1191    }
1192}