Skip to main content

ri_agent_graph/
checkpoint_store.rs

1//! Granular checkpoint store for recording node execution attempts.
2//!
3//! [`CheckpointStore`] provides per-attempt recording with input/output/status,
4//! complementing the legacy [`CheckpointSaver`](crate::checkpointer::CheckpointSaver)
5//! which operates at the superstep level.
6
7use crate::outcome::Interrupt;
8use crate::Result;
9use serde::{Deserialize, Serialize};
10use serde_json::Value;
11use std::collections::HashMap;
12use std::future::Future;
13use std::pin::Pin;
14use std::sync::Arc;
15use tokio::sync::RwLock;
16
17/// Unique identifier for a graph run.
18pub type RunId = String;
19/// Unique identifier for a checkpoint-level node execution attempt.
20///
21/// This is an opaque checkpoint-level ID, distinct from `stack_ids::AttemptId`
22/// which represents a retry-lineage primitive. The checkpoint store generates
23/// these IDs internally for tracking per-node execution records.
24pub type CheckpointAttemptId = String;
25
26/// Status of a node execution attempt.
27#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
28pub enum AttemptStatus {
29    Running,
30    Completed,
31    Failed,
32    Interrupted,
33    Cancelled,
34}
35
36/// Record of a single node execution attempt.
37#[derive(Debug, Clone, Serialize, Deserialize)]
38pub struct AttemptRecord {
39    pub attempt_id: CheckpointAttemptId,
40    pub run_id: RunId,
41    pub node_id: String,
42    pub attempt: u32,
43    pub input: Value,
44    pub output: Option<Value>,
45    pub status: AttemptStatus,
46    pub error: Option<String>,
47    pub meta: HashMap<String, Value>,
48    /// Canonical trace context for this attempt.
49    #[serde(skip_serializing_if = "Option::is_none", default)]
50    pub trace_ctx: Option<stack_ids::TraceCtx>,
51    pub started_at: chrono::DateTime<chrono::Utc>,
52    pub finished_at: Option<chrono::DateTime<chrono::Utc>>,
53}
54
55/// Persisted state of a run, sufficient to resume execution.
56#[derive(Debug, Clone, Serialize, Deserialize)]
57pub struct RunState {
58    pub run_id: RunId,
59    pub graph_name: String,
60    pub status: RunStatus,
61    pub attempts: Vec<AttemptRecord>,
62    pub state_snapshot: HashMap<String, Value>,
63    pub interrupted: Option<Interrupt>,
64    pub created_at: chrono::DateTime<chrono::Utc>,
65    pub updated_at: chrono::DateTime<chrono::Utc>,
66}
67
68/// Overall status of a run.
69#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
70pub enum RunStatus {
71    Running,
72    Completed,
73    Failed,
74    Interrupted,
75    Cancelled,
76}
77
78/// Granular checkpoint store for per-attempt recording.
79///
80/// This trait uses boxed futures instead of async-trait for forward compat.
81pub trait CheckpointStore: Send + Sync {
82    /// Create a new run and return its ID.
83    fn create_run(
84        &self,
85        graph_name: &str,
86    ) -> Pin<Box<dyn Future<Output = Result<RunId>> + Send + '_>>;
87
88    /// Record a new node attempt (status: Running).
89    fn record_attempt(
90        &self,
91        run_id: &str,
92        node_id: &str,
93        attempt: u32,
94        input: &Value,
95    ) -> Pin<Box<dyn Future<Output = Result<CheckpointAttemptId>> + Send + '_>>;
96
97    /// Mark an attempt as completed with output.
98    fn complete_attempt(
99        &self,
100        attempt_id: &str,
101        output: &Value,
102        meta: &HashMap<String, Value>,
103    ) -> Pin<Box<dyn Future<Output = Result<()>> + Send + '_>>;
104
105    /// Mark an attempt as failed.
106    fn fail_attempt(
107        &self,
108        attempt_id: &str,
109        error: &str,
110    ) -> Pin<Box<dyn Future<Output = Result<()>> + Send + '_>>;
111
112    /// Record an interrupt on an attempt.
113    fn record_interrupt(
114        &self,
115        attempt_id: &str,
116        interrupt: &Interrupt,
117    ) -> Pin<Box<dyn Future<Output = Result<()>> + Send + '_>>;
118
119    /// Save the current state snapshot for a run.
120    fn save_state_snapshot(
121        &self,
122        run_id: &str,
123        state: &HashMap<String, Value>,
124    ) -> Pin<Box<dyn Future<Output = Result<()>> + Send + '_>>;
125
126    /// Load the full run state (for resume).
127    fn load_run(
128        &self,
129        run_id: &str,
130    ) -> Pin<Box<dyn Future<Output = Result<Option<RunState>>> + Send + '_>>;
131
132    /// Mark a run as completed.
133    fn complete_run(&self, run_id: &str) -> Pin<Box<dyn Future<Output = Result<()>> + Send + '_>>;
134
135    /// Mark a run as failed.
136    fn fail_run(
137        &self,
138        run_id: &str,
139        error: &str,
140    ) -> Pin<Box<dyn Future<Output = Result<()>> + Send + '_>>;
141}
142
143/// Metadata attached to a checkpoint for validation and auditing.
144#[derive(Debug, Clone, Serialize, Deserialize)]
145pub struct CheckpointMetadata {
146    /// Hash of the graph definition at checkpoint time.
147    /// Used to detect graph-definition drift on resume.
148    pub graph_hash: String,
149    /// The run this checkpoint belongs to.
150    pub run_id: String,
151    /// Node that was active when the checkpoint was taken.
152    pub node_id: String,
153    /// Superstep number.
154    pub step: usize,
155    /// When the checkpoint was created.
156    pub created_at: chrono::DateTime<chrono::Utc>,
157}
158
159/// Summary of a completed (or failed) graph run.
160#[derive(Debug, Clone, Serialize, Deserialize)]
161pub struct RunSummary {
162    pub run_id: String,
163    pub graph_name: String,
164    pub status: RunStatus,
165    pub total_nodes_executed: usize,
166    pub total_attempts: usize,
167    pub failed_attempts: usize,
168    /// Phase status: compatibility / migration-only
169    pub trace_id: Option<String>,
170    /// Canonical trace context for this run.
171    #[serde(skip_serializing_if = "Option::is_none", default)]
172    pub trace_ctx: Option<stack_ids::TraceCtx>,
173    pub started_at: chrono::DateTime<chrono::Utc>,
174    pub finished_at: Option<chrono::DateTime<chrono::Utc>>,
175}
176
177impl InMemoryCheckpointStore {
178    /// Build a [`RunSummary`] for the given run.
179    pub async fn summarize_run(&self, run_id: &str) -> Option<RunSummary> {
180        let runs = self.runs.read().await;
181        let run = runs.get(run_id)?;
182        let total_attempts = run.attempts.len();
183        let failed_attempts = run
184            .attempts
185            .iter()
186            .filter(|a| a.status == AttemptStatus::Failed)
187            .count();
188        let trace_id = run.attempts.iter().find_map(|attempt| {
189            attempt
190                .meta
191                .get("trace_id")
192                .and_then(|value| value.as_str())
193                .map(str::to_owned)
194        });
195        let trace_ctx = run
196            .attempts
197            .iter()
198            .find_map(|attempt| attempt.trace_ctx.clone());
199        let unique_nodes: std::collections::HashSet<&str> =
200            run.attempts.iter().map(|a| a.node_id.as_str()).collect();
201        Some(RunSummary {
202            run_id: run.run_id.clone(),
203            graph_name: run.graph_name.clone(),
204            status: run.status.clone(),
205            total_nodes_executed: unique_nodes.len(),
206            total_attempts,
207            failed_attempts,
208            trace_id,
209            trace_ctx,
210            started_at: run.created_at,
211            finished_at: if run.status == RunStatus::Running {
212                None
213            } else {
214                Some(run.updated_at)
215            },
216        })
217    }
218}
219
220/// In-memory checkpoint store for testing and lightweight use.
221pub struct InMemoryCheckpointStore {
222    runs: Arc<RwLock<HashMap<RunId, RunState>>>,
223    attempts: Arc<RwLock<HashMap<CheckpointAttemptId, AttemptRecord>>>,
224}
225
226impl InMemoryCheckpointStore {
227    pub fn new() -> Self {
228        Self {
229            runs: Arc::new(RwLock::new(HashMap::new())),
230            attempts: Arc::new(RwLock::new(HashMap::new())),
231        }
232    }
233
234    /// List all runs. Useful for testing and inspection.
235    pub async fn list_runs(&self) -> Vec<RunState> {
236        self.runs.read().await.values().cloned().collect()
237    }
238}
239
240impl Default for InMemoryCheckpointStore {
241    fn default() -> Self {
242        Self::new()
243    }
244}
245
246impl CheckpointStore for InMemoryCheckpointStore {
247    fn create_run(
248        &self,
249        graph_name: &str,
250    ) -> Pin<Box<dyn Future<Output = Result<RunId>> + Send + '_>> {
251        let graph_name = graph_name.to_string();
252        Box::pin(async move {
253            let run_id = stack_ids::GraphRunId::random("agent-graph").to_string();
254            let now = chrono::Utc::now();
255            let run = RunState {
256                run_id: run_id.clone(),
257                graph_name,
258                status: RunStatus::Running,
259                attempts: Vec::new(),
260                state_snapshot: HashMap::new(),
261                interrupted: None,
262                created_at: now,
263                updated_at: now,
264            };
265            self.runs.write().await.insert(run_id.clone(), run);
266            Ok(run_id)
267        })
268    }
269
270    fn record_attempt(
271        &self,
272        run_id: &str,
273        node_id: &str,
274        attempt: u32,
275        input: &Value,
276    ) -> Pin<Box<dyn Future<Output = Result<CheckpointAttemptId>> + Send + '_>> {
277        let run_id = run_id.to_string();
278        let node_id = node_id.to_string();
279        let input = input.clone();
280        Box::pin(async move {
281            let attempt_id =
282                stack_ids::GraphCheckpointAttemptId::random("agent-graph-checkpoint").to_string();
283            let now = chrono::Utc::now();
284            let record = AttemptRecord {
285                attempt_id: attempt_id.clone(),
286                run_id: run_id.clone(),
287                node_id: node_id.clone(),
288                attempt,
289                input,
290                output: None,
291                status: AttemptStatus::Running,
292                error: None,
293                meta: HashMap::new(),
294                trace_ctx: None,
295                started_at: now,
296                finished_at: None,
297            };
298            self.attempts
299                .write()
300                .await
301                .insert(attempt_id.clone(), record.clone());
302            if let Some(run) = self.runs.write().await.get_mut(&run_id) {
303                run.attempts.push(record);
304                run.updated_at = now;
305            }
306            Ok(attempt_id)
307        })
308    }
309
310    fn complete_attempt(
311        &self,
312        attempt_id: &str,
313        output: &Value,
314        meta: &HashMap<String, Value>,
315    ) -> Pin<Box<dyn Future<Output = Result<()>> + Send + '_>> {
316        let attempt_id = attempt_id.to_string();
317        let output = output.clone();
318        let meta = meta.clone();
319        Box::pin(async move {
320            let now = chrono::Utc::now();
321            let mut attempts = self.attempts.write().await;
322            if let Some(record) = attempts.get_mut(&attempt_id) {
323                record.status = AttemptStatus::Completed;
324                record.output = Some(output.clone());
325                record.meta = meta.clone();
326                record.finished_at = Some(now);
327                // Also update in run
328                let run_id = record.run_id.clone();
329                drop(attempts);
330                if let Some(run) = self.runs.write().await.get_mut(&run_id) {
331                    if let Some(a) = run.attempts.iter_mut().find(|a| a.attempt_id == attempt_id) {
332                        a.status = AttemptStatus::Completed;
333                        a.output = Some(output);
334                        a.meta = meta;
335                        a.finished_at = Some(now);
336                    }
337                    run.updated_at = now;
338                }
339            }
340            Ok(())
341        })
342    }
343
344    fn fail_attempt(
345        &self,
346        attempt_id: &str,
347        error: &str,
348    ) -> Pin<Box<dyn Future<Output = Result<()>> + Send + '_>> {
349        let attempt_id = attempt_id.to_string();
350        let error = error.to_string();
351        Box::pin(async move {
352            let now = chrono::Utc::now();
353            let mut attempts = self.attempts.write().await;
354            if let Some(record) = attempts.get_mut(&attempt_id) {
355                record.status = AttemptStatus::Failed;
356                record.error = Some(error.clone());
357                record.finished_at = Some(now);
358                let run_id = record.run_id.clone();
359                drop(attempts);
360                if let Some(run) = self.runs.write().await.get_mut(&run_id) {
361                    if let Some(a) = run.attempts.iter_mut().find(|a| a.attempt_id == attempt_id) {
362                        a.status = AttemptStatus::Failed;
363                        a.error = Some(error);
364                        a.finished_at = Some(now);
365                    }
366                    run.updated_at = now;
367                }
368            }
369            Ok(())
370        })
371    }
372
373    fn record_interrupt(
374        &self,
375        attempt_id: &str,
376        interrupt: &Interrupt,
377    ) -> Pin<Box<dyn Future<Output = Result<()>> + Send + '_>> {
378        let attempt_id = attempt_id.to_string();
379        let interrupt = interrupt.clone();
380        Box::pin(async move {
381            let now = chrono::Utc::now();
382            let mut attempts = self.attempts.write().await;
383            if let Some(record) = attempts.get_mut(&attempt_id) {
384                record.status = AttemptStatus::Interrupted;
385                record.finished_at = Some(now);
386                let run_id = record.run_id.clone();
387                drop(attempts);
388                if let Some(run) = self.runs.write().await.get_mut(&run_id) {
389                    run.interrupted = Some(interrupt);
390                    run.status = RunStatus::Interrupted;
391                    if let Some(a) = run.attempts.iter_mut().find(|a| a.attempt_id == attempt_id) {
392                        a.status = AttemptStatus::Interrupted;
393                        a.finished_at = Some(now);
394                    }
395                    run.updated_at = now;
396                }
397            }
398            Ok(())
399        })
400    }
401
402    fn save_state_snapshot(
403        &self,
404        run_id: &str,
405        state: &HashMap<String, Value>,
406    ) -> Pin<Box<dyn Future<Output = Result<()>> + Send + '_>> {
407        let run_id = run_id.to_string();
408        let state = state.clone();
409        Box::pin(async move {
410            if let Some(run) = self.runs.write().await.get_mut(&run_id) {
411                run.state_snapshot = state;
412                run.updated_at = chrono::Utc::now();
413            }
414            Ok(())
415        })
416    }
417
418    fn load_run(
419        &self,
420        run_id: &str,
421    ) -> Pin<Box<dyn Future<Output = Result<Option<RunState>>> + Send + '_>> {
422        let run_id = run_id.to_string();
423        Box::pin(async move { Ok(self.runs.read().await.get(&run_id).cloned()) })
424    }
425
426    fn complete_run(&self, run_id: &str) -> Pin<Box<dyn Future<Output = Result<()>> + Send + '_>> {
427        let run_id = run_id.to_string();
428        Box::pin(async move {
429            let mut runs = self.runs.write().await;
430            let run = runs
431                .get_mut(&run_id)
432                .ok_or_else(|| crate::AgentGraphError::RunNotFound(run_id.clone()))?;
433            if run.status != RunStatus::Running {
434                return Err(crate::AgentGraphError::TerminalStateConflict(run_id));
435            }
436            run.status = RunStatus::Completed;
437            run.updated_at = chrono::Utc::now();
438            Ok(())
439        })
440    }
441
442    fn fail_run(
443        &self,
444        run_id: &str,
445        _error: &str,
446    ) -> Pin<Box<dyn Future<Output = Result<()>> + Send + '_>> {
447        let run_id = run_id.to_string();
448        Box::pin(async move {
449            let mut runs = self.runs.write().await;
450            let run = runs
451                .get_mut(&run_id)
452                .ok_or_else(|| crate::AgentGraphError::RunNotFound(run_id.clone()))?;
453            if run.status != RunStatus::Running {
454                return Err(crate::AgentGraphError::TerminalStateConflict(run_id));
455            }
456            run.status = RunStatus::Failed;
457            run.updated_at = chrono::Utc::now();
458            Ok(())
459        })
460    }
461}