Skip to main content

lc_langgraph/
checkpointer.rs

1// crates/lc-langgraph/src/checkpointer.rs
2//! Checkpointing for state persistence
3
4use crate::errors::{GraphError, GraphResult};
5use crate::state::StateSchema;
6use async_trait::async_trait;
7use serde::{Deserialize, Serialize};
8use std::collections::HashMap;
9use std::sync::atomic::{AtomicU64, Ordering};
10use tokio::sync::Mutex;
11use uuid::Uuid;
12
13/// Checkpointer trait for state persistence
14#[async_trait]
15pub trait Checkpointer<S: StateSchema>: Send + Sync {
16    /// Insert a new checkpoint, recording how much of the recursion budget had
17    /// been consumed by the run at that point (M6).
18    async fn save(&self, state: &S, recursion_count: usize) -> GraphResult<String>;
19    /// Load the state saved under the given checkpoint id.
20    async fn load(&self, checkpoint_id: &str) -> GraphResult<S>;
21    /// List checkpoint ids, ordered from oldest to most recent (H5).
22    async fn list(&self) -> GraphResult<Vec<String>>;
23    /// Delete the checkpoint with the given id.
24    async fn delete(&self, checkpoint_id: &str) -> GraphResult<()>;
25    /// State and recursion budget of the most recently saved checkpoint.
26    async fn last(&self) -> GraphResult<Option<(S, usize)>>;
27
28    /// List every checkpoint as a full snapshot (state + ordering keys +
29    /// recursion budget), oldest first.
30    ///
31    /// Used by [`CompiledGraph::get_state_history`](crate::compiled::CompiledGraph::get_state_history)
32    /// and time-travel forks. The default reconstructs a state-only history from
33    /// [`list`](Self::list) + [`load`](Self::load) and reports an unknown
34    /// `timestamp`/`seq`/`recursion_count` of `0`; backends that store the full
35    /// [`CheckpointData`] (or its columns) override this to report the real
36    /// ordering keys and budget.
37    async fn snapshots(&self) -> GraphResult<Vec<CheckpointInfo<S>>> {
38        let mut snaps = Vec::new();
39        for id in self.list().await? {
40            let state = self.load(&id).await?;
41            snaps.push(CheckpointInfo {
42                id,
43                timestamp: 0,
44                seq: 0,
45                recursion_count: 0,
46                state,
47            });
48        }
49        Ok(snaps)
50    }
51
52    /// Replace the state stored in an existing checkpoint (the LangGraph
53    /// `updateState` analogue), using optimistic concurrency control.
54    ///
55    /// `expected_version` is the version of the state the edit is based on
56    /// (every fresh checkpoint starts at version `1`, and each successful
57    /// `update_state` bumps it). When the stored version has moved on because
58    /// another writer edited the checkpoint first, the call fails with
59    /// [`GraphError::CheckpointVersionConflict`] instead of overwriting that
60    /// edit. Returns the new version.
61    ///
62    /// Backends without edit support keep the default implementation, which
63    /// fails with [`GraphError::CheckpointError`].
64    async fn update_state(
65        &self,
66        checkpoint_id: &str,
67        state: &S,
68        expected_version: u64,
69    ) -> GraphResult<u64> {
70        let _ = (state, expected_version);
71        Err(GraphError::CheckpointError(format!(
72            "update_state is not supported on checkpoint '{checkpoint_id}' by this checkpointer",
73        )))
74    }
75}
76
77/// Checkpoint data structure
78#[derive(Debug, Clone, Serialize, Deserialize)]
79#[serde(bound = "S: StateSchema")]
80pub struct CheckpointData<S: StateSchema> {
81    /// Unique identifier of the checkpoint.
82    pub id: String,
83    /// The state snapshot stored in the checkpoint.
84    pub state: S,
85    /// Unix timestamp (seconds) when the checkpoint was created.
86    pub timestamp: i64,
87    /// Arbitrary metadata associated with the checkpoint.
88    pub metadata: HashMap<String, serde_json::Value>,
89    /// Monotonic sequence number assigned by the checkpointer on save. Breaks
90    /// ties between checkpoints saved within the same `timestamp` second, so
91    /// "most recent" is well-defined even for fast back-to-back saves (H5).
92    #[serde(default)]
93    pub seq: u64,
94    /// Recursion budget consumed when this checkpoint was taken, so a resume
95    /// continues counting against the same `recursion_limit` instead of
96    /// restarting from zero (M6).
97    #[serde(default)]
98    pub recursion_count: usize,
99    /// Optimistic-concurrency version: `1` for a fresh checkpoint, bumped on
100    /// every [`Checkpointer::update_state`]. Backed by an atomic compare-and
101    /// swap in the durable checkpointers.
102    #[serde(default = "initial_version")]
103    pub version: u64,
104}
105
106/// Fresh checkpoints start at version 1 (0 only occurs in pre-0.22.4 files).
107fn initial_version() -> u64 {
108    1
109}
110
111/// A single snapshot in a graph's execution history: the state captured at one
112/// checkpoint, together with the ordering keys and the recursion budget consumed
113/// up to that point (used to fork a fresh timeline from an old snapshot).
114#[derive(Debug, Clone)]
115pub struct CheckpointInfo<S: StateSchema> {
116    /// Unique id of the checkpoint this snapshot came from.
117    pub id: String,
118    /// Unix timestamp (seconds) when the checkpoint was created.
119    pub timestamp: i64,
120    /// Sequence assigned by the checkpointer; breaks same-second ties.
121    pub seq: u64,
122    /// Recursion budget consumed when the snapshot was captured. A fork seeded
123    /// from this snapshot continues counting against this budget.
124    pub recursion_count: usize,
125    /// The state snapshot.
126    pub state: S,
127}
128
129impl<S: StateSchema> CheckpointData<S> {
130    /// Create a new checkpoint for the given state.
131    pub fn new(state: S) -> Self {
132        Self {
133            id: Uuid::new_v4().to_string(),
134            state,
135            timestamp: chrono::Utc::now().timestamp(),
136            metadata: HashMap::new(),
137            seq: 0,
138            recursion_count: 0,
139            version: 1,
140        }
141    }
142
143    /// Construct with the checkpointer-assigned sequence and the run's current
144    /// recursion budget.
145    pub fn with_progress(state: S, seq: u64, recursion_count: usize) -> Self {
146        let mut data = Self::new(state);
147        data.seq = seq;
148        data.recursion_count = recursion_count;
149        data
150    }
151}
152
153/// In-memory checkpointer for development
154pub struct MemoryCheckpointer<S: StateSchema> {
155    checkpoints: Mutex<HashMap<String, CheckpointData<S>>>,
156    next_seq: AtomicU64,
157}
158
159impl<S: StateSchema> MemoryCheckpointer<S> {
160    /// Create a new empty in-memory checkpointer.
161    pub fn new() -> Self {
162        Self {
163            checkpoints: Mutex::new(HashMap::new()),
164            next_seq: AtomicU64::new(0),
165        }
166    }
167}
168
169impl<S: StateSchema> Default for MemoryCheckpointer<S> {
170    fn default() -> Self {
171        Self::new()
172    }
173}
174
175#[async_trait]
176impl<S: StateSchema> Checkpointer<S> for MemoryCheckpointer<S> {
177    async fn save(&self, state: &S, recursion_count: usize) -> GraphResult<String> {
178        let seq = self.next_seq.fetch_add(1, Ordering::SeqCst);
179        let data = CheckpointData::with_progress(state.clone(), seq, recursion_count);
180        let id = data.id.clone();
181        self.checkpoints.lock().await.insert(id.clone(), data);
182        Ok(id)
183    }
184
185    async fn load(&self, checkpoint_id: &str) -> GraphResult<S> {
186        self.checkpoints
187            .lock()
188            .await
189            .get(checkpoint_id)
190            .map(|d| d.state.clone())
191            .ok_or_else(|| {
192                GraphError::CheckpointError(format!("Checkpoint '{}' not found", checkpoint_id))
193            })
194    }
195
196    async fn list(&self) -> GraphResult<Vec<String>> {
197        let guard = self.checkpoints.lock().await;
198        let mut items: Vec<(i64, u64, String)> = guard
199            .values()
200            .map(|d| (d.timestamp, d.seq, d.id.clone()))
201            .collect();
202        // H5: the old HashMap.keys() order was nondeterministic; sort by (timestamp, seq)
203        // ascending instead, so callers taking `.last()` get the most recent checkpoint.
204        items.sort();
205        Ok(items.into_iter().map(|(_, _, id)| id).collect())
206    }
207
208    async fn last(&self) -> GraphResult<Option<(S, usize)>> {
209        let guard = self.checkpoints.lock().await;
210        Ok(guard
211            .values()
212            .max_by_key(|d| (d.timestamp, d.seq))
213            .map(|d| (d.state.clone(), d.recursion_count)))
214    }
215
216    async fn snapshots(&self) -> GraphResult<Vec<CheckpointInfo<S>>> {
217        let guard = self.checkpoints.lock().await;
218        let mut items: Vec<&CheckpointData<S>> = guard.values().collect();
219        items.sort_by_key(|d| (d.timestamp, d.seq));
220        Ok(items.into_iter().map(checkpoint_info_from_data).collect())
221    }
222
223    async fn update_state(
224        &self,
225        checkpoint_id: &str,
226        state: &S,
227        expected_version: u64,
228    ) -> GraphResult<u64> {
229        update_locked(&self.checkpoints, checkpoint_id, state, expected_version).await
230    }
231
232    async fn delete(&self, checkpoint_id: &str) -> GraphResult<()> {
233        self.checkpoints.lock().await.remove(checkpoint_id);
234        Ok(())
235    }
236}
237
238/// Build a [`CheckpointInfo`] from stored [`CheckpointData`] (used by the
239/// in-memory checkpointers' `snapshots`).
240fn checkpoint_info_from_data<S: StateSchema>(d: &CheckpointData<S>) -> CheckpointInfo<S> {
241    CheckpointInfo {
242        id: d.id.clone(),
243        timestamp: d.timestamp,
244        seq: d.seq,
245        recursion_count: d.recursion_count,
246        state: d.state.clone(),
247    }
248}
249
250/// In-memory OCC edit shared by [`MemoryCheckpointer`] and
251/// [`ThreadSafeMemoryCheckpointer`]: bump the version only while holding the
252/// map lock, so two concurrent edits cannot both succeed against the same
253/// base version.
254async fn update_locked<S: StateSchema>(
255    checkpoints: &Mutex<HashMap<String, CheckpointData<S>>>,
256    checkpoint_id: &str,
257    state: &S,
258    expected_version: u64,
259) -> GraphResult<u64> {
260    let mut guard = checkpoints.lock().await;
261    let data = guard.get_mut(checkpoint_id).ok_or_else(|| {
262        GraphError::CheckpointError(format!("Checkpoint '{checkpoint_id}' not found"))
263    })?;
264    if data.version != expected_version {
265        return Err(GraphError::CheckpointVersionConflict {
266            checkpoint_id: checkpoint_id.to_string(),
267            expected: expected_version,
268            actual: data.version,
269        });
270    }
271    data.state = state.clone();
272    data.version += 1;
273    Ok(data.version)
274}
275
276/// Thread-safe memory checkpointer
277pub struct ThreadSafeMemoryCheckpointer<S: StateSchema> {
278    checkpoints: Mutex<HashMap<String, CheckpointData<S>>>,
279    next_seq: AtomicU64,
280}
281
282impl<S: StateSchema> ThreadSafeMemoryCheckpointer<S> {
283    /// Create a new empty thread-safe memory checkpointer.
284    pub fn new() -> Self {
285        Self {
286            checkpoints: Mutex::new(HashMap::new()),
287            next_seq: AtomicU64::new(0),
288        }
289    }
290}
291
292impl<S: StateSchema> Default for ThreadSafeMemoryCheckpointer<S> {
293    fn default() -> Self {
294        Self::new()
295    }
296}
297
298#[async_trait]
299impl<S: StateSchema> Checkpointer<S> for ThreadSafeMemoryCheckpointer<S> {
300    async fn save(&self, state: &S, recursion_count: usize) -> GraphResult<String> {
301        let seq = self.next_seq.fetch_add(1, Ordering::SeqCst);
302        let data = CheckpointData::with_progress(state.clone(), seq, recursion_count);
303        let id = data.id.clone();
304        self.checkpoints.lock().await.insert(id.clone(), data);
305        Ok(id)
306    }
307
308    async fn load(&self, checkpoint_id: &str) -> GraphResult<S> {
309        let checkpoints = self.checkpoints.lock().await;
310        checkpoints
311            .get(checkpoint_id)
312            .map(|d| d.state.clone())
313            .ok_or_else(|| {
314                GraphError::CheckpointError(format!("Checkpoint '{}' not found", checkpoint_id))
315            })
316    }
317
318    async fn list(&self) -> GraphResult<Vec<String>> {
319        let guard = self.checkpoints.lock().await;
320        let mut items: Vec<(i64, u64, String)> = guard
321            .values()
322            .map(|d| (d.timestamp, d.seq, d.id.clone()))
323            .collect();
324        // H5: sort by (timestamp, seq) ascending; `.last()` is the most recent checkpoint.
325        items.sort();
326        Ok(items.into_iter().map(|(_, _, id)| id).collect())
327    }
328
329    async fn last(&self) -> GraphResult<Option<(S, usize)>> {
330        let guard = self.checkpoints.lock().await;
331        Ok(guard
332            .values()
333            .max_by_key(|d| (d.timestamp, d.seq))
334            .map(|d| (d.state.clone(), d.recursion_count)))
335    }
336
337    async fn snapshots(&self) -> GraphResult<Vec<CheckpointInfo<S>>> {
338        let guard = self.checkpoints.lock().await;
339        let mut items: Vec<&CheckpointData<S>> = guard.values().collect();
340        items.sort_by_key(|d| (d.timestamp, d.seq));
341        Ok(items.into_iter().map(checkpoint_info_from_data).collect())
342    }
343
344    async fn update_state(
345        &self,
346        checkpoint_id: &str,
347        state: &S,
348        expected_version: u64,
349    ) -> GraphResult<u64> {
350        update_locked(&self.checkpoints, checkpoint_id, state, expected_version).await
351    }
352
353    async fn delete(&self, checkpoint_id: &str) -> GraphResult<()> {
354        self.checkpoints.lock().await.remove(checkpoint_id);
355        Ok(())
356    }
357}
358
359/// File-based checkpointer for persistent storage
360pub struct FileCheckpointer<S: StateSchema> {
361    directory: std::path::PathBuf,
362    next_seq: AtomicU64,
363    /// Serializes the read-check-write critical section of `update_state`
364    /// within this process (cross-process OCC is provided by the SQLite /
365    /// Postgres / Redis backends, not by plain JSON files).
366    update_lock: Mutex<()>,
367    _phantom: std::marker::PhantomData<S>,
368}
369
370impl<S: StateSchema> FileCheckpointer<S> {
371    /// Create a file-based checkpointer that persists checkpoints under the given directory.
372    pub fn new(directory: impl Into<std::path::PathBuf>) -> GraphResult<Self> {
373        let dir = directory.into();
374        if !dir.exists() {
375            std::fs::create_dir_all(&dir).map_err(|e| {
376                GraphError::CheckpointError(format!(
377                    "Failed to create directory '{}': {}",
378                    dir.display(),
379                    e
380                ))
381            })?;
382        }
383        Ok(Self {
384            directory: dir,
385            next_seq: AtomicU64::new(0),
386            update_lock: Mutex::new(()),
387            _phantom: std::marker::PhantomData,
388        })
389    }
390
391    fn checkpoint_path(&self, id: &str) -> GraphResult<std::path::PathBuf> {
392        // Sanitize id to prevent path traversal: reject ".." and absolute paths
393        if id.contains("..") || id.contains('/') || id.contains('\\') {
394            return Err(GraphError::CheckpointError(format!(
395                "Invalid checkpoint id '{}': path traversal detected",
396                id
397            )));
398        }
399        if std::path::Path::new(id).is_absolute() {
400            return Err(GraphError::CheckpointError(format!(
401                "Invalid checkpoint id '{}': absolute path not allowed",
402                id
403            )));
404        }
405        Ok(self.directory.join(format!("{}.json", id)))
406    }
407
408    /// Read every checkpoint file's `(timestamp, seq, id)` sort keys.
409    async fn sorted_ids(&self) -> GraphResult<Vec<(i64, u64, String)>> {
410        let mut items: Vec<(i64, u64, String)> = Vec::new();
411        let mut entries = tokio::fs::read_dir(&self.directory)
412            .await
413            .map_err(|e| GraphError::CheckpointError(format!("Read dir error: {}", e)))?;
414        while let Some(entry) = entries
415            .next_entry()
416            .await
417            .map_err(|e| GraphError::CheckpointError(format!("Read dir entry error: {}", e)))?
418        {
419            let path = entry.path();
420            if path.extension().is_some_and(|ext| ext == "json") {
421                let Some(id) = path.file_stem().and_then(|s| s.to_str()).map(String::from) else {
422                    continue;
423                };
424                let json = tokio::fs::read_to_string(&path)
425                    .await
426                    .map_err(|e| GraphError::CheckpointError(format!("Read error: {}", e)))?;
427                let data: CheckpointData<S> = serde_json::from_str(&json).map_err(|e| {
428                    GraphError::CheckpointError(format!("Deserialize error: {}", e))
429                })?;
430                items.push((data.timestamp, data.seq, id));
431            }
432        }
433        // H5: sort by (timestamp, seq) ascending; seq breaks ties within the same second.
434        items.sort();
435        Ok(items)
436    }
437}
438
439// NOTE: `Default` is intentionally NOT implemented for `FileCheckpointer` (Q1).
440// The default constructor would have to create the `.checkpoints` directory, which
441// is I/O that can fail (read-only cwd, disk full, permissions) — `Default` cannot
442// report that failure, so it would have to panic. Use `FileCheckpointer::new(...)`
443// which returns a `GraphResult` and surfaces the error instead.
444
445#[async_trait]
446impl<S: StateSchema> Checkpointer<S> for FileCheckpointer<S> {
447    async fn save(&self, state: &S, recursion_count: usize) -> GraphResult<String> {
448        let seq = self.next_seq.fetch_add(1, Ordering::SeqCst);
449        let data = CheckpointData::with_progress(state.clone(), seq, recursion_count);
450        let id = data.id.clone();
451        let path = self.checkpoint_path(&id)?;
452
453        let json = serde_json::to_string_pretty(&data)
454            .map_err(|e| GraphError::CheckpointError(format!("Serialize error: {}", e)))?;
455
456        // Atomic write: write `{id}.json.tmp` first, then rename over the real file, so a
457        // crash or interrupt mid-JSON cannot corrupt the checkpoint (same pattern as
458        // FileResumeStore). The `.tmp` extension is never picked up by sorted_ids' `.json` filter.
459        let tmp_path = self.directory.join(format!("{id}.json.tmp"));
460        tokio::fs::write(&tmp_path, &json)
461            .await
462            .map_err(|e| GraphError::CheckpointError(format!("Write error: {}", e)))?;
463        tokio::fs::rename(&tmp_path, &path)
464            .await
465            .map_err(|e| GraphError::CheckpointError(format!("Atomic rename error: {}", e)))?;
466
467        Ok(id)
468    }
469
470    async fn load(&self, checkpoint_id: &str) -> GraphResult<S> {
471        let path = self.checkpoint_path(checkpoint_id)?;
472
473        if !path.exists() {
474            return Err(GraphError::CheckpointError(format!(
475                "Checkpoint '{}' not found",
476                checkpoint_id
477            )));
478        }
479
480        let json = tokio::fs::read_to_string(&path)
481            .await
482            .map_err(|e| GraphError::CheckpointError(format!("Read error: {}", e)))?;
483
484        let data: CheckpointData<S> = serde_json::from_str(&json)
485            .map_err(|e| GraphError::CheckpointError(format!("Deserialize error: {}", e)))?;
486
487        Ok(data.state)
488    }
489
490    async fn list(&self) -> GraphResult<Vec<String>> {
491        Ok(self
492            .sorted_ids()
493            .await?
494            .into_iter()
495            .map(|(_, _, id)| id)
496            .collect())
497    }
498
499    async fn last(&self) -> GraphResult<Option<(S, usize)>> {
500        let Some((_, _, last_id)) = self.sorted_ids().await?.into_iter().last() else {
501            return Ok(None);
502        };
503        let json = tokio::fs::read_to_string(&self.checkpoint_path(&last_id)?)
504            .await
505            .map_err(|e| GraphError::CheckpointError(format!("Read error: {}", e)))?;
506        let data: CheckpointData<S> = serde_json::from_str(&json)
507            .map_err(|e| GraphError::CheckpointError(format!("Deserialize error: {}", e)))?;
508        Ok(Some((data.state, data.recursion_count)))
509    }
510
511    async fn snapshots(&self) -> GraphResult<Vec<CheckpointInfo<S>>> {
512        let ids = self.sorted_ids().await?;
513        let mut snaps = Vec::with_capacity(ids.len());
514        for (_, _, id) in ids {
515            let json = tokio::fs::read_to_string(&self.checkpoint_path(&id)?)
516                .await
517                .map_err(|e| GraphError::CheckpointError(format!("Read error: {}", e)))?;
518            let data: CheckpointData<S> = serde_json::from_str(&json)
519                .map_err(|e| GraphError::CheckpointError(format!("Deserialize error: {}", e)))?;
520            snaps.push(checkpoint_info_from_data(&data));
521        }
522        Ok(snaps)
523    }
524
525    async fn update_state(
526        &self,
527        checkpoint_id: &str,
528        state: &S,
529        expected_version: u64,
530    ) -> GraphResult<u64> {
531        let _guard = self.update_lock.lock().await;
532        let path = self.checkpoint_path(checkpoint_id)?;
533        let json = tokio::fs::read_to_string(&path)
534            .await
535            .map_err(|e| GraphError::CheckpointError(format!("Read error: {}", e)))?;
536        let mut data: CheckpointData<S> = serde_json::from_str(&json)
537            .map_err(|e| GraphError::CheckpointError(format!("Deserialize error: {}", e)))?;
538        if data.version != expected_version {
539            return Err(GraphError::CheckpointVersionConflict {
540                checkpoint_id: checkpoint_id.to_string(),
541                expected: expected_version,
542                actual: data.version,
543            });
544        }
545        data.state = state.clone();
546        data.version += 1;
547
548        let json = serde_json::to_string_pretty(&data)
549            .map_err(|e| GraphError::CheckpointError(format!("Serialize error: {}", e)))?;
550        let tmp_path = self.directory.join(format!("{checkpoint_id}.json.tmp"));
551        tokio::fs::write(&tmp_path, &json)
552            .await
553            .map_err(|e| GraphError::CheckpointError(format!("Write error: {}", e)))?;
554        tokio::fs::rename(&tmp_path, &path)
555            .await
556            .map_err(|e| GraphError::CheckpointError(format!("Atomic rename error: {}", e)))?;
557        Ok(data.version)
558    }
559
560    async fn delete(&self, checkpoint_id: &str) -> GraphResult<()> {
561        let path = self.checkpoint_path(checkpoint_id)?;
562
563        if path.exists() {
564            tokio::fs::remove_file(&path)
565                .await
566                .map_err(|e| GraphError::CheckpointError(format!("Delete error: {}", e)))?;
567        }
568
569        Ok(())
570    }
571}
572
573#[cfg(test)]
574mod tests {
575    use super::*;
576    use crate::state::AgentState;
577
578    #[tokio::test]
579    async fn test_thread_safe_checkpointer() {
580        let checkpointer = ThreadSafeMemoryCheckpointer::<AgentState>::new();
581
582        let state = AgentState::new("test".to_string());
583        let id = checkpointer.save(&state, 0).await.unwrap();
584
585        let loaded = checkpointer.load(&id).await.unwrap();
586        assert_eq!(loaded.input, "test");
587
588        let list = checkpointer.list().await.unwrap();
589        assert_eq!(list.len(), 1);
590
591        checkpointer.delete(&id).await.unwrap();
592        let list = checkpointer.list().await.unwrap();
593        assert!(list.is_empty());
594    }
595
596    #[tokio::test]
597    async fn test_file_checkpointer() {
598        let temp_dir = tempfile::tempdir().unwrap();
599        let checkpointer = FileCheckpointer::<AgentState>::new(temp_dir.path()).unwrap();
600
601        let state = AgentState::new("file_test".to_string());
602        let id = checkpointer.save(&state, 0).await.unwrap();
603
604        let loaded = checkpointer.load(&id).await.unwrap();
605        assert_eq!(loaded.input, "file_test");
606
607        let list = checkpointer.list().await.unwrap();
608        assert_eq!(list.len(), 1);
609
610        checkpointer.delete(&id).await.unwrap();
611        let list = checkpointer.list().await.unwrap();
612        assert!(list.is_empty());
613    }
614
615    #[tokio::test]
616    async fn test_file_checkpointer_atomic_write() {
617        let temp_dir = tempfile::tempdir().unwrap();
618        let checkpointer = FileCheckpointer::<AgentState>::new(temp_dir.path()).unwrap();
619
620        let id = checkpointer
621            .save(&AgentState::new("atomic".to_string()), 0)
622            .await
623            .unwrap();
624
625        // The main file is complete and parseable; no `.tmp` leftover (rename cleaned it up).
626        let main = temp_dir.path().join(format!("{id}.json"));
627        assert!(main.exists(), "checkpoint file must exist");
628        let json = tokio::fs::read_to_string(&main).await.unwrap();
629        assert!(
630            serde_json::from_str::<CheckpointData<AgentState>>(&json).is_ok(),
631            "checkpoint file must be complete JSON after atomic write"
632        );
633        assert!(
634            !temp_dir.path().join(format!("{id}.json.tmp")).exists(),
635            "tmp file must be renamed away, not left behind"
636        );
637
638        // A stale `.tmp` file must not be read by list() (extension filter).
639        std::fs::write(temp_dir.path().join("stale.json.tmp"), b"{}").unwrap();
640        let list = checkpointer.list().await.unwrap();
641        assert_eq!(list, vec![id]);
642    }
643
644    #[tokio::test]
645    async fn test_file_checkpointer_multiple() {
646        let temp_dir = tempfile::tempdir().unwrap();
647        let checkpointer = FileCheckpointer::<AgentState>::new(temp_dir.path()).unwrap();
648
649        let id1 = checkpointer
650            .save(&AgentState::new("state1".to_string()), 0)
651            .await
652            .unwrap();
653        let id2 = checkpointer
654            .save(&AgentState::new("state2".to_string()), 0)
655            .await
656            .unwrap();
657        let _id3 = checkpointer
658            .save(&AgentState::new("state3".to_string()), 0)
659            .await
660            .unwrap();
661
662        let list = checkpointer.list().await.unwrap();
663        assert_eq!(list.len(), 3);
664
665        let loaded = checkpointer.load(&id2).await.unwrap();
666        assert_eq!(loaded.input, "state2");
667
668        checkpointer.delete(&id1).await.unwrap();
669        let list = checkpointer.list().await.unwrap();
670        assert_eq!(list.len(), 2);
671    }
672
673    #[tokio::test]
674    async fn test_file_checkpointer_path_traversal() {
675        let temp_dir = tempfile::tempdir().unwrap();
676        let checkpointer = FileCheckpointer::<AgentState>::new(temp_dir.path()).unwrap();
677
678        // Path traversal should be rejected
679        let result = checkpointer.load("..").await;
680        assert!(result.is_err());
681
682        let result = checkpointer.load("../etc/passwd").await;
683        assert!(result.is_err());
684    }
685
686    #[tokio::test]
687    async fn test_list_orders_oldest_to_newest() {
688        let checkpointer = ThreadSafeMemoryCheckpointer::<AgentState>::new();
689        checkpointer
690            .save(&AgentState::new("first".to_string()), 0)
691            .await
692            .unwrap();
693        checkpointer
694            .save(&AgentState::new("second".to_string()), 1)
695            .await
696            .unwrap();
697        checkpointer
698            .save(&AgentState::new("third".to_string()), 2)
699            .await
700            .unwrap();
701
702        let list = checkpointer.list().await.unwrap();
703        assert_eq!(list.len(), 3);
704        // H5: the last id must be the most recent save (not HashMap arbitrary order).
705        let (state, _) = checkpointer.last().await.unwrap().unwrap();
706        assert_eq!(state.input, "third");
707    }
708
709    #[tokio::test]
710    async fn test_last_returns_recursion_count() {
711        let checkpointer = ThreadSafeMemoryCheckpointer::<AgentState>::new();
712        checkpointer
713            .save(&AgentState::new("a".to_string()), 7)
714            .await
715            .unwrap();
716        checkpointer
717            .save(&AgentState::new("b".to_string()), 12)
718            .await
719            .unwrap();
720
721        // M6: last() returns the recursion_count of the most recent save
722        let (state, recursion_count) = checkpointer.last().await.unwrap().unwrap();
723        assert_eq!(state.input, "b");
724        assert_eq!(recursion_count, 12);
725    }
726
727    #[tokio::test]
728    async fn test_update_state_occ_memory() {
729        let checkpointer = ThreadSafeMemoryCheckpointer::<AgentState>::new();
730        let id = checkpointer
731            .save(&AgentState::new("v1".to_string()), 0)
732            .await
733            .unwrap();
734
735        // First edit based on version 1 succeeds and bumps to 2.
736        let version = checkpointer
737            .update_state(&id, &AgentState::new("v2".to_string()), 1)
738            .await
739            .unwrap();
740        assert_eq!(version, 2);
741        assert_eq!(checkpointer.load(&id).await.unwrap().input, "v2");
742
743        // A stale edit still based on version 1 must be rejected, not overwrite.
744        let conflict = checkpointer
745            .update_state(&id, &AgentState::new("v3-stale".to_string()), 1)
746            .await
747            .unwrap_err();
748        match conflict {
749            GraphError::CheckpointVersionConflict {
750                expected, actual, ..
751            } => {
752                assert_eq!(expected, 1);
753                assert_eq!(actual, 2);
754            }
755            other => panic!("expected CheckpointVersionConflict, got {other:?}"),
756        }
757        assert_eq!(checkpointer.load(&id).await.unwrap().input, "v2");
758
759        // An edit based on the current version 2 succeeds.
760        checkpointer
761            .update_state(&id, &AgentState::new("v3".to_string()), 2)
762            .await
763            .unwrap();
764        assert_eq!(checkpointer.load(&id).await.unwrap().input, "v3");
765    }
766
767    #[tokio::test]
768    async fn test_update_state_missing_and_file() {
769        let temp_dir = tempfile::tempdir().unwrap();
770        let checkpointer = FileCheckpointer::<AgentState>::new(temp_dir.path()).unwrap();
771        let id = checkpointer
772            .save(&AgentState::new("disk-v1".to_string()), 0)
773            .await
774            .unwrap();
775        checkpointer
776            .update_state(&id, &AgentState::new("disk-v2".to_string()), 1)
777            .await
778            .unwrap();
779        assert_eq!(checkpointer.load(&id).await.unwrap().input, "disk-v2");
780        // Stale version conflicts on disk too.
781        assert!(checkpointer
782            .update_state(&id, &AgentState::new("stale".to_string()), 1)
783            .await
784            .is_err());
785        // Unknown checkpoint id errors rather than inserting.
786        assert!(checkpointer
787            .update_state("missing", &AgentState::new("x".to_string()), 1)
788            .await
789            .is_err());
790    }
791
792    #[tokio::test]
793    async fn test_file_checkpointer_last_orders_by_save() {
794        let temp_dir = tempfile::tempdir().unwrap();
795        let checkpointer = FileCheckpointer::<AgentState>::new(temp_dir.path()).unwrap();
796        checkpointer
797            .save(&AgentState::new("one".to_string()), 1)
798            .await
799            .unwrap();
800        checkpointer
801            .save(&AgentState::new("two".to_string()), 2)
802            .await
803            .unwrap();
804
805        let list = checkpointer.list().await.unwrap();
806        assert_eq!(list.len(), 2);
807        let (state, recursion_count) = checkpointer.last().await.unwrap().unwrap();
808        assert_eq!(state.input, "two");
809        assert_eq!(recursion_count, 2);
810    }
811}