1use crate::error::AgentRuntimeError;
16use crate::util::djb2;
17use async_trait::async_trait;
18use futures::future::try_join_all;
19use std::path::PathBuf;
20use std::sync::Arc;
21use uuid::Uuid;
22
23#[async_trait]
30pub trait PersistenceBackend: Send + Sync {
31 async fn save(&self, key: &str, value: &[u8]) -> Result<(), AgentRuntimeError>;
33
34 async fn load(&self, key: &str) -> Result<Option<Vec<u8>>, AgentRuntimeError>;
38
39 async fn delete(&self, key: &str) -> Result<(), AgentRuntimeError>;
41
42 async fn batch_save(&self, items: &[(&str, &[u8])]) -> Result<(), AgentRuntimeError> {
49 try_join_all(items.iter().map(|(key, value)| self.save(key, value))).await?;
50 Ok(())
51 }
52
53 async fn batch_load(&self, keys: &[&str]) -> Result<Vec<Option<Vec<u8>>>, AgentRuntimeError> {
59 try_join_all(keys.iter().map(|key| self.load(key))).await
60 }
61}
62
63#[derive(Debug, Clone)]
90pub struct FilePersistenceBackend {
91 base_dir: Arc<PathBuf>,
93}
94
95impl FilePersistenceBackend {
96 pub fn new(base_dir: impl Into<PathBuf>) -> Self {
100 Self {
101 base_dir: Arc::new(base_dir.into()),
102 }
103 }
104
105 pub fn base_dir(&self) -> &std::path::Path {
107 self.base_dir.as_ref()
108 }
109
110 fn path_for(&self, key: &str) -> PathBuf {
117 let hash = djb2(key);
118 let sanitized = key.replace(['/', '\\', ':', '*', '?', '"', '<', '>', '|'], "_");
119 self.base_dir
120 .join(format!("{sanitized}-{hash:016x}.bin"))
121 }
122}
123
124#[async_trait]
125impl PersistenceBackend for FilePersistenceBackend {
126 #[tracing::instrument(skip(self, value), fields(key))]
127 async fn save(&self, key: &str, value: &[u8]) -> Result<(), AgentRuntimeError> {
128 let path = self.path_for(key);
129 let tmp_path = path.with_extension(format!("tmp-{}", Uuid::new_v4().simple()));
133 tokio::fs::write(&tmp_path, value)
134 .await
135 .map_err(|e| AgentRuntimeError::Persistence(format!("write tmp {tmp_path:?}: {e}")))?;
136 tokio::fs::rename(&tmp_path, &path).await.map_err(|e| {
137 AgentRuntimeError::Persistence(format!(
138 "rename {tmp_path:?} -> {path:?}: {e}"
139 ))
140 })?;
141 Ok(())
142 }
143
144 #[tracing::instrument(skip(self), fields(key))]
145 async fn load(&self, key: &str) -> Result<Option<Vec<u8>>, AgentRuntimeError> {
146 let path = self.path_for(key);
147 match tokio::fs::read(&path).await {
148 Ok(bytes) => Ok(Some(bytes)),
149 Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None),
150 Err(e) => Err(AgentRuntimeError::Persistence(format!(
151 "read {path:?}: {e}"
152 ))),
153 }
154 }
155
156 async fn delete(&self, key: &str) -> Result<(), AgentRuntimeError> {
157 let path = self.path_for(key);
158 match tokio::fs::remove_file(&path).await {
159 Ok(()) => Ok(()),
160 Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(()),
161 Err(e) => Err(AgentRuntimeError::Persistence(format!(
162 "delete {path:?}: {e}"
163 ))),
164 }
165 }
166
167 async fn batch_save(&self, items: &[(&str, &[u8])]) -> Result<(), AgentRuntimeError> {
172 let futs: Vec<_> = items.iter().map(|(key, value)| self.save(key, value)).collect();
173 try_join_all(futs).await?;
174 Ok(())
175 }
176
177 async fn batch_load(&self, keys: &[&str]) -> Result<Vec<Option<Vec<u8>>>, AgentRuntimeError> {
182 let futs: Vec<_> = keys.iter().map(|key| self.load(key)).collect();
183 try_join_all(futs).await
184 }
185}
186
187impl FilePersistenceBackend {
188 pub async fn list_keys(&self) -> Result<Vec<String>, AgentRuntimeError> {
193 let mut entries = tokio::fs::read_dir(self.base_dir.as_ref())
194 .await
195 .map_err(|e| AgentRuntimeError::Persistence(format!("list_keys readdir: {e}")))?;
196
197 let mut keys = Vec::new();
198 while let Some(entry) = entries
199 .next_entry()
200 .await
201 .map_err(|e| AgentRuntimeError::Persistence(format!("list_keys entry: {e}")))?
202 {
203 if let Some(name) = entry.file_name().to_str() {
204 if name.ends_with(".bin") {
205 if let Some(stem) = name.strip_suffix(".bin") {
206 if stem.len() > 17 {
208 let prefix = &stem[..stem.len() - 17]; keys.push(prefix.to_owned());
210 }
211 }
212 }
213 }
214 }
215 Ok(keys)
216 }
217
218 pub async fn exists(&self, key: &str) -> Result<bool, AgentRuntimeError> {
222 let path = self.path_for(key);
223 Ok(tokio::fs::metadata(&path).await.is_ok())
224 }
225
226 pub async fn key_count(&self) -> Result<usize, AgentRuntimeError> {
231 let mut entries = tokio::fs::read_dir(self.base_dir.as_ref())
232 .await
233 .map_err(|e| AgentRuntimeError::Persistence(format!("key_count readdir: {e}")))?;
234 let mut count = 0usize;
235 while let Some(entry) = entries
236 .next_entry()
237 .await
238 .map_err(|e| AgentRuntimeError::Persistence(format!("key_count entry: {e}")))?
239 {
240 if entry
241 .file_name()
242 .to_str()
243 .map_or(false, |n| n.ends_with(".bin"))
244 {
245 count += 1;
246 }
247 }
248 Ok(count)
249 }
250}
251
252#[cfg(test)]
255mod tests {
256 use super::*;
257 use std::sync::Arc;
258
259 async fn temp_backend() -> (FilePersistenceBackend, tempdir::Handle) {
260 let dir = std::env::temp_dir().join(format!("agent_runtime_test_{}", uuid::Uuid::new_v4()));
263 tokio::fs::create_dir_all(&dir).await.unwrap();
264 let backend = FilePersistenceBackend::new(dir.clone());
265 (backend, tempdir::Handle { path: dir })
266 }
267
268 mod tempdir {
270 pub struct Handle {
271 pub path: std::path::PathBuf,
272 }
273 impl Drop for Handle {
274 fn drop(&mut self) {
275 let _ = std::fs::remove_dir_all(&self.path);
276 }
277 }
278 }
279
280 #[tokio::test]
281 async fn test_file_backend_save_and_load() {
282 let dir = std::env::temp_dir().join(format!("art_{}", uuid::Uuid::new_v4()));
283 tokio::fs::create_dir_all(&dir).await.unwrap();
284 let _guard = tempdir::Handle { path: dir.clone() };
285 let backend = FilePersistenceBackend::new(&dir);
286
287 backend.save("test-key", b"hello world").await.unwrap();
288 let loaded = backend.load("test-key").await.unwrap();
289 assert_eq!(loaded, Some(b"hello world".to_vec()));
290 }
291
292 #[tokio::test]
293 async fn test_file_backend_load_missing_returns_none() {
294 let dir = std::env::temp_dir().join(format!("art_{}", uuid::Uuid::new_v4()));
295 tokio::fs::create_dir_all(&dir).await.unwrap();
296 let _guard = tempdir::Handle { path: dir.clone() };
297 let backend = FilePersistenceBackend::new(&dir);
298
299 let loaded = backend.load("nonexistent").await.unwrap();
300 assert_eq!(loaded, None);
301 }
302
303 #[tokio::test]
304 async fn test_file_backend_delete_removes_file() {
305 let dir = std::env::temp_dir().join(format!("art_{}", uuid::Uuid::new_v4()));
306 tokio::fs::create_dir_all(&dir).await.unwrap();
307 let _guard = tempdir::Handle { path: dir.clone() };
308 let backend = FilePersistenceBackend::new(&dir);
309
310 backend.save("to-delete", b"data").await.unwrap();
311 backend.delete("to-delete").await.unwrap();
312 let loaded = backend.load("to-delete").await.unwrap();
313 assert_eq!(loaded, None);
314 }
315
316 #[tokio::test]
317 async fn test_file_backend_delete_missing_is_noop() {
318 let dir = std::env::temp_dir().join(format!("art_{}", uuid::Uuid::new_v4()));
319 tokio::fs::create_dir_all(&dir).await.unwrap();
320 let _guard = tempdir::Handle { path: dir.clone() };
321 let backend = FilePersistenceBackend::new(&dir);
322
323 backend.delete("never-existed").await.unwrap();
325 }
326
327 #[tokio::test]
328 async fn test_file_backend_key_sanitization() {
329 let dir = std::env::temp_dir().join(format!("art_{}", uuid::Uuid::new_v4()));
330 tokio::fs::create_dir_all(&dir).await.unwrap();
331 let _guard = tempdir::Handle { path: dir.clone() };
332 let backend = FilePersistenceBackend::new(&dir);
333
334 backend.save("agent/session:1", b"data").await.unwrap();
336 let loaded = backend.load("agent/session:1").await.unwrap();
337 assert_eq!(loaded, Some(b"data".to_vec()));
338 }
339
340 #[tokio::test]
341 async fn test_file_backend_collision_resistant_keys() {
342 let dir = std::env::temp_dir().join(format!("art_{}", uuid::Uuid::new_v4()));
345 tokio::fs::create_dir_all(&dir).await.unwrap();
346 let _guard = tempdir::Handle { path: dir.clone() };
347 let backend = FilePersistenceBackend::new(&dir);
348
349 backend.save("a/b", b"slash").await.unwrap();
350 backend.save("a_b", b"underscore").await.unwrap();
351
352 let loaded_slash = backend.load("a/b").await.unwrap();
353 let loaded_under = backend.load("a_b").await.unwrap();
354 assert_eq!(loaded_slash, Some(b"slash".to_vec()));
355 assert_eq!(loaded_under, Some(b"underscore".to_vec()));
356 assert_ne!(
357 loaded_slash, loaded_under,
358 "keys with the same sanitized form must not collide"
359 );
360 }
361
362 #[tokio::test]
363 async fn test_batch_save_and_load() {
364 let dir = std::env::temp_dir().join(format!("art_{}", uuid::Uuid::new_v4()));
365 tokio::fs::create_dir_all(&dir).await.unwrap();
366 let _guard = tempdir::Handle { path: dir.clone() };
367 let backend = FilePersistenceBackend::new(&dir);
368
369 let data: Vec<(&str, Vec<u8>)> = vec![
370 ("batch-key-1", b"value1".to_vec()),
371 ("batch-key-2", b"value2".to_vec()),
372 ];
373 let refs: Vec<(&str, &[u8])> = data.iter().map(|(k, v)| (*k, v.as_slice())).collect();
374 backend.batch_save(&refs).await.unwrap();
375
376 let keys = vec!["batch-key-1", "batch-key-2", "batch-key-missing"];
377 let results = backend.batch_load(&keys).await.unwrap();
378 assert_eq!(results[0], Some(b"value1".to_vec()));
379 assert_eq!(results[1], Some(b"value2".to_vec()));
380 assert_eq!(results[2], None);
381 }
382
383 #[tokio::test]
384 async fn test_file_backend_list_keys() {
385 let dir = std::env::temp_dir().join(format!("art_{}", uuid::Uuid::new_v4()));
386 tokio::fs::create_dir_all(&dir).await.unwrap();
387 let _guard = tempdir::Handle { path: dir.clone() };
388 let backend = FilePersistenceBackend::new(&dir);
389
390 backend.save("my-session", b"data1").await.unwrap();
391 backend.save("another-key", b"data2").await.unwrap();
392
393 let keys = backend.list_keys().await.unwrap();
394 assert_eq!(keys.len(), 2);
395 }
396
397 #[tokio::test]
398 async fn test_persistence_backend_object_safe() {
399 let dir = std::env::temp_dir().join(format!("art_{}", uuid::Uuid::new_v4()));
401 tokio::fs::create_dir_all(&dir).await.unwrap();
402 let _guard = tempdir::Handle { path: dir.clone() };
403 let backend: Arc<dyn PersistenceBackend> = Arc::new(FilePersistenceBackend::new(&dir));
404 backend.save("obj-safe", b"ok").await.unwrap();
405 let r = backend.load("obj-safe").await.unwrap();
406 assert_eq!(r, Some(b"ok".to_vec()));
407 }
408
409 #[tokio::test]
412 async fn test_key_count_zero_for_empty_directory() {
413 let dir = std::env::temp_dir().join(format!("art_{}", uuid::Uuid::new_v4()));
414 tokio::fs::create_dir_all(&dir).await.unwrap();
415 let _guard = tempdir::Handle { path: dir.clone() };
416 let backend = FilePersistenceBackend::new(&dir);
417 assert_eq!(backend.key_count().await.unwrap(), 0);
418 }
419
420 #[tokio::test]
421 async fn test_key_count_matches_number_of_saves() {
422 let dir = std::env::temp_dir().join(format!("art_{}", uuid::Uuid::new_v4()));
423 tokio::fs::create_dir_all(&dir).await.unwrap();
424 let _guard = tempdir::Handle { path: dir.clone() };
425 let backend = FilePersistenceBackend::new(&dir);
426 backend.save("k1", b"v1").await.unwrap();
427 backend.save("k2", b"v2").await.unwrap();
428 backend.save("k3", b"v3").await.unwrap();
429 assert_eq!(backend.key_count().await.unwrap(), 3);
430 }
431
432 #[tokio::test]
433 async fn test_key_count_decrements_after_delete() {
434 let dir = std::env::temp_dir().join(format!("art_{}", uuid::Uuid::new_v4()));
435 tokio::fs::create_dir_all(&dir).await.unwrap();
436 let _guard = tempdir::Handle { path: dir.clone() };
437 let backend = FilePersistenceBackend::new(&dir);
438 backend.save("key", b"val").await.unwrap();
439 assert_eq!(backend.key_count().await.unwrap(), 1);
440 backend.delete("key").await.unwrap();
441 assert_eq!(backend.key_count().await.unwrap(), 0);
442 }
443}