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};
20
21use crate::contract::finding::Finding;
22use crate::contract::ids::{AgentId, PhaseId, RunId, TokenUsage};
23use crate::scheduler::{BackendRegistry, SchedulerConfig};
24use crate::session::{resolve_session, restore_session};
25use crate::state::{AgentResultCache, AgentSessionCheckpoint, CheckpointBackend, RunCheckpoint};
26use blake3::Hasher;
27use chrono::Utc;
28use serde::{Deserialize, Serialize};
29use std::collections::HashMap;
30use std::path::Path;
31use std::sync::{Arc, RwLock};
32use std::time::{Duration, SystemTime, UNIX_EPOCH};
33use thiserror::Error;
34
35// ============================================================================
36// Error Types
37// ============================================================================
38
39#[derive(Error, Debug)]
40pub enum JournalError {
41    #[error("run not found: {0}")]
42    RunNotFound(RunId),
43    #[error("run is not resumable (status: {status:?})")]
44    NotResumable { status: String },
45    #[error("I/O error: {0}")]
46    Io(#[from] std::io::Error),
47    #[error("serialization error: {0}")]
48    Serde(#[from] serde_json::Error),
49    #[error("journal corrupted: {0}")]
50    Corrupted(String),
51    #[error("backend error: {0}")]
52    Backend(String),
53}
54
55fn map_anyhow(e: anyhow::Error) -> JournalError {
56    JournalError::Backend(e.to_string())
57}
58
59// ============================================================================
60// Agent Cache Key
61// ============================================================================
62
63/// Deterministic cache key for an agent invocation.
64/// Normalizes whitespace/unicode to ensure cache hits across formatting differences.
65#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
66pub struct AgentCacheKey {
67    pub hash: String,
68    /// Human-readable for debugging
69    pub prompt_preview: String,
70    pub phase_id: PhaseId,
71}
72
73impl AgentCacheKey {
74    /// Generate a cache key from agent parameters.
75    /// Uses blake3 with null separators to prevent field-concatenation collisions.
76    pub fn new(prompt: &str, phase_id: PhaseId) -> Self {
77        let normalized = normalize_prompt(prompt);
78        let preview = if normalized.chars().count() > 80 {
79            format!("{}...", normalized.chars().take(80).collect::<String>())
80        } else {
81            normalized.clone()
82        };
83
84        let mut h = Hasher::new();
85        h.update(normalized.as_bytes());
86        h.update(b"\0");
87        h.update(&phase_id.to_le_bytes());
88
89        Self {
90            hash: h.finalize().to_hex().to_string(),
91            prompt_preview: preview,
92            phase_id,
93        }
94    }
95}
96
97fn normalize_prompt(prompt: &str) -> String {
98    prompt
99        .replace("\r\n", "\n")
100        .replace('\r', "\n")
101        .split_whitespace()
102        .collect::<Vec<_>>()
103        .join(" ")
104}
105
106// ============================================================================
107// JournalStore — the journal abstraction over RunStore
108// ============================================================================
109
110/// JournalStore wraps RunStore with replay semantics.
111///
112/// Thread safety: All public methods take `&self` (interior mutability via RwLock).
113/// The underlying checkpoint data is protected by a single writer lock.
114///
115/// Usage lifecycle:
116///   new() → init_run() → cache_agent()* → flush()
117///   或:
118///   open() → has_completed()/get_cached() → workflow resume logic
119pub struct JournalStore {
120    /// Underlying persistence engine (SQLite-backed CheckpointBackend).
121    inner: Arc<dyn CheckpointBackend>,
122    /// In-memory index: AgentCacheKey hash → AgentResultCache.
123    /// Populated at open() time from the checkpoint's agent_results map.
124    cache_index: RwLock<HashMap<String, AgentResultCache>>,
125    /// Event sender for broadcasting journal updates.
126    event_tx: Option<EventSender>,
127}
128
129impl std::fmt::Debug for JournalStore {
130    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
131        f.debug_struct("JournalStore")
132            .field("inner", &self.inner)
133            .field("cache_index_size", &self.cache_index.read().unwrap().len())
134            .field("has_event_tx", &self.event_tx.is_some())
135            .finish()
136    }
137}
138
139impl JournalStore {
140    /// Create a new journal store backed by the given `CheckpointBackend`.
141    pub fn with_backend(backend: Arc<dyn CheckpointBackend>) -> Self {
142        tracing::debug!(backend = ?backend, "creating journal store with backend");
143        Self {
144            inner: backend,
145            cache_index: RwLock::new(HashMap::new()),
146            event_tx: None,
147        }
148    }
149
150    /// Create a new journal store at the given directory.
151    /// Convenience constructor — requires the caller to provide a backend factory.
152    /// Deprecated: prefer `with_backend`.
153    #[deprecated(note = "use JournalStore::with_backend instead")]
154    pub fn new(_run_dir: &Path) -> Result<Self, JournalError> {
155        Err(JournalError::Corrupted(
156            "JournalStore::new(path) is no longer supported. Use JournalStore::with_backend(backend).".into()
157        ))
158    }
159
160    /// Initialize a new run in the journal.
161    pub fn init_run(&self, run_id: RunId, task: &str, run_dir: &str) -> Result<(), JournalError> {
162        tracing::info!(%run_id, %task, "initializing run in journal");
163        self.inner.init_run(run_id, task, run_dir).map_err(map_anyhow)?;
164        Ok(())
165    }
166
167    /// Initialize a new run with declarative workflow metadata.
168    pub fn init_run_with_meta(
169        &self,
170        run_id: RunId,
171        task: &str,
172        run_dir: &str,
173        workflow_meta: serde_json::Value,
174    ) -> Result<(), JournalError> {
175        tracing::info!(
176            %run_id, %task,
177            "initializing run in journal with meta"
178        );
179        self.inner.init_run_with_meta(run_id, task, run_dir, workflow_meta).map_err(map_anyhow)?;
180        Ok(())
181    }
182
183    /// Open an existing run and rebuild the cache index from persisted data.
184    ///
185    /// This is the entry point for `--resume`. It:
186    /// 1. Loads the checkpoint from disk
187    /// 2. Rebuilds the in-memory cache_index from agent_results
188    /// 3. Returns the checkpoint for the caller to inspect
189    pub fn open(&self, run_id: RunId) -> Result<RunCheckpoint, JournalError> {
190        tracing::info!(%run_id, "opening journal for resume");
191        let checkpoint = self
192            .inner
193            .open_run(run_id).map_err(map_anyhow)?
194            .ok_or(JournalError::RunNotFound(run_id))?;
195
196        if matches!(
197            checkpoint.status,
198            crate::state::CheckpointStatus::Completed
199        ) {
200            return Err(JournalError::NotResumable {
201                status: format!("{:?}", checkpoint.status),
202            });
203        }
204
205        // Rebuild cache index — index by both agent_id and cache_key_hash
206        // so that the Lua SDK's has_completed(key) works after resume.
207        let mut index = HashMap::new();
208        for (agent_id, cache) in &checkpoint.agent_results {
209            index.insert(agent_id.to_string(), cache.clone());
210            if let Some(ref hash) = cache.cache_key_hash {
211                index.insert(hash.clone(), cache.clone());
212            }
213        }
214        *self.cache_index.write().unwrap() = index;
215
216        Ok(checkpoint)
217    }
218
219    /// Cache an agent's result in the journal.
220    ///
221    /// Called by the scheduler after an agent completes successfully or fails
222    /// with a non-retryable error. The result is persisted to disk immediately
223    /// (via append_event → update_from_event → write_checkpoint_to_disk).
224    #[allow(clippy::too_many_arguments)]
225    pub fn cache_agent(
226        &self,
227        cache_key: &AgentCacheKey,
228        agent_id: AgentId,
229        phase_id: PhaseId,
230        status: AgentStatus,
231        output: serde_json::Value,
232        findings: Vec<Finding>,
233        tokens: TokenUsage,
234    ) -> Result<AgentCacheKey, JournalError> {
235        let ts = current_timestamp();
236        let cache = AgentResultCache {
237            agent_id,
238            phase_id,
239            status: status.as_str().to_string(),
240            output,
241            findings,
242            tokens: tokens.total(),
243            completed_at: ts,
244            cache_key_hash: Some(cache_key.hash.clone()),
245            description: None,
246            role: None,
247        };
248
249        // Update in-memory index (instant lookup)
250        {
251            let mut index = self.cache_index.write().unwrap();
252            index.insert(cache_key.hash.clone(), cache.clone());
253            // Also index by agent_id for open() compatibility
254            index.insert(agent_id.to_string(), cache.clone());
255        }
256
257        // Persist the full cache entry directly to checkpoint disk (preserves cache_key_hash)
258        if let Err(e) = self.inner.upsert_agent_result(&cache) {
259            tracing::warn!(%agent_id, error = %e, "failed to persist agent cache");
260        }
261
262        // Also append event to log (this triggers update_from_event which finds the existing hash)
263        let event = AgentEvent::AgentDone {
264            run_id: self
265                .inner
266                .get_checkpoint()
267                .map(|c| c.run_id)
268                .unwrap_or_else(uuid::Uuid::nil),
269            agent_id,
270            status,
271            tokens,
272            elapsed_ms: 0,
273            name: None,
274            agent_seq: 0,
275            output: serde_json::Value::Null,
276            findings: Vec::new(),
277            prompt: String::new(),
278            retry_count: 0,
279            ts: Utc::now(),
280        };
281        self.inner.append_event(&event).map_err(map_anyhow)?;
282
283        // Broadcast via event bus (non-blocking — uses broadcast channel)
284        if let Some(ref tx) = self.event_tx {
285            let _ = tx.send(event);
286        }
287
288        Ok(cache_key.clone())
289    }
290
291    /// Record an agent's output for resume replay, keyed by `cache_key`.
292    ///
293    /// Unlike [`cache_agent`], this does **not** append an `AgentDone` event,
294    /// so it never double-counts tokens against the event-driven checkpoint
295    /// totals. It only upserts the checkpoint entry (preserving `cache_key_hash`
296    /// and the structured output) and refreshes the in-memory cache index.
297    /// Called by the Lua SDK after an agent completes during a live run.
298    #[allow(clippy::too_many_arguments)]
299    pub fn record_result(
300        &self,
301        cache_key: &AgentCacheKey,
302        agent_id: AgentId,
303        phase_id: PhaseId,
304        status: AgentStatus,
305        output: serde_json::Value,
306        findings: Vec<Finding>,
307        tokens: TokenUsage,
308    ) {
309        let cache = AgentResultCache {
310            agent_id,
311            phase_id,
312            status: status.as_str().to_string(),
313            output,
314            findings,
315            tokens: tokens.total(),
316            completed_at: current_timestamp(),
317            cache_key_hash: Some(cache_key.hash.clone()),
318            description: None,
319            role: None,
320        };
321
322        {
323            let mut index = self.cache_index.write().unwrap();
324            index.insert(cache_key.hash.clone(), cache.clone());
325            index.insert(agent_id.to_string(), cache.clone());
326        }
327
328        if let Err(e) = self.inner.upsert_agent_result(&cache) {
329            tracing::warn!(%agent_id, error = %e, "failed to persist agent result");
330        }
331    }
332
333    /// Persist the session id returned by a backend for later diagnostics and
334    /// same-run resume. The id is opaque to the journal; backend-specific
335    /// conversation state is not serialized here.
336    pub fn record_session(
337        &self,
338        agent_id: AgentId,
339        session_id: String,
340        status: &str,
341        resumable: bool,
342    ) {
343        let backend_id = crate::contract::current_backend().map(|backend| backend.id);
344        let protocol_session_id = backend_id
345            .as_deref()
346            .and_then(|backend| resolve_session(&session_id, backend))
347            .map(|record| record.protocol_session_id)
348            .or_else(|| Some(session_id.clone()));
349        let session = AgentSessionCheckpoint {
350            agent_id,
351            backend_id,
352            protocol_session_id,
353            session_id,
354            status: status.to_string(),
355            updated_at: current_timestamp(),
356            resumable,
357        };
358        if let Err(e) = self.inner.upsert_agent_session(&session) {
359            tracing::warn!(%agent_id, error = %e, "failed to persist agent session");
360        }
361    }
362
363    /// Return the persisted session metadata for an agent, if any.
364    pub fn get_session(&self, agent_id: AgentId) -> Option<AgentSessionCheckpoint> {
365        let session = self
366            .inner
367            .get_checkpoint()
368            .and_then(|checkpoint| checkpoint.agent_sessions.get(&agent_id).cloned());
369        if let Some(ref session) = session {
370            if let (Some(backend_id), Some(protocol_id)) =
371                (session.backend_id.as_deref(), session.protocol_session_id.as_deref())
372            {
373                restore_session(&session.session_id, backend_id, protocol_id);
374            }
375        }
376        session
377    }
378
379    /// Access the underlying run store (shared persistence engine).
380    /// Allows the CLI to route the scheduler event stream through the same
381    /// `RunStore` instance the journal uses, avoiding split-brain checkpoints.
382    pub fn store(&self) -> Arc<dyn CheckpointBackend> {
383        self.inner.clone()
384    }
385
386    /// Append an event to the underlying run store (event log + checkpoint).
387    pub fn append_event(&self, event: &AgentEvent) -> Result<(), JournalError> {
388        self.inner.append_event(event).map_err(map_anyhow)?;
389        Ok(())
390    }
391
392    /// Check if an agent with the given cache key has already completed.
393    /// Used by the Lua SDK's agent() function before submitting to the scheduler.
394    pub fn has_completed(&self, cache_key: &AgentCacheKey) -> bool {
395        let index = self.cache_index.read().unwrap();
396        index.contains_key(&cache_key.hash)
397    }
398
399    /// Get cached result for an agent.
400    /// Returns None if the agent hasn't completed yet.
401    pub fn get_cached(&self, cache_key: &AgentCacheKey) -> Option<AgentResultCache> {
402        let index = self.cache_index.read().unwrap();
403        index.get(&cache_key.hash).cloned()
404    }
405
406    /// Get list of all completed agent cache keys.
407    /// Useful for debugging and progress reporting.
408    pub fn completed_keys(&self) -> Vec<AgentCacheKey> {
409        let index = self.cache_index.read().unwrap();
410        index
411            .keys()
412            .map(|k| AgentCacheKey {
413                hash: k.clone(),
414                prompt_preview: String::new(),
415                phase_id: 0,
416            })
417            .collect()
418    }
419
420    /// Get the underlying checkpoint (read-only snapshot).
421    pub fn get_checkpoint(&self) -> Option<RunCheckpoint> {
422        self.inner.get_checkpoint()
423    }
424
425    /// Flush all pending data to disk.
426    pub fn flush(&self) -> Result<(), JournalError> {
427        // RunStore auto-flushes on append_event; explicit flush for safety.
428        Ok(())
429    }
430
431    /// Mark the run as cancelled.
432    pub fn cancel(&self) -> Result<(), JournalError> {
433        self.inner.cancel().map_err(map_anyhow)?;
434        Ok(())
435    }
436
437    /// Reset checkpoint status to `Running`. Used when resuming a
438    /// failed/cancelled run.
439    pub fn reset_status_to_running(&self) -> Result<(), JournalError> {
440        self.inner.reset_status_to_running().map_err(map_anyhow)?;
441        Ok(())
442    }
443}
444
445// ============================================================================
446// Scheduler Integration — JournalCallback trait
447// ============================================================================
448
449/// Composite callback that chains multiple JournalCallback implementations.
450pub struct CompositeJournalCallback {
451    callbacks: Vec<Arc<dyn crate::scheduler::JournalCallback>>,
452}
453
454impl CompositeJournalCallback {
455    pub fn new(callbacks: Vec<Arc<dyn crate::scheduler::JournalCallback>>) -> Self {
456        Self { callbacks }
457    }
458}
459
460#[async_trait::async_trait]
461impl crate::scheduler::JournalCallback for CompositeJournalCallback {
462    async fn on_agent_done(
463        &self,
464        agent_id: AgentId,
465        phase_id: PhaseId,
466        status: AgentStatus,
467        output: serde_json::Value,
468        tokens: TokenUsage,
469    ) {
470        for cb in &self.callbacks {
471            cb.on_agent_done(agent_id, phase_id, status.clone(), output.clone(), tokens)
472                .await;
473        }
474    }
475}
476
477#[async_trait::async_trait]
478impl crate::scheduler::JournalCallback for JournalStore {
479    async fn on_agent_done(
480        &self,
481        agent_id: AgentId,
482        phase_id: PhaseId,
483        status: AgentStatus,
484        output: serde_json::Value,
485        tokens: TokenUsage,
486    ) {
487        let ts = current_timestamp();
488
489        // Preserve cache_key_hash and other enriched fields from a prior
490        // cache_agent() / record_result() call.  Without this, the scheduler
491        // callback would overwrite the hash with None, causing the agent to be
492        // re-executed on resume even though it already completed.
493        let existing = {
494            let index = self.cache_index.read().unwrap();
495            index.get(&agent_id.to_string()).cloned()
496        };
497
498        let cache = AgentResultCache {
499            agent_id,
500            phase_id: existing.as_ref().map(|c| c.phase_id).unwrap_or(phase_id),
501            status: status.as_str().to_string(),
502            output: existing
503                .as_ref()
504                .filter(|c| !c.output.is_null())
505                .map(|c| c.output.clone())
506                .unwrap_or(output),
507            findings: existing
508                .as_ref()
509                .filter(|c| !c.findings.is_empty())
510                .map(|c| c.findings.clone())
511                .unwrap_or_default(),
512            tokens: tokens.total(),
513            completed_at: ts,
514            cache_key_hash: existing.as_ref().and_then(|c| c.cache_key_hash.clone()),
515            description: existing.as_ref().and_then(|c| c.description.clone()),
516            role: existing.as_ref().and_then(|c| c.role.clone()),
517        };
518
519        // Update in-memory index so subsequent on_agent_done calls also see
520        // the preserved hash.
521        {
522            let mut index = self.cache_index.write().unwrap();
523            index.insert(agent_id.to_string(), cache.clone());
524            if let Some(ref hash) = cache.cache_key_hash {
525                index.insert(hash.clone(), cache.clone());
526            }
527        }
528
529        // Persist to checkpoint disk
530        if let Err(e) = self.inner.upsert_agent_result(&cache) {
531            tracing::warn!(%agent_id, error = %e, "failed to persist agent result from callback");
532        }
533    }
534}
535
536// ============================================================================
537// Resume Orchestration
538// ============================================================================
539
540/// Context for resuming a run.
541#[derive(Debug)]
542pub struct ResumeContext {
543    pub run_id: RunId,
544    pub checkpoint: RunCheckpoint,
545    pub journal: Arc<JournalStore>,
546    pub scheduler_config: SchedulerConfig,
547    pub backend_registry: BackendRegistry,
548}
549
550/// Options for creating a run (new or resume).
551#[derive(Debug, Clone)]
552pub enum RunCreationMode {
553    /// Start a fresh run.
554    New { task: String },
555    /// Resume from an existing checkpoint.
556    Resume { run_id: RunId, run_dir_name: String },
557    /// Auto-detect: resume if resumable run exists, else new.
558    Auto { task: String },
559}
560
561impl RunCreationMode {
562    /// Resolve the creation mode to concrete parameters.
563    /// `backend_factory` creates a `CheckpointBackend` for a given run directory.
564    pub fn resolve(
565        self,
566        journal_dir: &Path,
567        backend_factory: &dyn Fn(&Path) -> Arc<dyn CheckpointBackend>,
568    ) -> Result<(RunId, Option<RunCheckpoint>), JournalError> {
569        match self {
570            RunCreationMode::New { task: _ } => {
571                let run_id = uuid::Uuid::now_v7();
572                Ok((run_id, None))
573            }
574            RunCreationMode::Resume {
575                run_id,
576                run_dir_name,
577            } => {
578                let backend = backend_factory(&journal_dir.join(&run_dir_name));
579                let store = JournalStore::with_backend(backend);
580                let checkpoint = store.open(run_id)?;
581                Ok((run_id, Some(checkpoint)))
582            }
583            RunCreationMode::Auto { task: _ } => {
584                let run_dirs = crate::state::list_run_dirs(journal_dir).map_err(map_anyhow)?;
585                for dir_name in run_dirs.iter().rev() {
586                    let run_dir = journal_dir.join(dir_name);
587                    let backend = backend_factory(&run_dir);
588                    if let Ok(Some(checkpoint)) = backend.open_run(uuid::Uuid::nil()) {
589                        if matches!(checkpoint.status, crate::state::CheckpointStatus::Running)
590                            || matches!(checkpoint.status, crate::state::CheckpointStatus::Failed)
591                            || matches!(checkpoint.status, crate::state::CheckpointStatus::Cancelled)
592                        {
593                            let run_id = checkpoint.run_id;
594                            return Ok((run_id, Some(checkpoint)));
595                        }
596                    }
597                }
598                let run_id = uuid::Uuid::now_v7();
599                Ok((run_id, None))
600            }
601        }
602    }
603}
604
605// ============================================================================
606// GC (Garbage Collection)
607// ============================================================================
608
609/// Clean up old completed/cancelled runs.
610///
611/// Policy:
612/// - Completed/Cancelled runs older than `older_than` are eligible for deletion.
613/// - Running runs are never cleaned.
614///
615/// Returns the number of runs cleaned.
616pub fn gc_runs(journal_dir: &Path, older_than: Duration) -> Result<usize, JournalError> {
617    let run_dirs = crate::state::list_run_dirs(journal_dir).map_err(map_anyhow)?;
618    let cutoff = current_timestamp().saturating_sub(older_than.as_secs());
619
620    tracing::debug!("GC: scanning {} runs", run_dirs.len());
621    let mut cleaned = 0;
622    for dir_name in &run_dirs {
623        let run_dir = journal_dir.join(dir_name);
624        // Peek at checkpoint without full open
625        let checkpoint_path = run_dir.join("checkpoint.json");
626        if !checkpoint_path.exists() {
627            continue;
628        }
629
630        let content = std::fs::read_to_string(&checkpoint_path)?;
631        let checkpoint: RunCheckpoint = serde_json::from_str(&content)?;
632
633        let is_old = checkpoint.updated_at < cutoff;
634        let is_terminal = matches!(
635            checkpoint.status,
636            crate::state::CheckpointStatus::Completed
637                | crate::state::CheckpointStatus::Cancelled
638                | crate::state::CheckpointStatus::Failed
639        );
640
641        if is_old && is_terminal {
642            tracing::info!(dir = %dir_name, "GC: removing old terminal run");
643            std::fs::remove_dir_all(&run_dir)?;
644            cleaned += 1;
645        }
646    }
647
648    Ok(cleaned)
649}
650
651fn current_timestamp() -> u64 {
652    SystemTime::now()
653        .duration_since(UNIX_EPOCH)
654        .map(|d| d.as_secs())
655        .unwrap_or(0)
656}
657
658// ============================================================================
659// Tests — moved to luft-storage (SqliteCheckpointBackend integration tests)
660// ============================================================================
661
662#[cfg(test)]
663mod tests {
664    use super::*;
665
666    #[test]
667    fn test_cache_key_uniqueness() {
668        let k1 = AgentCacheKey::new("prompt A", 1);
669        let k2 = AgentCacheKey::new("prompt B", 1);
670        assert_ne!(k1.hash, k2.hash);
671
672        // Same prompt, different phase
673        let k4 = AgentCacheKey::new("prompt A", 2);
674        assert_ne!(k1.hash, k4.hash);
675
676        // Whitespace normalization
677        let k5 = AgentCacheKey::new("  prompt  \r\nA  ", 1);
678        assert_eq!(k1.hash, k5.hash);
679    }
680}