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