Skip to main content

luft_core/
journal.rs

1//! Journal / Resume — checkpoint persistence with replay semantics (M1).
2//!
3//! Provides:
4//! - `JournalStore` — wraps `RunStore` with cache-key index for O(1) lookups
5//! - `AgentCacheKey` — deterministic blake3-based key for agent invocations
6//! - `JournalCallback` trait — scheduler integration hook
7//! - `ResumeContext` — orchestrates run recovery
8//! - `gc_runs()` — cleanup old completed runs
9//!
10//! Thread safety: All public methods take `&self` (interior mutability via RwLock).
11//! The underlying checkpoint data is protected by a single writer lock.
12//!
13//! Lifecycle:
14//!   new() → init_run() → cache_agent()* → flush()
15//!   或:
16//!   open() → has_completed()/get_cached() → workflow resume logic
17
18use crate::contract::backend::AgentStatus;
19use crate::contract::event::{AgentEvent, EventSender};
20use crate::contract::finding::Finding;
21use crate::contract::ids::{AgentId, PhaseId, RunId, TokenUsage};
22use crate::scheduler::{BackendRegistry, SchedulerConfig};
23use crate::state::{AgentResultCache, RunCheckpoint, RunStore};
24use blake3::Hasher;
25use serde::{Deserialize, Serialize};
26use std::collections::HashMap;
27use std::path::Path;
28use std::sync::{Arc, RwLock};
29use std::time::{Duration, SystemTime, UNIX_EPOCH};
30use thiserror::Error;
31
32// ============================================================================
33// Error Types
34// ============================================================================
35
36#[derive(Error, Debug)]
37pub enum JournalError {
38    #[error("run not found: {0}")]
39    RunNotFound(RunId),
40    #[error("run is not resumable (status: {status:?})")]
41    NotResumable { status: String },
42    #[error("I/O error: {0}")]
43    Io(#[from] std::io::Error),
44    #[error("serialization error: {0}")]
45    Serde(#[from] serde_json::Error),
46    #[error("journal corrupted: {0}")]
47    Corrupted(String),
48}
49
50// ============================================================================
51// Agent Cache Key
52// ============================================================================
53
54/// Deterministic cache key for an agent invocation.
55/// Normalizes whitespace/unicode to ensure cache hits across formatting differences.
56#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
57pub struct AgentCacheKey {
58    pub hash: String,
59    /// Human-readable for debugging
60    pub prompt_preview: String,
61    pub model: Option<String>,
62    pub phase_id: PhaseId,
63}
64
65impl AgentCacheKey {
66    /// Generate a cache key from agent parameters.
67    /// Uses blake3 with null separators to prevent field-concatenation collisions.
68    pub fn new(prompt: &str, model: Option<&str>, phase_id: PhaseId) -> Self {
69        let normalized = normalize_prompt(prompt);
70        let preview = if normalized.chars().count() > 80 {
71            format!("{}...", normalized.chars().take(80).collect::<String>())
72        } else {
73            normalized.clone()
74        };
75
76        let mut h = Hasher::new();
77        h.update(normalized.as_bytes());
78        h.update(b"\0");
79        if let Some(m) = model {
80            h.update(m.as_bytes());
81        }
82        h.update(b"\0");
83        h.update(&phase_id.to_le_bytes());
84
85        Self {
86            hash: h.finalize().to_hex().to_string(),
87            prompt_preview: preview,
88            model: model.map(|s| s.to_string()),
89            phase_id,
90        }
91    }
92}
93
94fn normalize_prompt(prompt: &str) -> String {
95    prompt
96        .replace("\r\n", "\n")
97        .replace('\r', "\n")
98        .split_whitespace()
99        .collect::<Vec<_>>()
100        .join(" ")
101}
102
103// ============================================================================
104// JournalStore — the journal abstraction over RunStore
105// ============================================================================
106
107/// JournalStore wraps RunStore with replay semantics.
108///
109/// Thread safety: All public methods take `&self` (interior mutability via RwLock).
110/// The underlying checkpoint data is protected by a single writer lock.
111///
112/// Usage lifecycle:
113///   new() → init_run() → cache_agent()* → flush()
114///   或:
115///   open() → has_completed()/get_cached() → workflow resume logic
116pub struct JournalStore {
117    /// Underlying persistence engine (checkpoint.json + events.jsonl).
118    inner: Arc<RunStore>,
119    /// In-memory index: AgentCacheKey hash → AgentResultCache.
120    /// Populated at open() time from the checkpoint's agent_results map.
121    cache_index: RwLock<HashMap<String, AgentResultCache>>,
122    /// Event sender for broadcasting journal updates.
123    event_tx: Option<EventSender>,
124}
125
126impl std::fmt::Debug for JournalStore {
127    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
128        f.debug_struct("JournalStore")
129            .field("inner", &self.inner)
130            .field("cache_index_size", &self.cache_index.read().unwrap().len())
131            .field("has_event_tx", &self.event_tx.is_some())
132            .finish()
133    }
134}
135
136impl JournalStore {
137    /// Create a new journal store at the given directory.
138    /// Initializes the underlying RunStore and creates an empty cache index.
139    pub fn new(run_dir: &Path) -> Result<Self, JournalError> {
140        tracing::debug!(path = %run_dir.display(), "creating journal store");
141        let inner = RunStore::new(run_dir)?;
142        Ok(Self {
143            inner,
144            cache_index: RwLock::new(HashMap::new()),
145            event_tx: None,
146        })
147    }
148
149    /// Attach an event sender for broadcasting journal updates.
150    pub fn with_event_sender(mut self, tx: EventSender) -> Self {
151        self.event_tx = Some(tx);
152        self
153    }
154
155    /// Initialize a new run in the journal.
156    pub fn init_run(&self, run_id: RunId, task: &str) -> Result<(), JournalError> {
157        tracing::info!(%run_id, %task, "initializing run in journal");
158        self.inner.init_run(run_id, task)?;
159        Ok(())
160    }
161
162    /// Initialize a new run with declarative workflow metadata.
163    pub fn init_run_with_meta(
164        &self,
165        run_id: RunId,
166        task: &str,
167        workflow_meta: serde_json::Value,
168    ) -> Result<(), JournalError> {
169        tracing::info!(
170            %run_id, %task,
171            "initializing run in journal with meta"
172        );
173        self.inner.init_run_with_meta(run_id, task, workflow_meta)?;
174        Ok(())
175    }
176
177    /// Open an existing run and rebuild the cache index from persisted data.
178    ///
179    /// This is the entry point for `--resume`. It:
180    /// 1. Loads the checkpoint from disk
181    /// 2. Rebuilds the in-memory cache_index from agent_results
182    /// 3. Returns the checkpoint for the caller to inspect
183    pub fn open(&self, run_id: RunId) -> Result<RunCheckpoint, JournalError> {
184        tracing::info!(%run_id, "opening journal for resume");
185        let checkpoint = self
186            .inner
187            .open_run(run_id)?
188            .ok_or(JournalError::RunNotFound(run_id))?;
189
190        if matches!(
191            checkpoint.status,
192            crate::state::CheckpointStatus::Completed | crate::state::CheckpointStatus::Cancelled
193        ) {
194            return Err(JournalError::NotResumable {
195                status: format!("{:?}", checkpoint.status),
196            });
197        }
198
199        // Rebuild cache index — index by both agent_id and cache_key_hash
200        // so that the Lua SDK's has_completed(key) works after resume.
201        let mut index = HashMap::new();
202        for (agent_id, cache) in &checkpoint.agent_results {
203            index.insert(agent_id.to_string(), cache.clone());
204            if let Some(ref hash) = cache.cache_key_hash {
205                index.insert(hash.clone(), cache.clone());
206            }
207        }
208        *self.cache_index.write().unwrap() = index;
209
210        Ok(checkpoint)
211    }
212
213    /// Cache an agent's result in the journal.
214    ///
215    /// Called by the scheduler after an agent completes successfully or fails
216    /// with a non-retryable error. The result is persisted to disk immediately
217    /// (via append_event → update_from_event → write_checkpoint_to_disk).
218    #[allow(clippy::too_many_arguments)]
219    pub fn cache_agent(
220        &self,
221        cache_key: &AgentCacheKey,
222        agent_id: AgentId,
223        phase_id: PhaseId,
224        status: AgentStatus,
225        output: serde_json::Value,
226        findings: Vec<Finding>,
227        tokens: TokenUsage,
228    ) -> Result<AgentCacheKey, JournalError> {
229        let ts = current_timestamp();
230        let cache = AgentResultCache {
231            agent_id,
232            phase_id,
233            status: status.as_str().to_string(),
234            output,
235            findings,
236            tokens: tokens.total(),
237            completed_at: ts,
238            cache_key_hash: Some(cache_key.hash.clone()),
239            description: None,
240            role: None,
241        };
242
243        // Update in-memory index (instant lookup)
244        {
245            let mut index = self.cache_index.write().unwrap();
246            index.insert(cache_key.hash.clone(), cache.clone());
247            // Also index by agent_id for open() compatibility
248            index.insert(agent_id.to_string(), cache.clone());
249        }
250
251        // Persist the full cache entry directly to checkpoint disk (preserves cache_key_hash)
252        if let Err(e) = self.inner.upsert_agent_result(&cache) {
253            tracing::warn!(%agent_id, error = %e, "failed to persist agent cache");
254        }
255
256        // Also append event to log (this triggers update_from_event which finds the existing hash)
257        let event = AgentEvent::AgentDone {
258            run_id: self
259                .inner
260                .get_checkpoint()
261                .map(|c| c.run_id)
262                .unwrap_or_else(uuid::Uuid::nil),
263            agent_id,
264            status,
265            tokens,
266            elapsed_ms: 0,
267            name: None,
268            agent_seq: 0,
269            output: serde_json::Value::Null,
270            findings: Vec::new(),
271            prompt: String::new(),
272            retry_count: 0,
273        };
274        self.inner.append_event(&event)?;
275
276        // Broadcast via event bus (non-blocking — uses broadcast channel)
277        if let Some(ref tx) = self.event_tx {
278            let _ = tx.send(event);
279        }
280
281        Ok(cache_key.clone())
282    }
283
284    /// Record an agent's output for resume replay, keyed by `cache_key`.
285    ///
286    /// Unlike [`cache_agent`], this does **not** append an `AgentDone` event,
287    /// so it never double-counts tokens against the event-driven checkpoint
288    /// totals. It only upserts the checkpoint entry (preserving `cache_key_hash`
289    /// and the structured output) and refreshes the in-memory cache index.
290    /// Called by the Lua SDK after an agent completes during a live run.
291    #[allow(clippy::too_many_arguments)]
292    pub fn record_result(
293        &self,
294        cache_key: &AgentCacheKey,
295        agent_id: AgentId,
296        phase_id: PhaseId,
297        status: AgentStatus,
298        output: serde_json::Value,
299        findings: Vec<Finding>,
300        tokens: TokenUsage,
301    ) {
302        let cache = AgentResultCache {
303            agent_id,
304            phase_id,
305            status: status.as_str().to_string(),
306            output,
307            findings,
308            tokens: tokens.total(),
309            completed_at: current_timestamp(),
310            cache_key_hash: Some(cache_key.hash.clone()),
311            description: None,
312            role: None,
313        };
314
315        {
316            let mut index = self.cache_index.write().unwrap();
317            index.insert(cache_key.hash.clone(), cache.clone());
318            index.insert(agent_id.to_string(), cache.clone());
319        }
320
321        if let Err(e) = self.inner.upsert_agent_result(&cache) {
322            tracing::warn!(%agent_id, error = %e, "failed to persist agent result");
323        }
324    }
325
326    /// Access the underlying run store (shared persistence engine).
327    /// Allows the CLI to route the scheduler event stream through the same
328    /// `RunStore` instance the journal uses, avoiding split-brain checkpoints.
329    pub fn store(&self) -> Arc<RunStore> {
330        self.inner.clone()
331    }
332
333    /// Append an event to the underlying run store (event log + checkpoint).
334    pub fn append_event(&self, event: &AgentEvent) -> Result<(), JournalError> {
335        self.inner.append_event(event)?;
336        Ok(())
337    }
338
339    /// Check if an agent with the given cache key has already completed.
340    /// Used by the Lua SDK's agent() function before submitting to the scheduler.
341    pub fn has_completed(&self, cache_key: &AgentCacheKey) -> bool {
342        let index = self.cache_index.read().unwrap();
343        index.contains_key(&cache_key.hash)
344    }
345
346    /// Get cached result for an agent.
347    /// Returns None if the agent hasn't completed yet.
348    pub fn get_cached(&self, cache_key: &AgentCacheKey) -> Option<AgentResultCache> {
349        let index = self.cache_index.read().unwrap();
350        index.get(&cache_key.hash).cloned()
351    }
352
353    /// Get list of all completed agent cache keys.
354    /// Useful for debugging and progress reporting.
355    pub fn completed_keys(&self) -> Vec<AgentCacheKey> {
356        let index = self.cache_index.read().unwrap();
357        index
358            .keys()
359            .map(|k| AgentCacheKey {
360                hash: k.clone(),
361                prompt_preview: String::new(),
362                model: None,
363                phase_id: 0,
364            })
365            .collect()
366    }
367
368    /// Get the underlying checkpoint (read-only snapshot).
369    pub fn get_checkpoint(&self) -> Option<RunCheckpoint> {
370        self.inner.get_checkpoint()
371    }
372
373    /// Flush all pending data to disk.
374    pub fn flush(&self) -> Result<(), JournalError> {
375        // RunStore auto-flushes on append_event; explicit flush for safety.
376        Ok(())
377    }
378
379    /// Mark the run as cancelled.
380    pub fn cancel(&self) -> Result<(), JournalError> {
381        self.inner.cancel()?;
382        Ok(())
383    }
384}
385
386// ============================================================================
387// Scheduler Integration — JournalCallback trait
388// ============================================================================
389
390/// Composite callback that chains multiple JournalCallback implementations.
391pub struct CompositeJournalCallback {
392    callbacks: Vec<Arc<dyn crate::scheduler::JournalCallback>>,
393}
394
395impl CompositeJournalCallback {
396    pub fn new(callbacks: Vec<Arc<dyn crate::scheduler::JournalCallback>>) -> Self {
397        Self { callbacks }
398    }
399}
400
401#[async_trait::async_trait]
402impl crate::scheduler::JournalCallback for CompositeJournalCallback {
403    async fn on_agent_done(
404        &self,
405        agent_id: AgentId,
406        phase_id: PhaseId,
407        status: AgentStatus,
408        output: serde_json::Value,
409        tokens: TokenUsage,
410    ) {
411        for cb in &self.callbacks {
412            cb.on_agent_done(agent_id, phase_id, status.clone(), output.clone(), tokens)
413                .await;
414        }
415    }
416}
417
418#[async_trait::async_trait]
419impl crate::scheduler::JournalCallback for JournalStore {
420    async fn on_agent_done(
421        &self,
422        agent_id: AgentId,
423        phase_id: PhaseId,
424        status: AgentStatus,
425        output: serde_json::Value,
426        tokens: TokenUsage,
427    ) {
428        // Store into the checkpoint by agent_id directly (no cache_key index needed).
429        let ts = current_timestamp();
430        let cache = AgentResultCache {
431            agent_id,
432            phase_id,
433            status: status.as_str().to_string(),
434            output,
435            findings: vec![], // findings not available from scheduler callback
436            tokens: tokens.total(),
437            completed_at: ts,
438            cache_key_hash: None, // not indexed by cache key from this path
439            description: None,
440            role: None,
441        };
442
443        // Persist to checkpoint disk
444        if let Err(e) = self.inner.upsert_agent_result(&cache) {
445            tracing::warn!(%agent_id, error = %e, "failed to persist agent result from callback");
446        }
447    }
448}
449
450// ============================================================================
451// Resume Orchestration
452// ============================================================================
453
454/// Context for resuming a run.
455#[derive(Debug)]
456pub struct ResumeContext {
457    pub run_id: RunId,
458    pub checkpoint: RunCheckpoint,
459    pub journal: Arc<JournalStore>,
460    pub scheduler_config: SchedulerConfig,
461    pub backend_registry: BackendRegistry,
462}
463
464/// Options for creating a run (new or resume).
465#[derive(Debug, Clone)]
466pub enum RunCreationMode {
467    /// Start a fresh run.
468    New { task: String },
469    /// Resume from an existing checkpoint.
470    Resume { run_id: RunId, run_dir_name: String },
471    /// Auto-detect: resume if resumable run exists, else new.
472    Auto { task: String },
473}
474
475impl RunCreationMode {
476    /// Resolve the creation mode to concrete parameters.
477    /// For Auto mode, checks journal directory for resumable runs.
478    pub fn resolve(
479        self,
480        journal_dir: &Path,
481    ) -> Result<(RunId, Option<RunCheckpoint>), JournalError> {
482        match self {
483            RunCreationMode::New { task: _ } => {
484                let run_id = uuid::Uuid::now_v7();
485                Ok((run_id, None))
486            }
487            RunCreationMode::Resume {
488                run_id,
489                run_dir_name,
490            } => {
491                let store = JournalStore::new(&journal_dir.join(&run_dir_name))?;
492                let checkpoint = store.open(run_id)?;
493                Ok((run_id, Some(checkpoint)))
494            }
495            RunCreationMode::Auto { task: _ } => {
496                // List all run dirs, find the most recent Running checkpoint
497                let run_dirs = crate::state::list_runs(journal_dir)?;
498                for dir_name in run_dirs.iter().rev() {
499                    let checkpoint_path = journal_dir.join(dir_name).join("checkpoint.json");
500                    if let Ok(content) = std::fs::read_to_string(&checkpoint_path) {
501                        if let Ok(checkpoint) = serde_json::from_str::<RunCheckpoint>(&content) {
502                            if matches!(checkpoint.status, crate::state::CheckpointStatus::Running)
503                            {
504                                let run_id = checkpoint.run_id;
505                                return Ok((run_id, Some(checkpoint)));
506                            }
507                        }
508                    }
509                }
510                // No resumable run — create new
511                let run_id = uuid::Uuid::now_v7();
512                Ok((run_id, None))
513            }
514        }
515    }
516}
517
518// ============================================================================
519// GC (Garbage Collection)
520// ============================================================================
521
522/// Clean up old completed/cancelled runs.
523///
524/// Policy:
525/// - Completed/Cancelled runs older than `older_than` are eligible for deletion.
526/// - Running runs are never cleaned.
527///
528/// Returns the number of runs cleaned.
529pub fn gc_runs(journal_dir: &Path, older_than: Duration) -> Result<usize, JournalError> {
530    let run_dirs = crate::state::list_runs(journal_dir)?;
531    let cutoff = current_timestamp().saturating_sub(older_than.as_secs());
532
533    tracing::debug!("GC: scanning {} runs", run_dirs.len());
534    let mut cleaned = 0;
535    for dir_name in &run_dirs {
536        let run_dir = journal_dir.join(dir_name);
537        // Peek at checkpoint without full open
538        let checkpoint_path = run_dir.join("checkpoint.json");
539        if !checkpoint_path.exists() {
540            continue;
541        }
542
543        let content = std::fs::read_to_string(&checkpoint_path)?;
544        let checkpoint: RunCheckpoint = serde_json::from_str(&content)?;
545
546        let is_old = checkpoint.updated_at < cutoff;
547        let is_terminal = matches!(
548            checkpoint.status,
549            crate::state::CheckpointStatus::Completed
550                | crate::state::CheckpointStatus::Cancelled
551                | crate::state::CheckpointStatus::Failed
552        );
553
554        if is_old && is_terminal {
555            tracing::info!(dir = %dir_name, "GC: removing old terminal run");
556            std::fs::remove_dir_all(&run_dir)?;
557            cleaned += 1;
558        }
559    }
560
561    Ok(cleaned)
562}
563
564fn current_timestamp() -> u64 {
565    SystemTime::now()
566        .duration_since(UNIX_EPOCH)
567        .map(|d| d.as_secs())
568        .unwrap_or(0)
569}
570
571// ============================================================================
572// Tests
573// ============================================================================
574
575#[cfg(test)]
576mod tests {
577    use super::*;
578    use tempfile::tempdir;
579
580    /// Test basic journal lifecycle: init → cache → read → cancel
581    #[test]
582    fn test_journal_lifecycle() {
583        let dir = tempdir().unwrap();
584        let run_id = uuid::Uuid::now_v7();
585        let journal = JournalStore::new(dir.path()).unwrap();
586
587        // 1. Init
588        journal.init_run(run_id, "Test task").unwrap();
589        let cp = journal.get_checkpoint().unwrap();
590        assert_eq!(cp.status, crate::state::CheckpointStatus::Running);
591        assert_eq!(cp.task, "Test task");
592
593        // 2. Cache an agent result
594        let agent_id = uuid::Uuid::now_v7();
595        let key = AgentCacheKey::new("test prompt", Some("gpt-4"), 1);
596        journal
597            .cache_agent(
598                &key,
599                agent_id,
600                1,
601                AgentStatus::Ok,
602                serde_json::json!({"result": "ok"}),
603                vec![],
604                TokenUsage {
605                    input: 100,
606                    output: 50,
607                    cache_read: 0,
608                    cache_write: 0,
609                },
610            )
611            .unwrap();
612
613        // 3. Verify cache
614        assert!(journal.has_completed(&key));
615        let cached = journal.get_cached(&key).unwrap();
616        assert_eq!(cached.output, serde_json::json!({"result": "ok"}));
617        assert_eq!(cached.tokens, 150);
618
619        // 4. Cancel
620        journal.cancel().unwrap();
621        let cp = journal.get_checkpoint().unwrap();
622        assert_eq!(cp.status, crate::state::CheckpointStatus::Cancelled);
623    }
624
625    /// Test that different prompts produce different cache keys
626    #[test]
627    fn test_cache_key_uniqueness() {
628        let k1 = AgentCacheKey::new("prompt A", Some("gpt-4"), 1);
629        let k2 = AgentCacheKey::new("prompt B", Some("gpt-4"), 1);
630        assert_ne!(k1.hash, k2.hash);
631
632        // Same prompt, different model
633        let k3 = AgentCacheKey::new("prompt A", Some("claude"), 1);
634        assert_ne!(k1.hash, k3.hash);
635
636        // Same prompt, different phase
637        let k4 = AgentCacheKey::new("prompt A", Some("gpt-4"), 2);
638        assert_ne!(k1.hash, k4.hash);
639
640        // Whitespace normalization
641        let k5 = AgentCacheKey::new("  prompt  \r\nA  ", Some("gpt-4"), 1);
642        assert_eq!(k1.hash, k5.hash);
643    }
644
645    /// Test resume: simulate workflow with 3 agents, 2 cached, 1 new
646    #[test]
647    fn test_resume_skip_cached() {
648        let dir = tempdir().unwrap();
649        let run_id = uuid::Uuid::now_v7();
650        let journal = JournalStore::new(dir.path()).unwrap();
651        journal.init_run(run_id, "Three agent test").unwrap();
652
653        // Cache agent 1 and 2
654        let k1 = AgentCacheKey::new("task 1", None, 1);
655        let k2 = AgentCacheKey::new("task 2", None, 1);
656        let k3 = AgentCacheKey::new("task 3", None, 1);
657
658        journal
659            .cache_agent(
660                &k1,
661                uuid::Uuid::now_v7(),
662                1,
663                AgentStatus::Ok,
664                serde_json::json!({"done": 1}),
665                vec![],
666                TokenUsage {
667                    input: 10,
668                    output: 5,
669                    cache_read: 0,
670                    cache_write: 0,
671                },
672            )
673            .unwrap();
674        journal
675            .cache_agent(
676                &k2,
677                uuid::Uuid::now_v7(),
678                1,
679                AgentStatus::Ok,
680                serde_json::json!({"done": 2}),
681                vec![],
682                TokenUsage {
683                    input: 10,
684                    output: 5,
685                    cache_read: 0,
686                    cache_write: 0,
687                },
688            )
689            .unwrap();
690
691        // Verify cache hits
692        assert!(journal.has_completed(&k1));
693        assert!(journal.has_completed(&k2));
694        assert!(!journal.has_completed(&k3));
695
696        // Agent 3 should NOT be cached → would go through scheduler
697        assert!(journal.get_cached(&k3).is_none());
698    }
699
700    /// Test that journal survives crash (simulated by re-opening)
701    #[test]
702    fn test_journal_crash_recovery() {
703        let dir = tempdir().unwrap();
704        let run_id = uuid::Uuid::now_v7();
705
706        // Part 1: Create and cache
707        {
708            let j = JournalStore::new(dir.path()).unwrap();
709            j.init_run(run_id, "Crash test").unwrap();
710            let key = AgentCacheKey::new("important work", None, 0);
711            j.cache_agent(
712                &key,
713                uuid::Uuid::now_v7(),
714                0,
715                AgentStatus::Ok,
716                serde_json::json!({"survived": true}),
717                vec![],
718                TokenUsage {
719                    input: 1,
720                    output: 1,
721                    cache_read: 0,
722                    cache_write: 0,
723                },
724            )
725            .unwrap();
726        } // j dropped — simulates crash
727
728        // Part 2: Re-open and verify data survived
729        {
730            let j2 = JournalStore::new(dir.path()).unwrap();
731            let cp = j2.open(run_id).unwrap();
732            assert_eq!(cp.status, crate::state::CheckpointStatus::Running);
733            assert!(!cp.agent_results.is_empty());
734
735            let key = AgentCacheKey::new("important work", None, 0);
736            let cached = j2.get_cached(&key).unwrap();
737            assert_eq!(cached.output, serde_json::json!({"survived": true}));
738        }
739    }
740
741    /// Test GC reference
742    #[test]
743    fn test_gc_older_than() {
744        let dir = tempdir().unwrap();
745        let run_dir = dir.path().join("runs");
746        std::fs::create_dir_all(&run_dir).unwrap();
747
748        // Create a completed run
749        let run_id = uuid::Uuid::now_v7();
750        let journal = JournalStore::new(&run_dir.join(run_id.to_string())).unwrap();
751        journal.init_run(run_id, "GC me").unwrap();
752
753        // Manually mark as completed with old timestamp
754        if let Some(mut cp) = journal.get_checkpoint() {
755            cp.status = crate::state::CheckpointStatus::Completed;
756            cp.updated_at = 1000; // Very old
757            let _ = journal.inner.save_checkpoint(&cp);
758        }
759
760        // GC with very short duration
761        let cleaned = gc_runs(&run_dir, Duration::from_secs(3600)).unwrap();
762        assert_eq!(cleaned, 1);
763    }
764
765    // ----------------------------------------------------------------------
766    // Tests for the F5 contract — AgentResultCache.status persistence.
767    //
768    // cache_agent, record_result, and the JournalCallback impl for
769    // JournalStore all persist AgentResultCache.status. Before F5 the value
770    // was derived from `format!("{:?}", status).to_lowercase()`, which
771    // silently mis-mapped TimedOut → "timedout" (no underscore). The
772    // implementations must now use `AgentStatus::as_str()` and produce
773    // snake_case strings that match the canonical on-disk mapping.
774    // ----------------------------------------------------------------------
775
776    fn read_checkpoint_status_for(run_dir: &std::path::Path, agent_id: AgentId) -> Option<String> {
777        let cp_path = run_dir.join("checkpoint.json");
778        let content = std::fs::read_to_string(&cp_path).ok()?;
779        let raw: serde_json::Value = serde_json::from_str(&content).ok()?;
780        let ar = raw.get("agent_results")?.as_object()?;
781        for (_k, v) in ar {
782            if v.get("agent_id").and_then(|id| id.as_str()) == Some(&agent_id.to_string()) {
783                return v.get("status").and_then(|s| s.as_str()).map(String::from);
784            }
785        }
786        None
787    }
788
789    fn sample_token_usage(input: u64, output: u64) -> TokenUsage {
790        TokenUsage {
791            input,
792            output,
793            cache_read: 0,
794            cache_write: 0,
795        }
796    }
797
798    #[test]
799    fn cache_agent_persists_snake_case_status_for_each_variant() {
800        // F5 KEY test for JournalStore::cache_agent: the persisted status
801        // MUST equal AgentStatus::as_str() (snake_case), not Debug lowercased.
802        // Particularly important for TimedOut which would otherwise round-trip
803        // as "timedout" (no underscore) and break cross-process resume.
804        let dir = tempdir().unwrap();
805        let run_id = uuid::Uuid::now_v7();
806        let journal = JournalStore::new(dir.path()).unwrap();
807        journal.init_run(run_id, "cache_agent F5").unwrap();
808
809        let cases: Vec<(AgentStatus, &str)> = vec![
810            (AgentStatus::Ok, "ok"),
811            (AgentStatus::Error, "error"),
812            (AgentStatus::Cancelled, "cancelled"),
813            (AgentStatus::TimedOut, "timed_out"),
814        ];
815        for (status, expected) in &cases {
816            let agent_id = uuid::Uuid::now_v7();
817            let key = AgentCacheKey::new("prompt", Some("gpt-4"), 1);
818            journal
819                .cache_agent(
820                    &key,
821                    agent_id,
822                    1,
823                    status.clone(),
824                    serde_json::json!({"v": 1}),
825                    vec![],
826                    sample_token_usage(10, 5),
827                )
828                .unwrap();
829
830            let persisted = read_checkpoint_status_for(dir.path(), agent_id)
831                .unwrap_or_else(|| panic!("status missing on disk for {status:?}"));
832            assert_eq!(
833                persisted, *expected,
834                "cache_agent({status:?}) must persist status={expected:?} (snake_case); \
835                 got {persisted:?}. Reverting to Debug formatting would yield \"timedout\" \
836                 for TimedOut and break the on-disk contract."
837            );
838        }
839    }
840
841    #[test]
842    fn cache_agent_timed_out_persists_with_underscore_not_collapsed() {
843        // Strongest F5 regression guard for cache_agent: TimedOut MUST persist
844        // as "timed_out" with an underscore. The buggy Debug-lowercased path
845        // would produce "timedout" and silently corrupt the journal.
846        let dir = tempdir().unwrap();
847        let run_id = uuid::Uuid::now_v7();
848        let journal = JournalStore::new(dir.path()).unwrap();
849        journal.init_run(run_id, "timed-out guard").unwrap();
850
851        let agent_id = uuid::Uuid::now_v7();
852        let key = AgentCacheKey::new("p", None, 0);
853        journal
854            .cache_agent(
855                &key,
856                agent_id,
857                0,
858                AgentStatus::TimedOut,
859                serde_json::json!(null),
860                vec![],
861                sample_token_usage(1, 2),
862            )
863            .unwrap();
864
865        let persisted = read_checkpoint_status_for(dir.path(), agent_id).expect("status on disk");
866        assert_eq!(
867            persisted, "timed_out",
868            "cache_agent(TimedOut) must persist \"timed_out\"; got {persisted:?}"
869        );
870        assert_ne!(
871            persisted, "timedout",
872            "cache_agent(TimedOut) must NOT collapse to Debug-lowercased \"timedout\""
873        );
874    }
875
876    #[test]
877    fn record_result_persists_snake_case_status_for_each_variant() {
878        // F5 test for JournalStore::record_result: same snake_case contract
879        // applies to the non-event-emitting path used by Lua SDK callbacks.
880        let dir = tempdir().unwrap();
881        let run_id = uuid::Uuid::now_v7();
882        let journal = JournalStore::new(dir.path()).unwrap();
883        journal.init_run(run_id, "record_result F5").unwrap();
884
885        let cases: Vec<(AgentStatus, &str)> = vec![
886            (AgentStatus::Ok, "ok"),
887            (AgentStatus::Error, "error"),
888            (AgentStatus::Cancelled, "cancelled"),
889            (AgentStatus::TimedOut, "timed_out"),
890        ];
891        for (status, expected) in &cases {
892            let agent_id = uuid::Uuid::now_v7();
893            let key = AgentCacheKey::new("p", None, 1);
894            journal.record_result(
895                &key,
896                agent_id,
897                1,
898                status.clone(),
899                serde_json::json!({"r": 1}),
900                vec![],
901                sample_token_usage(2, 3),
902            );
903
904            let persisted = read_checkpoint_status_for(dir.path(), agent_id)
905                .unwrap_or_else(|| panic!("status missing on disk for {status:?}"));
906            assert_eq!(
907                persisted, *expected,
908                "record_result({status:?}) must persist status={expected:?}; got {persisted:?}"
909            );
910        }
911    }
912
913    #[test]
914    fn record_result_timed_out_persists_with_underscore() {
915        // Same regression guard for record_result.
916        let dir = tempdir().unwrap();
917        let run_id = uuid::Uuid::now_v7();
918        let journal = JournalStore::new(dir.path()).unwrap();
919        journal.init_run(run_id, "record_result timed-out").unwrap();
920
921        let agent_id = uuid::Uuid::now_v7();
922        let key = AgentCacheKey::new("p", None, 0);
923        journal.record_result(
924            &key,
925            agent_id,
926            0,
927            AgentStatus::TimedOut,
928            serde_json::json!(null),
929            vec![],
930            sample_token_usage(0, 0),
931        );
932
933        let persisted = read_checkpoint_status_for(dir.path(), agent_id).expect("status on disk");
934        assert_eq!(persisted, "timed_out");
935        assert_ne!(persisted, "timedout");
936    }
937
938    #[tokio::test]
939    async fn journal_callback_on_agent_done_persists_snake_case_status() {
940        // F5 test for the JournalCallback impl on JournalStore. The scheduler
941        // calls `on_agent_done` when an agent finishes; the persisted
942        // AgentResultCache.status MUST match AgentStatus::as_str() exactly.
943        let dir = tempdir().unwrap();
944        let run_id = uuid::Uuid::now_v7();
945        let journal = std::sync::Arc::new(JournalStore::new(dir.path()).unwrap());
946        journal.init_run(run_id, "callback F5").unwrap();
947
948        let cases: Vec<(AgentStatus, &str)> = vec![
949            (AgentStatus::Ok, "ok"),
950            (AgentStatus::Error, "error"),
951            (AgentStatus::Cancelled, "cancelled"),
952            (AgentStatus::TimedOut, "timed_out"),
953        ];
954        for (status, expected) in &cases {
955            let agent_id = uuid::Uuid::now_v7();
956            use crate::scheduler::JournalCallback;
957            journal
958                .on_agent_done(
959                    agent_id,
960                    1,
961                    status.clone(),
962                    serde_json::json!({}),
963                    sample_token_usage(4, 6),
964                )
965                .await;
966
967            let persisted = read_checkpoint_status_for(dir.path(), agent_id)
968                .unwrap_or_else(|| panic!("status missing on disk for {status:?}"));
969            assert_eq!(
970                persisted, *expected,
971                "JournalCallback::on_agent_done({status:?}) must persist status={expected:?}; \
972                 got {persisted:?}"
973            );
974        }
975    }
976
977    #[test]
978    fn record_result_then_reopen_uses_snake_case_status() {
979        // Snake_case persistence must survive a close+reopen cycle so a
980        // resumed process sees the canonical strings (not Debug leftovers).
981        let dir = tempdir().unwrap();
982        let run_id = uuid::Uuid::now_v7();
983        let journal = JournalStore::new(dir.path()).unwrap();
984        journal.init_run(run_id, "reopen F5").unwrap();
985
986        let agent_id = uuid::Uuid::now_v7();
987        let key = AgentCacheKey::new("reopen prompt", Some("gpt-4"), 1);
988        journal.record_result(
989            &key,
990            agent_id,
991            1,
992            AgentStatus::Cancelled,
993            serde_json::json!({"result": "ok"}),
994            vec![],
995            sample_token_usage(7, 11),
996        );
997        drop(journal);
998
999        let j2 = JournalStore::new(dir.path()).unwrap();
1000        let cp = j2.open(run_id).expect("open after drop");
1001        let cached = cp
1002            .agent_results
1003            .get(&agent_id)
1004            .expect("entry survives reopen");
1005        assert_eq!(
1006            cached.status, "cancelled",
1007            "snake_case status must round-trip through close+reopen"
1008        );
1009        assert_eq!(cached.tokens, 18);
1010    }
1011
1012    #[test]
1013    fn cache_agent_persists_snake_case_status_to_event_log() {
1014        // The AgentDone event itself also travels through the same snake_case
1015        // contract (via update_from_event → as_str()). Read events.jsonl back
1016        // and confirm the event log carries the canonical status.
1017        let dir = tempdir().unwrap();
1018        let run_id = uuid::Uuid::now_v7();
1019        let journal = JournalStore::new(dir.path()).unwrap();
1020        journal.init_run(run_id, "event log F5").unwrap();
1021
1022        let agent_id = uuid::Uuid::now_v7();
1023        let key = AgentCacheKey::new("p", None, 1);
1024        journal
1025            .cache_agent(
1026                &key,
1027                agent_id,
1028                1,
1029                AgentStatus::TimedOut,
1030                serde_json::json!(null),
1031                vec![],
1032                sample_token_usage(1, 1),
1033            )
1034            .unwrap();
1035
1036        // The persisted AgentResultCache.status must already be verified by
1037        // the test above; this test only confirms the event log still parses
1038        // and carries the AgentDone event with the right status enum.
1039        let log = journal.store().get_event_log().expect("read events.jsonl");
1040        let agent_done = log
1041            .iter()
1042            .find_map(|e| match e {
1043                AgentEvent::AgentDone {
1044                    agent_id: id,
1045                    status,
1046                    ..
1047                } if id == &agent_id => Some(status.clone()),
1048                _ => None,
1049            })
1050            .expect("AgentDone event in log");
1051        // Status enum round-trip is enforced by serde, but the persisted
1052        // cache status string (verified above) is the part that the on-disk
1053        // contract depends on.
1054        assert!(matches!(agent_done, AgentStatus::TimedOut));
1055    }
1056}