1use 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#[async_trait]
15pub trait Checkpointer<S: StateSchema>: Send + Sync {
16 async fn save(&self, state: &S, recursion_count: usize) -> GraphResult<String>;
19 async fn load(&self, checkpoint_id: &str) -> GraphResult<S>;
20 async fn list(&self) -> GraphResult<Vec<String>>;
22 async fn delete(&self, checkpoint_id: &str) -> GraphResult<()>;
23 async fn last(&self) -> GraphResult<Option<(S, usize)>>;
25}
26
27#[derive(Debug, Clone, Serialize, Deserialize)]
29#[serde(bound = "S: StateSchema")]
30pub struct CheckpointData<S: StateSchema> {
31 pub id: String,
32 pub state: S,
33 pub timestamp: i64,
34 pub metadata: HashMap<String, serde_json::Value>,
35 #[serde(default)]
39 pub seq: u64,
40 #[serde(default)]
44 pub recursion_count: usize,
45}
46
47impl<S: StateSchema> CheckpointData<S> {
48 pub fn new(state: S) -> Self {
49 Self {
50 id: Uuid::new_v4().to_string(),
51 state,
52 timestamp: chrono::Utc::now().timestamp(),
53 metadata: HashMap::new(),
54 seq: 0,
55 recursion_count: 0,
56 }
57 }
58
59 pub fn with_progress(state: S, seq: u64, recursion_count: usize) -> Self {
62 let mut data = Self::new(state);
63 data.seq = seq;
64 data.recursion_count = recursion_count;
65 data
66 }
67}
68
69pub struct MemoryCheckpointer<S: StateSchema> {
71 checkpoints: Mutex<HashMap<String, CheckpointData<S>>>,
72 next_seq: AtomicU64,
73}
74
75impl<S: StateSchema> MemoryCheckpointer<S> {
76 pub fn new() -> Self {
77 Self {
78 checkpoints: Mutex::new(HashMap::new()),
79 next_seq: AtomicU64::new(0),
80 }
81 }
82}
83
84impl<S: StateSchema> Default for MemoryCheckpointer<S> {
85 fn default() -> Self {
86 Self::new()
87 }
88}
89
90#[async_trait]
91impl<S: StateSchema> Checkpointer<S> for MemoryCheckpointer<S> {
92 async fn save(&self, state: &S, recursion_count: usize) -> GraphResult<String> {
93 let seq = self.next_seq.fetch_add(1, Ordering::SeqCst);
94 let data = CheckpointData::with_progress(state.clone(), seq, recursion_count);
95 let id = data.id.clone();
96 self.checkpoints.lock().await.insert(id.clone(), data);
97 Ok(id)
98 }
99
100 async fn load(&self, checkpoint_id: &str) -> GraphResult<S> {
101 self.checkpoints
102 .lock()
103 .await
104 .get(checkpoint_id)
105 .map(|d| d.state.clone())
106 .ok_or_else(|| {
107 GraphError::CheckpointError(format!("Checkpoint '{}' not found", checkpoint_id))
108 })
109 }
110
111 async fn list(&self) -> GraphResult<Vec<String>> {
112 let guard = self.checkpoints.lock().await;
113 let mut items: Vec<(i64, u64, String)> = guard
114 .values()
115 .map(|d| (d.timestamp, d.seq, d.id.clone()))
116 .collect();
117 items.sort();
120 Ok(items.into_iter().map(|(_, _, id)| id).collect())
121 }
122
123 async fn last(&self) -> GraphResult<Option<(S, usize)>> {
124 let guard = self.checkpoints.lock().await;
125 Ok(guard
126 .values()
127 .max_by_key(|d| (d.timestamp, d.seq))
128 .map(|d| (d.state.clone(), d.recursion_count)))
129 }
130
131 async fn delete(&self, checkpoint_id: &str) -> GraphResult<()> {
132 self.checkpoints.lock().await.remove(checkpoint_id);
133 Ok(())
134 }
135}
136
137pub struct ThreadSafeMemoryCheckpointer<S: StateSchema> {
139 checkpoints: Mutex<HashMap<String, CheckpointData<S>>>,
140 next_seq: AtomicU64,
141}
142
143impl<S: StateSchema> ThreadSafeMemoryCheckpointer<S> {
144 pub fn new() -> Self {
145 Self {
146 checkpoints: Mutex::new(HashMap::new()),
147 next_seq: AtomicU64::new(0),
148 }
149 }
150}
151
152impl<S: StateSchema> Default for ThreadSafeMemoryCheckpointer<S> {
153 fn default() -> Self {
154 Self::new()
155 }
156}
157
158#[async_trait]
159impl<S: StateSchema> Checkpointer<S> for ThreadSafeMemoryCheckpointer<S> {
160 async fn save(&self, state: &S, recursion_count: usize) -> GraphResult<String> {
161 let seq = self.next_seq.fetch_add(1, Ordering::SeqCst);
162 let data = CheckpointData::with_progress(state.clone(), seq, recursion_count);
163 let id = data.id.clone();
164 self.checkpoints.lock().await.insert(id.clone(), data);
165 Ok(id)
166 }
167
168 async fn load(&self, checkpoint_id: &str) -> GraphResult<S> {
169 let checkpoints = self.checkpoints.lock().await;
170 checkpoints
171 .get(checkpoint_id)
172 .map(|d| d.state.clone())
173 .ok_or_else(|| {
174 GraphError::CheckpointError(format!("Checkpoint '{}' not found", checkpoint_id))
175 })
176 }
177
178 async fn list(&self) -> GraphResult<Vec<String>> {
179 let guard = self.checkpoints.lock().await;
180 let mut items: Vec<(i64, u64, String)> = guard
181 .values()
182 .map(|d| (d.timestamp, d.seq, d.id.clone()))
183 .collect();
184 items.sort();
186 Ok(items.into_iter().map(|(_, _, id)| id).collect())
187 }
188
189 async fn last(&self) -> GraphResult<Option<(S, usize)>> {
190 let guard = self.checkpoints.lock().await;
191 Ok(guard
192 .values()
193 .max_by_key(|d| (d.timestamp, d.seq))
194 .map(|d| (d.state.clone(), d.recursion_count)))
195 }
196
197 async fn delete(&self, checkpoint_id: &str) -> GraphResult<()> {
198 self.checkpoints.lock().await.remove(checkpoint_id);
199 Ok(())
200 }
201}
202
203pub struct FileCheckpointer<S: StateSchema> {
205 directory: std::path::PathBuf,
206 next_seq: AtomicU64,
207 _phantom: std::marker::PhantomData<S>,
208}
209
210impl<S: StateSchema> FileCheckpointer<S> {
211 pub fn new(directory: impl Into<std::path::PathBuf>) -> GraphResult<Self> {
212 let dir = directory.into();
213 if !dir.exists() {
214 std::fs::create_dir_all(&dir).map_err(|e| {
215 GraphError::CheckpointError(format!(
216 "Failed to create directory '{}': {}",
217 dir.display(),
218 e
219 ))
220 })?;
221 }
222 Ok(Self {
223 directory: dir,
224 next_seq: AtomicU64::new(0),
225 _phantom: std::marker::PhantomData,
226 })
227 }
228
229 fn checkpoint_path(&self, id: &str) -> GraphResult<std::path::PathBuf> {
230 if id.contains("..") || id.contains('/') || id.contains('\\') {
232 return Err(GraphError::CheckpointError(format!(
233 "Invalid checkpoint id '{}': path traversal detected",
234 id
235 )));
236 }
237 if std::path::Path::new(id).is_absolute() {
238 return Err(GraphError::CheckpointError(format!(
239 "Invalid checkpoint id '{}': absolute path not allowed",
240 id
241 )));
242 }
243 Ok(self.directory.join(format!("{}.json", id)))
244 }
245
246 async fn sorted_ids(&self) -> GraphResult<Vec<(i64, u64, String)>> {
248 let mut items: Vec<(i64, u64, String)> = Vec::new();
249 let mut entries = tokio::fs::read_dir(&self.directory)
250 .await
251 .map_err(|e| GraphError::CheckpointError(format!("Read dir error: {}", e)))?;
252 while let Some(entry) = entries
253 .next_entry()
254 .await
255 .map_err(|e| GraphError::CheckpointError(format!("Read dir entry error: {}", e)))?
256 {
257 let path = entry.path();
258 if path.extension().is_some_and(|ext| ext == "json") {
259 let Some(id) = path.file_stem().and_then(|s| s.to_str()).map(String::from)
260 else {
261 continue;
262 };
263 let json = tokio::fs::read_to_string(&path)
264 .await
265 .map_err(|e| GraphError::CheckpointError(format!("Read error: {}", e)))?;
266 let data: CheckpointData<S> = serde_json::from_str(&json)
267 .map_err(|e| GraphError::CheckpointError(format!("Deserialize error: {}", e)))?;
268 items.push((data.timestamp, data.seq, id));
269 }
270 }
271 items.sort();
273 Ok(items)
274 }
275}
276
277#[async_trait]
284impl<S: StateSchema> Checkpointer<S> for FileCheckpointer<S> {
285 async fn save(&self, state: &S, recursion_count: usize) -> GraphResult<String> {
286 let seq = self.next_seq.fetch_add(1, Ordering::SeqCst);
287 let data = CheckpointData::with_progress(state.clone(), seq, recursion_count);
288 let id = data.id.clone();
289 let path = self.checkpoint_path(&id)?;
290
291 let json = serde_json::to_string_pretty(&data)
292 .map_err(|e| GraphError::CheckpointError(format!("Serialize error: {}", e)))?;
293
294 tokio::fs::write(&path, json)
295 .await
296 .map_err(|e| GraphError::CheckpointError(format!("Write error: {}", e)))?;
297
298 Ok(id)
299 }
300
301 async fn load(&self, checkpoint_id: &str) -> GraphResult<S> {
302 let path = self.checkpoint_path(checkpoint_id)?;
303
304 if !path.exists() {
305 return Err(GraphError::CheckpointError(format!(
306 "Checkpoint '{}' not found",
307 checkpoint_id
308 )));
309 }
310
311 let json = tokio::fs::read_to_string(&path)
312 .await
313 .map_err(|e| GraphError::CheckpointError(format!("Read error: {}", e)))?;
314
315 let data: CheckpointData<S> = serde_json::from_str(&json)
316 .map_err(|e| GraphError::CheckpointError(format!("Deserialize error: {}", e)))?;
317
318 Ok(data.state)
319 }
320
321 async fn list(&self) -> GraphResult<Vec<String>> {
322 Ok(self
323 .sorted_ids()
324 .await?
325 .into_iter()
326 .map(|(_, _, id)| id)
327 .collect())
328 }
329
330 async fn last(&self) -> GraphResult<Option<(S, usize)>> {
331 let Some((_, _, last_id)) = self.sorted_ids().await?.into_iter().last() else {
332 return Ok(None);
333 };
334 let json = tokio::fs::read_to_string(&self.checkpoint_path(&last_id)?)
335 .await
336 .map_err(|e| GraphError::CheckpointError(format!("Read error: {}", e)))?;
337 let data: CheckpointData<S> = serde_json::from_str(&json)
338 .map_err(|e| GraphError::CheckpointError(format!("Deserialize error: {}", e)))?;
339 Ok(Some((data.state, data.recursion_count)))
340 }
341
342 async fn delete(&self, checkpoint_id: &str) -> GraphResult<()> {
343 let path = self.checkpoint_path(checkpoint_id)?;
344
345 if path.exists() {
346 tokio::fs::remove_file(&path)
347 .await
348 .map_err(|e| GraphError::CheckpointError(format!("Delete error: {}", e)))?;
349 }
350
351 Ok(())
352 }
353}
354
355#[cfg(test)]
356mod tests {
357 use super::*;
358 use crate::state::AgentState;
359
360 #[tokio::test]
361 async fn test_thread_safe_checkpointer() {
362 let checkpointer = ThreadSafeMemoryCheckpointer::<AgentState>::new();
363
364 let state = AgentState::new("test".to_string());
365 let id = checkpointer.save(&state, 0).await.unwrap();
366
367 let loaded = checkpointer.load(&id).await.unwrap();
368 assert_eq!(loaded.input, "test");
369
370 let list = checkpointer.list().await.unwrap();
371 assert_eq!(list.len(), 1);
372
373 checkpointer.delete(&id).await.unwrap();
374 let list = checkpointer.list().await.unwrap();
375 assert!(list.is_empty());
376 }
377
378 #[tokio::test]
379 async fn test_file_checkpointer() {
380 let temp_dir = tempfile::tempdir().unwrap();
381 let checkpointer = FileCheckpointer::<AgentState>::new(temp_dir.path()).unwrap();
382
383 let state = AgentState::new("file_test".to_string());
384 let id = checkpointer.save(&state, 0).await.unwrap();
385
386 let loaded = checkpointer.load(&id).await.unwrap();
387 assert_eq!(loaded.input, "file_test");
388
389 let list = checkpointer.list().await.unwrap();
390 assert_eq!(list.len(), 1);
391
392 checkpointer.delete(&id).await.unwrap();
393 let list = checkpointer.list().await.unwrap();
394 assert!(list.is_empty());
395 }
396
397 #[tokio::test]
398 async fn test_file_checkpointer_multiple() {
399 let temp_dir = tempfile::tempdir().unwrap();
400 let checkpointer = FileCheckpointer::<AgentState>::new(temp_dir.path()).unwrap();
401
402 let id1 = checkpointer
403 .save(&AgentState::new("state1".to_string()), 0)
404 .await
405 .unwrap();
406 let id2 = checkpointer
407 .save(&AgentState::new("state2".to_string()), 0)
408 .await
409 .unwrap();
410 let _id3 = checkpointer
411 .save(&AgentState::new("state3".to_string()), 0)
412 .await
413 .unwrap();
414
415 let list = checkpointer.list().await.unwrap();
416 assert_eq!(list.len(), 3);
417
418 let loaded = checkpointer.load(&id2).await.unwrap();
419 assert_eq!(loaded.input, "state2");
420
421 checkpointer.delete(&id1).await.unwrap();
422 let list = checkpointer.list().await.unwrap();
423 assert_eq!(list.len(), 2);
424 }
425
426 #[tokio::test]
427 async fn test_file_checkpointer_path_traversal() {
428 let temp_dir = tempfile::tempdir().unwrap();
429 let checkpointer = FileCheckpointer::<AgentState>::new(temp_dir.path()).unwrap();
430
431 let result = checkpointer.load("..").await;
433 assert!(result.is_err());
434
435 let result = checkpointer.load("../etc/passwd").await;
436 assert!(result.is_err());
437 }
438
439 #[tokio::test]
440 async fn test_list_orders_oldest_to_newest() {
441 let checkpointer = ThreadSafeMemoryCheckpointer::<AgentState>::new();
442 checkpointer
443 .save(&AgentState::new("first".to_string()), 0)
444 .await
445 .unwrap();
446 checkpointer
447 .save(&AgentState::new("second".to_string()), 1)
448 .await
449 .unwrap();
450 checkpointer
451 .save(&AgentState::new("third".to_string()), 2)
452 .await
453 .unwrap();
454
455 let list = checkpointer.list().await.unwrap();
456 assert_eq!(list.len(), 3);
457 let (state, _) = checkpointer.last().await.unwrap().unwrap();
459 assert_eq!(state.input, "third");
460 }
461
462 #[tokio::test]
463 async fn test_last_returns_recursion_count() {
464 let checkpointer = ThreadSafeMemoryCheckpointer::<AgentState>::new();
465 checkpointer
466 .save(&AgentState::new("a".to_string()), 7)
467 .await
468 .unwrap();
469 checkpointer
470 .save(&AgentState::new("b".to_string()), 12)
471 .await
472 .unwrap();
473
474 let (state, recursion_count) = checkpointer.last().await.unwrap().unwrap();
476 assert_eq!(state.input, "b");
477 assert_eq!(recursion_count, 12);
478 }
479
480 #[tokio::test]
481 async fn test_file_checkpointer_last_orders_by_save() {
482 let temp_dir = tempfile::tempdir().unwrap();
483 let checkpointer = FileCheckpointer::<AgentState>::new(temp_dir.path()).unwrap();
484 checkpointer
485 .save(&AgentState::new("one".to_string()), 1)
486 .await
487 .unwrap();
488 checkpointer
489 .save(&AgentState::new("two".to_string()), 2)
490 .await
491 .unwrap();
492
493 let list = checkpointer.list().await.unwrap();
494 assert_eq!(list.len(), 2);
495 let (state, recursion_count) = checkpointer.last().await.unwrap().unwrap();
496 assert_eq!(state.input, "two");
497 assert_eq!(recursion_count, 2);
498 }
499}