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 async fn update_state(
41 &self,
42 checkpoint_id: &str,
43 state: &S,
44 expected_version: u64,
45 ) -> GraphResult<u64> {
46 let _ = (state, expected_version);
47 Err(GraphError::CheckpointError(format!(
48 "update_state is not supported on checkpoint '{checkpoint_id}' by this checkpointer",
49 )))
50 }
51}
52
53#[derive(Debug, Clone, Serialize, Deserialize)]
55#[serde(bound = "S: StateSchema")]
56pub struct CheckpointData<S: StateSchema> {
57 pub id: String,
59 pub state: S,
61 pub timestamp: i64,
63 pub metadata: HashMap<String, serde_json::Value>,
65 #[serde(default)]
69 pub seq: u64,
70 #[serde(default)]
74 pub recursion_count: usize,
75 #[serde(default = "initial_version")]
79 pub version: u64,
80}
81
82fn initial_version() -> u64 {
84 1
85}
86
87impl<S: StateSchema> CheckpointData<S> {
88 pub fn new(state: S) -> Self {
90 Self {
91 id: Uuid::new_v4().to_string(),
92 state,
93 timestamp: chrono::Utc::now().timestamp(),
94 metadata: HashMap::new(),
95 seq: 0,
96 recursion_count: 0,
97 version: 1,
98 }
99 }
100
101 pub fn with_progress(state: S, seq: u64, recursion_count: usize) -> Self {
104 let mut data = Self::new(state);
105 data.seq = seq;
106 data.recursion_count = recursion_count;
107 data
108 }
109}
110
111pub struct MemoryCheckpointer<S: StateSchema> {
113 checkpoints: Mutex<HashMap<String, CheckpointData<S>>>,
114 next_seq: AtomicU64,
115}
116
117impl<S: StateSchema> MemoryCheckpointer<S> {
118 pub fn new() -> Self {
120 Self {
121 checkpoints: Mutex::new(HashMap::new()),
122 next_seq: AtomicU64::new(0),
123 }
124 }
125}
126
127impl<S: StateSchema> Default for MemoryCheckpointer<S> {
128 fn default() -> Self {
129 Self::new()
130 }
131}
132
133#[async_trait]
134impl<S: StateSchema> Checkpointer<S> for MemoryCheckpointer<S> {
135 async fn save(&self, state: &S, recursion_count: usize) -> GraphResult<String> {
136 let seq = self.next_seq.fetch_add(1, Ordering::SeqCst);
137 let data = CheckpointData::with_progress(state.clone(), seq, recursion_count);
138 let id = data.id.clone();
139 self.checkpoints.lock().await.insert(id.clone(), data);
140 Ok(id)
141 }
142
143 async fn load(&self, checkpoint_id: &str) -> GraphResult<S> {
144 self.checkpoints
145 .lock()
146 .await
147 .get(checkpoint_id)
148 .map(|d| d.state.clone())
149 .ok_or_else(|| {
150 GraphError::CheckpointError(format!("Checkpoint '{}' not found", checkpoint_id))
151 })
152 }
153
154 async fn list(&self) -> GraphResult<Vec<String>> {
155 let guard = self.checkpoints.lock().await;
156 let mut items: Vec<(i64, u64, String)> = guard
157 .values()
158 .map(|d| (d.timestamp, d.seq, d.id.clone()))
159 .collect();
160 items.sort();
163 Ok(items.into_iter().map(|(_, _, id)| id).collect())
164 }
165
166 async fn last(&self) -> GraphResult<Option<(S, usize)>> {
167 let guard = self.checkpoints.lock().await;
168 Ok(guard
169 .values()
170 .max_by_key(|d| (d.timestamp, d.seq))
171 .map(|d| (d.state.clone(), d.recursion_count)))
172 }
173
174 async fn update_state(
175 &self,
176 checkpoint_id: &str,
177 state: &S,
178 expected_version: u64,
179 ) -> GraphResult<u64> {
180 update_locked(&self.checkpoints, checkpoint_id, state, expected_version).await
181 }
182
183 async fn delete(&self, checkpoint_id: &str) -> GraphResult<()> {
184 self.checkpoints.lock().await.remove(checkpoint_id);
185 Ok(())
186 }
187}
188
189async fn update_locked<S: StateSchema>(
194 checkpoints: &Mutex<HashMap<String, CheckpointData<S>>>,
195 checkpoint_id: &str,
196 state: &S,
197 expected_version: u64,
198) -> GraphResult<u64> {
199 let mut guard = checkpoints.lock().await;
200 let data = guard.get_mut(checkpoint_id).ok_or_else(|| {
201 GraphError::CheckpointError(format!("Checkpoint '{checkpoint_id}' not found"))
202 })?;
203 if data.version != expected_version {
204 return Err(GraphError::CheckpointVersionConflict {
205 checkpoint_id: checkpoint_id.to_string(),
206 expected: expected_version,
207 actual: data.version,
208 });
209 }
210 data.state = state.clone();
211 data.version += 1;
212 Ok(data.version)
213}
214
215pub struct ThreadSafeMemoryCheckpointer<S: StateSchema> {
217 checkpoints: Mutex<HashMap<String, CheckpointData<S>>>,
218 next_seq: AtomicU64,
219}
220
221impl<S: StateSchema> ThreadSafeMemoryCheckpointer<S> {
222 pub fn new() -> Self {
224 Self {
225 checkpoints: Mutex::new(HashMap::new()),
226 next_seq: AtomicU64::new(0),
227 }
228 }
229}
230
231impl<S: StateSchema> Default for ThreadSafeMemoryCheckpointer<S> {
232 fn default() -> Self {
233 Self::new()
234 }
235}
236
237#[async_trait]
238impl<S: StateSchema> Checkpointer<S> for ThreadSafeMemoryCheckpointer<S> {
239 async fn save(&self, state: &S, recursion_count: usize) -> GraphResult<String> {
240 let seq = self.next_seq.fetch_add(1, Ordering::SeqCst);
241 let data = CheckpointData::with_progress(state.clone(), seq, recursion_count);
242 let id = data.id.clone();
243 self.checkpoints.lock().await.insert(id.clone(), data);
244 Ok(id)
245 }
246
247 async fn load(&self, checkpoint_id: &str) -> GraphResult<S> {
248 let checkpoints = self.checkpoints.lock().await;
249 checkpoints
250 .get(checkpoint_id)
251 .map(|d| d.state.clone())
252 .ok_or_else(|| {
253 GraphError::CheckpointError(format!("Checkpoint '{}' not found", checkpoint_id))
254 })
255 }
256
257 async fn list(&self) -> GraphResult<Vec<String>> {
258 let guard = self.checkpoints.lock().await;
259 let mut items: Vec<(i64, u64, String)> = guard
260 .values()
261 .map(|d| (d.timestamp, d.seq, d.id.clone()))
262 .collect();
263 items.sort();
265 Ok(items.into_iter().map(|(_, _, id)| id).collect())
266 }
267
268 async fn last(&self) -> GraphResult<Option<(S, usize)>> {
269 let guard = self.checkpoints.lock().await;
270 Ok(guard
271 .values()
272 .max_by_key(|d| (d.timestamp, d.seq))
273 .map(|d| (d.state.clone(), d.recursion_count)))
274 }
275
276 async fn update_state(
277 &self,
278 checkpoint_id: &str,
279 state: &S,
280 expected_version: u64,
281 ) -> GraphResult<u64> {
282 update_locked(&self.checkpoints, checkpoint_id, state, expected_version).await
283 }
284
285 async fn delete(&self, checkpoint_id: &str) -> GraphResult<()> {
286 self.checkpoints.lock().await.remove(checkpoint_id);
287 Ok(())
288 }
289}
290
291pub struct FileCheckpointer<S: StateSchema> {
293 directory: std::path::PathBuf,
294 next_seq: AtomicU64,
295 update_lock: Mutex<()>,
299 _phantom: std::marker::PhantomData<S>,
300}
301
302impl<S: StateSchema> FileCheckpointer<S> {
303 pub fn new(directory: impl Into<std::path::PathBuf>) -> GraphResult<Self> {
305 let dir = directory.into();
306 if !dir.exists() {
307 std::fs::create_dir_all(&dir).map_err(|e| {
308 GraphError::CheckpointError(format!(
309 "Failed to create directory '{}': {}",
310 dir.display(),
311 e
312 ))
313 })?;
314 }
315 Ok(Self {
316 directory: dir,
317 next_seq: AtomicU64::new(0),
318 update_lock: Mutex::new(()),
319 _phantom: std::marker::PhantomData,
320 })
321 }
322
323 fn checkpoint_path(&self, id: &str) -> GraphResult<std::path::PathBuf> {
324 if id.contains("..") || id.contains('/') || id.contains('\\') {
326 return Err(GraphError::CheckpointError(format!(
327 "Invalid checkpoint id '{}': path traversal detected",
328 id
329 )));
330 }
331 if std::path::Path::new(id).is_absolute() {
332 return Err(GraphError::CheckpointError(format!(
333 "Invalid checkpoint id '{}': absolute path not allowed",
334 id
335 )));
336 }
337 Ok(self.directory.join(format!("{}.json", id)))
338 }
339
340 async fn sorted_ids(&self) -> GraphResult<Vec<(i64, u64, String)>> {
342 let mut items: Vec<(i64, u64, String)> = Vec::new();
343 let mut entries = tokio::fs::read_dir(&self.directory)
344 .await
345 .map_err(|e| GraphError::CheckpointError(format!("Read dir error: {}", e)))?;
346 while let Some(entry) = entries
347 .next_entry()
348 .await
349 .map_err(|e| GraphError::CheckpointError(format!("Read dir entry error: {}", e)))?
350 {
351 let path = entry.path();
352 if path.extension().is_some_and(|ext| ext == "json") {
353 let Some(id) = path.file_stem().and_then(|s| s.to_str()).map(String::from) else {
354 continue;
355 };
356 let json = tokio::fs::read_to_string(&path)
357 .await
358 .map_err(|e| GraphError::CheckpointError(format!("Read error: {}", e)))?;
359 let data: CheckpointData<S> = serde_json::from_str(&json).map_err(|e| {
360 GraphError::CheckpointError(format!("Deserialize error: {}", e))
361 })?;
362 items.push((data.timestamp, data.seq, id));
363 }
364 }
365 items.sort();
367 Ok(items)
368 }
369}
370
371#[async_trait]
378impl<S: StateSchema> Checkpointer<S> for FileCheckpointer<S> {
379 async fn save(&self, state: &S, recursion_count: usize) -> GraphResult<String> {
380 let seq = self.next_seq.fetch_add(1, Ordering::SeqCst);
381 let data = CheckpointData::with_progress(state.clone(), seq, recursion_count);
382 let id = data.id.clone();
383 let path = self.checkpoint_path(&id)?;
384
385 let json = serde_json::to_string_pretty(&data)
386 .map_err(|e| GraphError::CheckpointError(format!("Serialize error: {}", e)))?;
387
388 let tmp_path = self.directory.join(format!("{id}.json.tmp"));
392 tokio::fs::write(&tmp_path, &json)
393 .await
394 .map_err(|e| GraphError::CheckpointError(format!("Write error: {}", e)))?;
395 tokio::fs::rename(&tmp_path, &path)
396 .await
397 .map_err(|e| GraphError::CheckpointError(format!("Atomic rename error: {}", e)))?;
398
399 Ok(id)
400 }
401
402 async fn load(&self, checkpoint_id: &str) -> GraphResult<S> {
403 let path = self.checkpoint_path(checkpoint_id)?;
404
405 if !path.exists() {
406 return Err(GraphError::CheckpointError(format!(
407 "Checkpoint '{}' not found",
408 checkpoint_id
409 )));
410 }
411
412 let json = tokio::fs::read_to_string(&path)
413 .await
414 .map_err(|e| GraphError::CheckpointError(format!("Read error: {}", e)))?;
415
416 let data: CheckpointData<S> = serde_json::from_str(&json)
417 .map_err(|e| GraphError::CheckpointError(format!("Deserialize error: {}", e)))?;
418
419 Ok(data.state)
420 }
421
422 async fn list(&self) -> GraphResult<Vec<String>> {
423 Ok(self
424 .sorted_ids()
425 .await?
426 .into_iter()
427 .map(|(_, _, id)| id)
428 .collect())
429 }
430
431 async fn last(&self) -> GraphResult<Option<(S, usize)>> {
432 let Some((_, _, last_id)) = self.sorted_ids().await?.into_iter().last() else {
433 return Ok(None);
434 };
435 let json = tokio::fs::read_to_string(&self.checkpoint_path(&last_id)?)
436 .await
437 .map_err(|e| GraphError::CheckpointError(format!("Read error: {}", e)))?;
438 let data: CheckpointData<S> = serde_json::from_str(&json)
439 .map_err(|e| GraphError::CheckpointError(format!("Deserialize error: {}", e)))?;
440 Ok(Some((data.state, data.recursion_count)))
441 }
442
443 async fn update_state(
444 &self,
445 checkpoint_id: &str,
446 state: &S,
447 expected_version: u64,
448 ) -> GraphResult<u64> {
449 let _guard = self.update_lock.lock().await;
450 let path = self.checkpoint_path(checkpoint_id)?;
451 let json = tokio::fs::read_to_string(&path)
452 .await
453 .map_err(|e| GraphError::CheckpointError(format!("Read error: {}", e)))?;
454 let mut data: CheckpointData<S> = serde_json::from_str(&json)
455 .map_err(|e| GraphError::CheckpointError(format!("Deserialize error: {}", e)))?;
456 if data.version != expected_version {
457 return Err(GraphError::CheckpointVersionConflict {
458 checkpoint_id: checkpoint_id.to_string(),
459 expected: expected_version,
460 actual: data.version,
461 });
462 }
463 data.state = state.clone();
464 data.version += 1;
465
466 let json = serde_json::to_string_pretty(&data)
467 .map_err(|e| GraphError::CheckpointError(format!("Serialize error: {}", e)))?;
468 let tmp_path = self.directory.join(format!("{checkpoint_id}.json.tmp"));
469 tokio::fs::write(&tmp_path, &json)
470 .await
471 .map_err(|e| GraphError::CheckpointError(format!("Write error: {}", e)))?;
472 tokio::fs::rename(&tmp_path, &path)
473 .await
474 .map_err(|e| GraphError::CheckpointError(format!("Atomic rename error: {}", e)))?;
475 Ok(data.version)
476 }
477
478 async fn delete(&self, checkpoint_id: &str) -> GraphResult<()> {
479 let path = self.checkpoint_path(checkpoint_id)?;
480
481 if path.exists() {
482 tokio::fs::remove_file(&path)
483 .await
484 .map_err(|e| GraphError::CheckpointError(format!("Delete error: {}", e)))?;
485 }
486
487 Ok(())
488 }
489}
490
491#[cfg(test)]
492mod tests {
493 use super::*;
494 use crate::state::AgentState;
495
496 #[tokio::test]
497 async fn test_thread_safe_checkpointer() {
498 let checkpointer = ThreadSafeMemoryCheckpointer::<AgentState>::new();
499
500 let state = AgentState::new("test".to_string());
501 let id = checkpointer.save(&state, 0).await.unwrap();
502
503 let loaded = checkpointer.load(&id).await.unwrap();
504 assert_eq!(loaded.input, "test");
505
506 let list = checkpointer.list().await.unwrap();
507 assert_eq!(list.len(), 1);
508
509 checkpointer.delete(&id).await.unwrap();
510 let list = checkpointer.list().await.unwrap();
511 assert!(list.is_empty());
512 }
513
514 #[tokio::test]
515 async fn test_file_checkpointer() {
516 let temp_dir = tempfile::tempdir().unwrap();
517 let checkpointer = FileCheckpointer::<AgentState>::new(temp_dir.path()).unwrap();
518
519 let state = AgentState::new("file_test".to_string());
520 let id = checkpointer.save(&state, 0).await.unwrap();
521
522 let loaded = checkpointer.load(&id).await.unwrap();
523 assert_eq!(loaded.input, "file_test");
524
525 let list = checkpointer.list().await.unwrap();
526 assert_eq!(list.len(), 1);
527
528 checkpointer.delete(&id).await.unwrap();
529 let list = checkpointer.list().await.unwrap();
530 assert!(list.is_empty());
531 }
532
533 #[tokio::test]
534 async fn test_file_checkpointer_atomic_write() {
535 let temp_dir = tempfile::tempdir().unwrap();
536 let checkpointer = FileCheckpointer::<AgentState>::new(temp_dir.path()).unwrap();
537
538 let id = checkpointer
539 .save(&AgentState::new("atomic".to_string()), 0)
540 .await
541 .unwrap();
542
543 let main = temp_dir.path().join(format!("{id}.json"));
545 assert!(main.exists(), "checkpoint file must exist");
546 let json = tokio::fs::read_to_string(&main).await.unwrap();
547 assert!(
548 serde_json::from_str::<CheckpointData<AgentState>>(&json).is_ok(),
549 "checkpoint file must be complete JSON after atomic write"
550 );
551 assert!(
552 !temp_dir.path().join(format!("{id}.json.tmp")).exists(),
553 "tmp file must be renamed away, not left behind"
554 );
555
556 std::fs::write(temp_dir.path().join("stale.json.tmp"), b"{}").unwrap();
558 let list = checkpointer.list().await.unwrap();
559 assert_eq!(list, vec![id]);
560 }
561
562 #[tokio::test]
563 async fn test_file_checkpointer_multiple() {
564 let temp_dir = tempfile::tempdir().unwrap();
565 let checkpointer = FileCheckpointer::<AgentState>::new(temp_dir.path()).unwrap();
566
567 let id1 = checkpointer
568 .save(&AgentState::new("state1".to_string()), 0)
569 .await
570 .unwrap();
571 let id2 = checkpointer
572 .save(&AgentState::new("state2".to_string()), 0)
573 .await
574 .unwrap();
575 let _id3 = checkpointer
576 .save(&AgentState::new("state3".to_string()), 0)
577 .await
578 .unwrap();
579
580 let list = checkpointer.list().await.unwrap();
581 assert_eq!(list.len(), 3);
582
583 let loaded = checkpointer.load(&id2).await.unwrap();
584 assert_eq!(loaded.input, "state2");
585
586 checkpointer.delete(&id1).await.unwrap();
587 let list = checkpointer.list().await.unwrap();
588 assert_eq!(list.len(), 2);
589 }
590
591 #[tokio::test]
592 async fn test_file_checkpointer_path_traversal() {
593 let temp_dir = tempfile::tempdir().unwrap();
594 let checkpointer = FileCheckpointer::<AgentState>::new(temp_dir.path()).unwrap();
595
596 let result = checkpointer.load("..").await;
598 assert!(result.is_err());
599
600 let result = checkpointer.load("../etc/passwd").await;
601 assert!(result.is_err());
602 }
603
604 #[tokio::test]
605 async fn test_list_orders_oldest_to_newest() {
606 let checkpointer = ThreadSafeMemoryCheckpointer::<AgentState>::new();
607 checkpointer
608 .save(&AgentState::new("first".to_string()), 0)
609 .await
610 .unwrap();
611 checkpointer
612 .save(&AgentState::new("second".to_string()), 1)
613 .await
614 .unwrap();
615 checkpointer
616 .save(&AgentState::new("third".to_string()), 2)
617 .await
618 .unwrap();
619
620 let list = checkpointer.list().await.unwrap();
621 assert_eq!(list.len(), 3);
622 let (state, _) = checkpointer.last().await.unwrap().unwrap();
624 assert_eq!(state.input, "third");
625 }
626
627 #[tokio::test]
628 async fn test_last_returns_recursion_count() {
629 let checkpointer = ThreadSafeMemoryCheckpointer::<AgentState>::new();
630 checkpointer
631 .save(&AgentState::new("a".to_string()), 7)
632 .await
633 .unwrap();
634 checkpointer
635 .save(&AgentState::new("b".to_string()), 12)
636 .await
637 .unwrap();
638
639 let (state, recursion_count) = checkpointer.last().await.unwrap().unwrap();
641 assert_eq!(state.input, "b");
642 assert_eq!(recursion_count, 12);
643 }
644
645 #[tokio::test]
646 async fn test_update_state_occ_memory() {
647 let checkpointer = ThreadSafeMemoryCheckpointer::<AgentState>::new();
648 let id = checkpointer
649 .save(&AgentState::new("v1".to_string()), 0)
650 .await
651 .unwrap();
652
653 let version = checkpointer
655 .update_state(&id, &AgentState::new("v2".to_string()), 1)
656 .await
657 .unwrap();
658 assert_eq!(version, 2);
659 assert_eq!(checkpointer.load(&id).await.unwrap().input, "v2");
660
661 let conflict = checkpointer
663 .update_state(&id, &AgentState::new("v3-stale".to_string()), 1)
664 .await
665 .unwrap_err();
666 match conflict {
667 GraphError::CheckpointVersionConflict {
668 expected, actual, ..
669 } => {
670 assert_eq!(expected, 1);
671 assert_eq!(actual, 2);
672 }
673 other => panic!("expected CheckpointVersionConflict, got {other:?}"),
674 }
675 assert_eq!(checkpointer.load(&id).await.unwrap().input, "v2");
676
677 checkpointer
679 .update_state(&id, &AgentState::new("v3".to_string()), 2)
680 .await
681 .unwrap();
682 assert_eq!(checkpointer.load(&id).await.unwrap().input, "v3");
683 }
684
685 #[tokio::test]
686 async fn test_update_state_missing_and_file() {
687 let temp_dir = tempfile::tempdir().unwrap();
688 let checkpointer = FileCheckpointer::<AgentState>::new(temp_dir.path()).unwrap();
689 let id = checkpointer
690 .save(&AgentState::new("disk-v1".to_string()), 0)
691 .await
692 .unwrap();
693 checkpointer
694 .update_state(&id, &AgentState::new("disk-v2".to_string()), 1)
695 .await
696 .unwrap();
697 assert_eq!(checkpointer.load(&id).await.unwrap().input, "disk-v2");
698 assert!(checkpointer
700 .update_state(&id, &AgentState::new("stale".to_string()), 1)
701 .await
702 .is_err());
703 assert!(checkpointer
705 .update_state("missing", &AgentState::new("x".to_string()), 1)
706 .await
707 .is_err());
708 }
709
710 #[tokio::test]
711 async fn test_file_checkpointer_last_orders_by_save() {
712 let temp_dir = tempfile::tempdir().unwrap();
713 let checkpointer = FileCheckpointer::<AgentState>::new(temp_dir.path()).unwrap();
714 checkpointer
715 .save(&AgentState::new("one".to_string()), 1)
716 .await
717 .unwrap();
718 checkpointer
719 .save(&AgentState::new("two".to_string()), 2)
720 .await
721 .unwrap();
722
723 let list = checkpointer.list().await.unwrap();
724 assert_eq!(list.len(), 2);
725 let (state, recursion_count) = checkpointer.last().await.unwrap().unwrap();
726 assert_eq!(state.input, "two");
727 assert_eq!(recursion_count, 2);
728 }
729}