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