1use async_trait::async_trait;
46use serde::{Deserialize, Serialize};
47use std::collections::HashMap;
48use std::path::PathBuf;
49
50use crate::types::AgentStep;
51
52#[derive(Debug, thiserror::Error)]
54#[non_exhaustive]
55pub enum ResumeError {
56 #[error("resume store I/O error: {0}")]
58 Io(String),
59 #[error("resume store serialization error: {0}")]
61 Serialize(String),
62}
63
64#[derive(Debug, Clone, Serialize, Deserialize)]
74pub struct PendingApproval {
75 pub tool_name: String,
77 pub arguments: serde_json::Value,
79 pub tool_id: String,
81 pub inputs: HashMap<String, String>,
83 pub steps: Vec<AgentStep>,
85 pub iteration: usize,
87 pub tool_calls_consumed: usize,
89 pub tokens_consumed: Option<usize>,
91 pub trace_id: Option<String>,
93}
94
95#[async_trait]
102pub trait ResumeStore: Send + Sync {
103 async fn save_pending(&self, pending: &PendingApproval) -> Result<(), ResumeError>;
105 async fn load_pending(&self) -> Result<Option<PendingApproval>, ResumeError>;
107 async fn clear_pending(&self) -> Result<(), ResumeError>;
109}
110
111pub struct FileResumeStore {
118 dir: PathBuf,
119}
120
121impl FileResumeStore {
122 pub fn new(dir: impl Into<PathBuf>) -> Result<Self, ResumeError> {
124 let dir = dir.into();
125 std::fs::create_dir_all(&dir)
126 .map_err(|e| ResumeError::Io(format!("create dir {}: {}", dir.display(), e)))?;
127 Ok(Self { dir })
128 }
129
130 fn pending_path(&self) -> PathBuf {
131 self.dir.join("pending.json")
132 }
133}
134
135#[async_trait]
136impl ResumeStore for FileResumeStore {
137 async fn save_pending(&self, pending: &PendingApproval) -> Result<(), ResumeError> {
138 let bytes = serde_json::to_vec_pretty(pending)
139 .map_err(|e| ResumeError::Serialize(e.to_string()))?;
140 let tmp = self.dir.join("pending.json.tmp");
141 tokio::fs::write(&tmp, &bytes)
142 .await
143 .map_err(|e| ResumeError::Io(format!("write {}: {}", tmp.display(), e)))?;
144 tokio::fs::rename(&tmp, self.pending_path())
145 .await
146 .map_err(|e| ResumeError::Io(format!("rename {}: {}", tmp.display(), e)))?;
147 Ok(())
148 }
149
150 async fn load_pending(&self) -> Result<Option<PendingApproval>, ResumeError> {
151 let path = self.pending_path();
152 let bytes = match tokio::fs::read(&path).await {
153 Ok(bytes) => bytes,
154 Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(None),
155 Err(e) => {
156 return Err(ResumeError::Io(format!("read {}: {}", path.display(), e)));
157 }
158 };
159 let pending = serde_json::from_slice(&bytes)
160 .map_err(|e| ResumeError::Serialize(format!("parse {}: {}", path.display(), e)))?;
161 Ok(Some(pending))
162 }
163
164 async fn clear_pending(&self) -> Result<(), ResumeError> {
165 let path = self.pending_path();
166 match tokio::fs::remove_file(&path).await {
167 Ok(()) => Ok(()),
168 Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(()),
170 Err(e) => Err(ResumeError::Io(format!("remove {}: {}", path.display(), e))),
171 }
172 }
173}
174
175#[derive(Default)]
177pub struct MemoryResumeStore {
178 pending: tokio::sync::Mutex<Option<PendingApproval>>,
179}
180
181impl MemoryResumeStore {
182 pub fn new() -> Self {
184 Self::default()
185 }
186}
187
188#[async_trait]
189impl ResumeStore for MemoryResumeStore {
190 async fn save_pending(&self, pending: &PendingApproval) -> Result<(), ResumeError> {
191 *self.pending.lock().await = Some(pending.clone());
192 Ok(())
193 }
194
195 async fn load_pending(&self) -> Result<Option<PendingApproval>, ResumeError> {
196 Ok(self.pending.lock().await.clone())
197 }
198
199 async fn clear_pending(&self) -> Result<(), ResumeError> {
200 *self.pending.lock().await = None;
201 Ok(())
202 }
203}
204
205#[cfg(test)]
206mod tests {
207 use super::*;
208 use crate::types::{AgentAction, ToolInput};
209
210 fn sample_pending() -> PendingApproval {
211 let mut inputs = HashMap::new();
212 inputs.insert("input".to_string(), "compute".to_string());
213 PendingApproval {
214 tool_name: "calculator".to_string(),
215 arguments: serde_json::json!({"expression": "2 + 3"}),
216 tool_id: "call_1".to_string(),
217 inputs,
218 steps: vec![AgentStep::new(
219 AgentAction {
220 tool: "other".to_string(),
221 tool_input: ToolInput::String {
222 value: "x".to_string(),
223 },
224 log: String::new(),
225 },
226 "obs".to_string(),
227 )],
228 iteration: 3,
229 tool_calls_consumed: 4,
230 tokens_consumed: Some(128),
231 trace_id: Some("trace-1".to_string()),
232 }
233 }
234
235 #[tokio::test]
236 async fn test_file_store_roundtrip() {
237 let dir = tempfile::tempdir().unwrap();
238 let store = FileResumeStore::new(dir.path()).unwrap();
239
240 assert!(store.load_pending().await.unwrap().is_none());
242
243 let pending = sample_pending();
244 store.save_pending(&pending).await.unwrap();
245 let loaded = store.load_pending().await.unwrap().unwrap();
246 assert_eq!(loaded.tool_name, "calculator");
247 assert_eq!(loaded.arguments, serde_json::json!({"expression": "2 + 3"}));
248 assert_eq!(loaded.iteration, 3);
249 assert_eq!(loaded.tool_calls_consumed, 4);
250 assert_eq!(loaded.tokens_consumed, Some(128));
251 assert_eq!(loaded.inputs.get("input").unwrap(), "compute");
252 assert_eq!(loaded.steps.len(), 1);
253 assert_eq!(loaded.trace_id.as_deref(), Some("trace-1"));
254
255 store.clear_pending().await.unwrap();
256 assert!(store.load_pending().await.unwrap().is_none());
257 }
258
259 #[tokio::test]
260 async fn test_file_store_clear_idempotent() {
261 let dir = tempfile::tempdir().unwrap();
262 let store = FileResumeStore::new(dir.path()).unwrap();
263 store.clear_pending().await.unwrap();
265 store.clear_pending().await.unwrap();
266 }
267
268 #[tokio::test]
269 async fn test_file_store_atomic_no_tmp_left() {
270 let dir = tempfile::tempdir().unwrap();
271 let store = FileResumeStore::new(dir.path()).unwrap();
272 store.save_pending(&sample_pending()).await.unwrap();
273
274 let entries: Vec<String> = std::fs::read_dir(dir.path())
276 .unwrap()
277 .map(|e| e.unwrap().file_name().to_string_lossy().to_string())
278 .collect();
279 assert!(
280 !entries.iter().any(|n| n == "pending.json.tmp"),
281 "tmp file should be renamed away, got {entries:?}"
282 );
283 assert!(entries.contains(&"pending.json".to_string()));
284 }
285
286 #[tokio::test]
287 async fn test_memory_store_roundtrip() {
288 let store = MemoryResumeStore::new();
289 assert!(store.load_pending().await.unwrap().is_none());
290
291 store.save_pending(&sample_pending()).await.unwrap();
292 assert!(store.load_pending().await.unwrap().is_some());
293
294 store.clear_pending().await.unwrap();
295 assert!(store.load_pending().await.unwrap().is_none());
296 }
297
298 #[test]
299 fn test_pending_approval_serde_roundtrip() {
300 let pending = sample_pending();
301 let bytes = serde_json::to_vec(&pending).unwrap();
302 let back: PendingApproval = serde_json::from_slice(&bytes).unwrap();
303 assert_eq!(back.tool_name, pending.tool_name);
304 assert_eq!(back.arguments, pending.arguments);
305 assert_eq!(back.steps.len(), pending.steps.len());
306 assert_eq!(back.trace_id, pending.trace_id);
307 }
308
309 #[test]
310 fn test_resume_error_display() {
311 let e = ResumeError::Io("disk full".to_string());
312 assert!(e.to_string().contains("disk full"));
313 let e = ResumeError::Serialize("bad json".to_string());
314 assert!(e.to_string().contains("bad json"));
315 }
316
317 #[tokio::test]
319 async fn test_file_store_creates_dir() {
320 let dir = tempfile::tempdir().unwrap();
321 let nested = dir.path().join("a").join("b");
322 let store = FileResumeStore::new(&nested).unwrap();
323 assert!(nested.is_dir());
324 assert!(store.load_pending().await.is_ok());
325 }
326}