use crate::errors::{GraphError, GraphResult};
use crate::state::StateSchema;
use async_trait::async_trait;
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::sync::atomic::{AtomicU64, Ordering};
use tokio::sync::Mutex;
use uuid::Uuid;
#[async_trait]
pub trait Checkpointer<S: StateSchema>: Send + Sync {
async fn save(&self, state: &S, recursion_count: usize) -> GraphResult<String>;
async fn load(&self, checkpoint_id: &str) -> GraphResult<S>;
async fn list(&self) -> GraphResult<Vec<String>>;
async fn delete(&self, checkpoint_id: &str) -> GraphResult<()>;
async fn last(&self) -> GraphResult<Option<(S, usize)>>;
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(bound = "S: StateSchema")]
pub struct CheckpointData<S: StateSchema> {
pub id: String,
pub state: S,
pub timestamp: i64,
pub metadata: HashMap<String, serde_json::Value>,
#[serde(default)]
pub seq: u64,
#[serde(default)]
pub recursion_count: usize,
}
impl<S: StateSchema> CheckpointData<S> {
pub fn new(state: S) -> Self {
Self {
id: Uuid::new_v4().to_string(),
state,
timestamp: chrono::Utc::now().timestamp(),
metadata: HashMap::new(),
seq: 0,
recursion_count: 0,
}
}
pub fn with_progress(state: S, seq: u64, recursion_count: usize) -> Self {
let mut data = Self::new(state);
data.seq = seq;
data.recursion_count = recursion_count;
data
}
}
pub struct MemoryCheckpointer<S: StateSchema> {
checkpoints: Mutex<HashMap<String, CheckpointData<S>>>,
next_seq: AtomicU64,
}
impl<S: StateSchema> MemoryCheckpointer<S> {
pub fn new() -> Self {
Self {
checkpoints: Mutex::new(HashMap::new()),
next_seq: AtomicU64::new(0),
}
}
}
impl<S: StateSchema> Default for MemoryCheckpointer<S> {
fn default() -> Self {
Self::new()
}
}
#[async_trait]
impl<S: StateSchema> Checkpointer<S> for MemoryCheckpointer<S> {
async fn save(&self, state: &S, recursion_count: usize) -> GraphResult<String> {
let seq = self.next_seq.fetch_add(1, Ordering::SeqCst);
let data = CheckpointData::with_progress(state.clone(), seq, recursion_count);
let id = data.id.clone();
self.checkpoints.lock().await.insert(id.clone(), data);
Ok(id)
}
async fn load(&self, checkpoint_id: &str) -> GraphResult<S> {
self.checkpoints
.lock()
.await
.get(checkpoint_id)
.map(|d| d.state.clone())
.ok_or_else(|| {
GraphError::CheckpointError(format!("Checkpoint '{}' not found", checkpoint_id))
})
}
async fn list(&self) -> GraphResult<Vec<String>> {
let guard = self.checkpoints.lock().await;
let mut items: Vec<(i64, u64, String)> = guard
.values()
.map(|d| (d.timestamp, d.seq, d.id.clone()))
.collect();
items.sort();
Ok(items.into_iter().map(|(_, _, id)| id).collect())
}
async fn last(&self) -> GraphResult<Option<(S, usize)>> {
let guard = self.checkpoints.lock().await;
Ok(guard
.values()
.max_by_key(|d| (d.timestamp, d.seq))
.map(|d| (d.state.clone(), d.recursion_count)))
}
async fn delete(&self, checkpoint_id: &str) -> GraphResult<()> {
self.checkpoints.lock().await.remove(checkpoint_id);
Ok(())
}
}
pub struct ThreadSafeMemoryCheckpointer<S: StateSchema> {
checkpoints: Mutex<HashMap<String, CheckpointData<S>>>,
next_seq: AtomicU64,
}
impl<S: StateSchema> ThreadSafeMemoryCheckpointer<S> {
pub fn new() -> Self {
Self {
checkpoints: Mutex::new(HashMap::new()),
next_seq: AtomicU64::new(0),
}
}
}
impl<S: StateSchema> Default for ThreadSafeMemoryCheckpointer<S> {
fn default() -> Self {
Self::new()
}
}
#[async_trait]
impl<S: StateSchema> Checkpointer<S> for ThreadSafeMemoryCheckpointer<S> {
async fn save(&self, state: &S, recursion_count: usize) -> GraphResult<String> {
let seq = self.next_seq.fetch_add(1, Ordering::SeqCst);
let data = CheckpointData::with_progress(state.clone(), seq, recursion_count);
let id = data.id.clone();
self.checkpoints.lock().await.insert(id.clone(), data);
Ok(id)
}
async fn load(&self, checkpoint_id: &str) -> GraphResult<S> {
let checkpoints = self.checkpoints.lock().await;
checkpoints
.get(checkpoint_id)
.map(|d| d.state.clone())
.ok_or_else(|| {
GraphError::CheckpointError(format!("Checkpoint '{}' not found", checkpoint_id))
})
}
async fn list(&self) -> GraphResult<Vec<String>> {
let guard = self.checkpoints.lock().await;
let mut items: Vec<(i64, u64, String)> = guard
.values()
.map(|d| (d.timestamp, d.seq, d.id.clone()))
.collect();
items.sort();
Ok(items.into_iter().map(|(_, _, id)| id).collect())
}
async fn last(&self) -> GraphResult<Option<(S, usize)>> {
let guard = self.checkpoints.lock().await;
Ok(guard
.values()
.max_by_key(|d| (d.timestamp, d.seq))
.map(|d| (d.state.clone(), d.recursion_count)))
}
async fn delete(&self, checkpoint_id: &str) -> GraphResult<()> {
self.checkpoints.lock().await.remove(checkpoint_id);
Ok(())
}
}
pub struct FileCheckpointer<S: StateSchema> {
directory: std::path::PathBuf,
next_seq: AtomicU64,
_phantom: std::marker::PhantomData<S>,
}
impl<S: StateSchema> FileCheckpointer<S> {
pub fn new(directory: impl Into<std::path::PathBuf>) -> GraphResult<Self> {
let dir = directory.into();
if !dir.exists() {
std::fs::create_dir_all(&dir).map_err(|e| {
GraphError::CheckpointError(format!(
"Failed to create directory '{}': {}",
dir.display(),
e
))
})?;
}
Ok(Self {
directory: dir,
next_seq: AtomicU64::new(0),
_phantom: std::marker::PhantomData,
})
}
fn checkpoint_path(&self, id: &str) -> GraphResult<std::path::PathBuf> {
if id.contains("..") || id.contains('/') || id.contains('\\') {
return Err(GraphError::CheckpointError(format!(
"Invalid checkpoint id '{}': path traversal detected",
id
)));
}
if std::path::Path::new(id).is_absolute() {
return Err(GraphError::CheckpointError(format!(
"Invalid checkpoint id '{}': absolute path not allowed",
id
)));
}
Ok(self.directory.join(format!("{}.json", id)))
}
async fn sorted_ids(&self) -> GraphResult<Vec<(i64, u64, String)>> {
let mut items: Vec<(i64, u64, String)> = Vec::new();
let mut entries = tokio::fs::read_dir(&self.directory)
.await
.map_err(|e| GraphError::CheckpointError(format!("Read dir error: {}", e)))?;
while let Some(entry) = entries
.next_entry()
.await
.map_err(|e| GraphError::CheckpointError(format!("Read dir entry error: {}", e)))?
{
let path = entry.path();
if path.extension().is_some_and(|ext| ext == "json") {
let Some(id) = path.file_stem().and_then(|s| s.to_str()).map(String::from) else {
continue;
};
let json = tokio::fs::read_to_string(&path)
.await
.map_err(|e| GraphError::CheckpointError(format!("Read error: {}", e)))?;
let data: CheckpointData<S> = serde_json::from_str(&json).map_err(|e| {
GraphError::CheckpointError(format!("Deserialize error: {}", e))
})?;
items.push((data.timestamp, data.seq, id));
}
}
items.sort();
Ok(items)
}
}
#[async_trait]
impl<S: StateSchema> Checkpointer<S> for FileCheckpointer<S> {
async fn save(&self, state: &S, recursion_count: usize) -> GraphResult<String> {
let seq = self.next_seq.fetch_add(1, Ordering::SeqCst);
let data = CheckpointData::with_progress(state.clone(), seq, recursion_count);
let id = data.id.clone();
let path = self.checkpoint_path(&id)?;
let json = serde_json::to_string_pretty(&data)
.map_err(|e| GraphError::CheckpointError(format!("Serialize error: {}", e)))?;
tokio::fs::write(&path, json)
.await
.map_err(|e| GraphError::CheckpointError(format!("Write error: {}", e)))?;
Ok(id)
}
async fn load(&self, checkpoint_id: &str) -> GraphResult<S> {
let path = self.checkpoint_path(checkpoint_id)?;
if !path.exists() {
return Err(GraphError::CheckpointError(format!(
"Checkpoint '{}' not found",
checkpoint_id
)));
}
let json = tokio::fs::read_to_string(&path)
.await
.map_err(|e| GraphError::CheckpointError(format!("Read error: {}", e)))?;
let data: CheckpointData<S> = serde_json::from_str(&json)
.map_err(|e| GraphError::CheckpointError(format!("Deserialize error: {}", e)))?;
Ok(data.state)
}
async fn list(&self) -> GraphResult<Vec<String>> {
Ok(self
.sorted_ids()
.await?
.into_iter()
.map(|(_, _, id)| id)
.collect())
}
async fn last(&self) -> GraphResult<Option<(S, usize)>> {
let Some((_, _, last_id)) = self.sorted_ids().await?.into_iter().last() else {
return Ok(None);
};
let json = tokio::fs::read_to_string(&self.checkpoint_path(&last_id)?)
.await
.map_err(|e| GraphError::CheckpointError(format!("Read error: {}", e)))?;
let data: CheckpointData<S> = serde_json::from_str(&json)
.map_err(|e| GraphError::CheckpointError(format!("Deserialize error: {}", e)))?;
Ok(Some((data.state, data.recursion_count)))
}
async fn delete(&self, checkpoint_id: &str) -> GraphResult<()> {
let path = self.checkpoint_path(checkpoint_id)?;
if path.exists() {
tokio::fs::remove_file(&path)
.await
.map_err(|e| GraphError::CheckpointError(format!("Delete error: {}", e)))?;
}
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::state::AgentState;
#[tokio::test]
async fn test_thread_safe_checkpointer() {
let checkpointer = ThreadSafeMemoryCheckpointer::<AgentState>::new();
let state = AgentState::new("test".to_string());
let id = checkpointer.save(&state, 0).await.unwrap();
let loaded = checkpointer.load(&id).await.unwrap();
assert_eq!(loaded.input, "test");
let list = checkpointer.list().await.unwrap();
assert_eq!(list.len(), 1);
checkpointer.delete(&id).await.unwrap();
let list = checkpointer.list().await.unwrap();
assert!(list.is_empty());
}
#[tokio::test]
async fn test_file_checkpointer() {
let temp_dir = tempfile::tempdir().unwrap();
let checkpointer = FileCheckpointer::<AgentState>::new(temp_dir.path()).unwrap();
let state = AgentState::new("file_test".to_string());
let id = checkpointer.save(&state, 0).await.unwrap();
let loaded = checkpointer.load(&id).await.unwrap();
assert_eq!(loaded.input, "file_test");
let list = checkpointer.list().await.unwrap();
assert_eq!(list.len(), 1);
checkpointer.delete(&id).await.unwrap();
let list = checkpointer.list().await.unwrap();
assert!(list.is_empty());
}
#[tokio::test]
async fn test_file_checkpointer_multiple() {
let temp_dir = tempfile::tempdir().unwrap();
let checkpointer = FileCheckpointer::<AgentState>::new(temp_dir.path()).unwrap();
let id1 = checkpointer
.save(&AgentState::new("state1".to_string()), 0)
.await
.unwrap();
let id2 = checkpointer
.save(&AgentState::new("state2".to_string()), 0)
.await
.unwrap();
let _id3 = checkpointer
.save(&AgentState::new("state3".to_string()), 0)
.await
.unwrap();
let list = checkpointer.list().await.unwrap();
assert_eq!(list.len(), 3);
let loaded = checkpointer.load(&id2).await.unwrap();
assert_eq!(loaded.input, "state2");
checkpointer.delete(&id1).await.unwrap();
let list = checkpointer.list().await.unwrap();
assert_eq!(list.len(), 2);
}
#[tokio::test]
async fn test_file_checkpointer_path_traversal() {
let temp_dir = tempfile::tempdir().unwrap();
let checkpointer = FileCheckpointer::<AgentState>::new(temp_dir.path()).unwrap();
let result = checkpointer.load("..").await;
assert!(result.is_err());
let result = checkpointer.load("../etc/passwd").await;
assert!(result.is_err());
}
#[tokio::test]
async fn test_list_orders_oldest_to_newest() {
let checkpointer = ThreadSafeMemoryCheckpointer::<AgentState>::new();
checkpointer
.save(&AgentState::new("first".to_string()), 0)
.await
.unwrap();
checkpointer
.save(&AgentState::new("second".to_string()), 1)
.await
.unwrap();
checkpointer
.save(&AgentState::new("third".to_string()), 2)
.await
.unwrap();
let list = checkpointer.list().await.unwrap();
assert_eq!(list.len(), 3);
let (state, _) = checkpointer.last().await.unwrap().unwrap();
assert_eq!(state.input, "third");
}
#[tokio::test]
async fn test_last_returns_recursion_count() {
let checkpointer = ThreadSafeMemoryCheckpointer::<AgentState>::new();
checkpointer
.save(&AgentState::new("a".to_string()), 7)
.await
.unwrap();
checkpointer
.save(&AgentState::new("b".to_string()), 12)
.await
.unwrap();
let (state, recursion_count) = checkpointer.last().await.unwrap().unwrap();
assert_eq!(state.input, "b");
assert_eq!(recursion_count, 12);
}
#[tokio::test]
async fn test_file_checkpointer_last_orders_by_save() {
let temp_dir = tempfile::tempdir().unwrap();
let checkpointer = FileCheckpointer::<AgentState>::new(temp_dir.path()).unwrap();
checkpointer
.save(&AgentState::new("one".to_string()), 1)
.await
.unwrap();
checkpointer
.save(&AgentState::new("two".to_string()), 2)
.await
.unwrap();
let list = checkpointer.list().await.unwrap();
assert_eq!(list.len(), 2);
let (state, recursion_count) = checkpointer.last().await.unwrap().unwrap();
assert_eq!(state.input, "two");
assert_eq!(recursion_count, 2);
}
}