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