lc_langgraph/
checkpointer.rs1use crate::errors::{GraphError, GraphResult};
5use crate::state::StateSchema;
6use async_trait::async_trait;
7use serde::{Deserialize, Serialize};
8use std::collections::HashMap;
9use tokio::sync::Mutex;
10use uuid::Uuid;
11
12#[async_trait]
14pub trait Checkpointer<S: StateSchema>: Send + Sync {
15 async fn save(&self, state: &S) -> GraphResult<String>;
16 async fn load(&self, checkpoint_id: &str) -> GraphResult<S>;
17 async fn list(&self) -> GraphResult<Vec<String>>;
18 async fn delete(&self, checkpoint_id: &str) -> GraphResult<()>;
19}
20
21#[derive(Debug, Clone, Serialize, Deserialize)]
23#[serde(bound = "S: StateSchema")]
24pub struct CheckpointData<S: StateSchema> {
25 pub id: String,
26 pub state: S,
27 pub timestamp: i64,
28 pub metadata: HashMap<String, serde_json::Value>,
29}
30
31impl<S: StateSchema> CheckpointData<S> {
32 pub fn new(state: S) -> Self {
33 Self {
34 id: Uuid::new_v4().to_string(),
35 state,
36 timestamp: chrono::Utc::now().timestamp(),
37 metadata: HashMap::new(),
38 }
39 }
40}
41
42pub struct MemoryCheckpointer<S: StateSchema> {
44 checkpoints: Mutex<HashMap<String, CheckpointData<S>>>,
45}
46
47impl<S: StateSchema> MemoryCheckpointer<S> {
48 pub fn new() -> Self {
49 Self {
50 checkpoints: Mutex::new(HashMap::new()),
51 }
52 }
53}
54
55impl<S: StateSchema> Default for MemoryCheckpointer<S> {
56 fn default() -> Self {
57 Self::new()
58 }
59}
60
61#[async_trait]
62impl<S: StateSchema> Checkpointer<S> for MemoryCheckpointer<S> {
63 async fn save(&self, state: &S) -> GraphResult<String> {
64 let data = CheckpointData::new(state.clone());
65 let id = data.id.clone();
66 self.checkpoints.lock().await.insert(id.clone(), data);
67 Ok(id)
68 }
69
70 async fn load(&self, checkpoint_id: &str) -> GraphResult<S> {
71 self.checkpoints
72 .lock()
73 .await
74 .get(checkpoint_id)
75 .map(|d| d.state.clone())
76 .ok_or_else(|| {
77 GraphError::CheckpointError(format!("Checkpoint '{}' not found", checkpoint_id))
78 })
79 }
80
81 async fn list(&self) -> GraphResult<Vec<String>> {
82 Ok(self.checkpoints.lock().await.keys().cloned().collect())
83 }
84
85 async fn delete(&self, checkpoint_id: &str) -> GraphResult<()> {
86 self.checkpoints.lock().await.remove(checkpoint_id);
87 Ok(())
88 }
89}
90
91pub struct ThreadSafeMemoryCheckpointer<S: StateSchema> {
93 checkpoints: std::sync::Mutex<HashMap<String, CheckpointData<S>>>,
94}
95
96impl<S: StateSchema> ThreadSafeMemoryCheckpointer<S> {
97 pub fn new() -> Self {
98 Self {
99 checkpoints: std::sync::Mutex::new(HashMap::new()),
100 }
101 }
102}
103
104impl<S: StateSchema> Default for ThreadSafeMemoryCheckpointer<S> {
105 fn default() -> Self {
106 Self::new()
107 }
108}
109
110#[async_trait]
111impl<S: StateSchema> Checkpointer<S> for ThreadSafeMemoryCheckpointer<S> {
112 async fn save(&self, state: &S) -> GraphResult<String> {
113 let data = CheckpointData::new(state.clone());
114 let id = data.id.clone();
115 self.checkpoints
116 .lock()
117 .unwrap_or_else(|e| e.into_inner())
118 .insert(id.clone(), data);
119 Ok(id)
120 }
121
122 async fn load(&self, checkpoint_id: &str) -> GraphResult<S> {
123 let checkpoints = self.checkpoints.lock().unwrap_or_else(|e| e.into_inner());
124 checkpoints
125 .get(checkpoint_id)
126 .map(|d| d.state.clone())
127 .ok_or_else(|| {
128 GraphError::CheckpointError(format!("Checkpoint '{}' not found", checkpoint_id))
129 })
130 }
131
132 async fn list(&self) -> GraphResult<Vec<String>> {
133 Ok(self
134 .checkpoints
135 .lock()
136 .unwrap_or_else(|e| e.into_inner())
137 .keys()
138 .cloned()
139 .collect())
140 }
141
142 async fn delete(&self, checkpoint_id: &str) -> GraphResult<()> {
143 self.checkpoints
144 .lock()
145 .unwrap_or_else(|e| e.into_inner())
146 .remove(checkpoint_id);
147 Ok(())
148 }
149}
150
151pub struct FileCheckpointer<S: StateSchema> {
153 directory: std::path::PathBuf,
154 _phantom: std::marker::PhantomData<S>,
155}
156
157impl<S: StateSchema> FileCheckpointer<S> {
158 pub fn new(directory: impl Into<std::path::PathBuf>) -> GraphResult<Self> {
159 let dir = directory.into();
160 if !dir.exists() {
161 std::fs::create_dir_all(&dir).map_err(|e| {
162 GraphError::CheckpointError(format!(
163 "Failed to create directory '{}': {}",
164 dir.display(),
165 e
166 ))
167 })?;
168 }
169 Ok(Self {
170 directory: dir,
171 _phantom: std::marker::PhantomData,
172 })
173 }
174
175 fn checkpoint_path(&self, id: &str) -> GraphResult<std::path::PathBuf> {
176 if id.contains("..") || id.contains('/') || id.contains('\\') {
178 return Err(GraphError::CheckpointError(format!(
179 "Invalid checkpoint id '{}': path traversal detected",
180 id
181 )));
182 }
183 if std::path::Path::new(id).is_absolute() {
184 return Err(GraphError::CheckpointError(format!(
185 "Invalid checkpoint id '{}': absolute path not allowed",
186 id
187 )));
188 }
189 Ok(self.directory.join(format!("{}.json", id)))
190 }
191}
192
193impl<S: StateSchema> Default for FileCheckpointer<S> {
194 fn default() -> Self {
195 Self::new(".checkpoints").expect("Failed to create default checkpoint directory")
196 }
197}
198
199#[async_trait]
200impl<S: StateSchema> Checkpointer<S> for FileCheckpointer<S> {
201 async fn save(&self, state: &S) -> GraphResult<String> {
202 let data = CheckpointData::new(state.clone());
203 let id = data.id.clone();
204 let path = self.checkpoint_path(&id)?;
205
206 let json = serde_json::to_string_pretty(&data)
207 .map_err(|e| GraphError::CheckpointError(format!("Serialize error: {}", e)))?;
208
209 tokio::fs::write(&path, json)
210 .await
211 .map_err(|e| GraphError::CheckpointError(format!("Write error: {}", e)))?;
212
213 Ok(id)
214 }
215
216 async fn load(&self, checkpoint_id: &str) -> GraphResult<S> {
217 let path = self.checkpoint_path(checkpoint_id)?;
218
219 if !path.exists() {
220 return Err(GraphError::CheckpointError(format!(
221 "Checkpoint '{}' not found",
222 checkpoint_id
223 )));
224 }
225
226 let json = tokio::fs::read_to_string(&path)
227 .await
228 .map_err(|e| GraphError::CheckpointError(format!("Read error: {}", e)))?;
229
230 let data: CheckpointData<S> = serde_json::from_str(&json)
231 .map_err(|e| GraphError::CheckpointError(format!("Deserialize error: {}", e)))?;
232
233 Ok(data.state)
234 }
235
236 async fn list(&self) -> GraphResult<Vec<String>> {
237 let mut ids = Vec::new();
238
239 let entries = tokio::fs::read_dir(&self.directory)
240 .await
241 .map_err(|e| GraphError::CheckpointError(format!("Read dir error: {}", e)))?;
242
243 let mut entries = entries;
244 while let Some(entry) = entries
245 .next_entry()
246 .await
247 .map_err(|e| GraphError::CheckpointError(format!("Read dir entry error: {}", e)))?
248 {
249 let path = entry.path();
250 if path.extension().is_some_and(|ext| ext == "json") {
251 if let Some(id) = path.file_stem().and_then(|s| s.to_str()) {
252 ids.push(id.to_string());
253 }
254 }
255 }
256
257 Ok(ids)
258 }
259
260 async fn delete(&self, checkpoint_id: &str) -> GraphResult<()> {
261 let path = self.checkpoint_path(checkpoint_id)?;
262
263 if path.exists() {
264 tokio::fs::remove_file(&path)
265 .await
266 .map_err(|e| GraphError::CheckpointError(format!("Delete error: {}", e)))?;
267 }
268
269 Ok(())
270 }
271}
272
273#[cfg(test)]
274mod tests {
275 use super::*;
276 use crate::state::AgentState;
277
278 #[tokio::test]
279 async fn test_thread_safe_checkpointer() {
280 let checkpointer = ThreadSafeMemoryCheckpointer::<AgentState>::new();
281
282 let state = AgentState::new("test".to_string());
283 let id = checkpointer.save(&state).await.unwrap();
284
285 let loaded = checkpointer.load(&id).await.unwrap();
286 assert_eq!(loaded.input, "test");
287
288 let list = checkpointer.list().await.unwrap();
289 assert_eq!(list.len(), 1);
290
291 checkpointer.delete(&id).await.unwrap();
292 let list = checkpointer.list().await.unwrap();
293 assert!(list.is_empty());
294 }
295
296 #[tokio::test]
297 async fn test_file_checkpointer() {
298 let temp_dir = tempfile::tempdir().unwrap();
299 let checkpointer = FileCheckpointer::<AgentState>::new(temp_dir.path()).unwrap();
300
301 let state = AgentState::new("file_test".to_string());
302 let id = checkpointer.save(&state).await.unwrap();
303
304 let loaded = checkpointer.load(&id).await.unwrap();
305 assert_eq!(loaded.input, "file_test");
306
307 let list = checkpointer.list().await.unwrap();
308 assert_eq!(list.len(), 1);
309
310 checkpointer.delete(&id).await.unwrap();
311 let list = checkpointer.list().await.unwrap();
312 assert!(list.is_empty());
313 }
314
315 #[tokio::test]
316 async fn test_file_checkpointer_multiple() {
317 let temp_dir = tempfile::tempdir().unwrap();
318 let checkpointer = FileCheckpointer::<AgentState>::new(temp_dir.path()).unwrap();
319
320 let id1 = checkpointer
321 .save(&AgentState::new("state1".to_string()))
322 .await
323 .unwrap();
324 let id2 = checkpointer
325 .save(&AgentState::new("state2".to_string()))
326 .await
327 .unwrap();
328 let _id3 = checkpointer
329 .save(&AgentState::new("state3".to_string()))
330 .await
331 .unwrap();
332
333 let list = checkpointer.list().await.unwrap();
334 assert_eq!(list.len(), 3);
335
336 let loaded = checkpointer.load(&id2).await.unwrap();
337 assert_eq!(loaded.input, "state2");
338
339 checkpointer.delete(&id1).await.unwrap();
340 let list = checkpointer.list().await.unwrap();
341 assert_eq!(list.len(), 2);
342 }
343
344 #[tokio::test]
345 async fn test_file_checkpointer_path_traversal() {
346 let temp_dir = tempfile::tempdir().unwrap();
347 let checkpointer = FileCheckpointer::<AgentState>::new(temp_dir.path()).unwrap();
348
349 let result = checkpointer.load("..").await;
351 assert!(result.is_err());
352
353 let result = checkpointer.load("../etc/passwd").await;
354 assert!(result.is_err());
355 }
356}