oxicode_sdk/lifecycle/
snapshot.rs1use crate::lifecycle::MetricsSnapshot;
4use oxicode_agent::{AgentConfig, AgentState, ToolRegistry};
5use serde::{Deserialize, Serialize};
6use std::collections::HashMap;
7use std::future::Future;
8use std::path::PathBuf;
9use std::pin::Pin;
10
11#[derive(Debug, Clone, Serialize, Deserialize)]
15pub struct AgentSnapshot {
16 pub agent_id: String,
18 pub config: AgentConfig,
20 pub state: AgentState,
22 pub tool_manifest: ToolManifest,
24 pub parent_id: Option<String>,
26 pub created_at_ms: u64,
28 pub snapshot_at_ms: u64,
30 pub metrics: MetricsSnapshot,
32 #[serde(default)]
34 pub metadata: HashMap<String, serde_json::Value>,
35}
36
37impl AgentSnapshot {
38 pub fn from_agent(
40 agent_id: String,
41 config: &AgentConfig,
42 state: &AgentState,
43 tools: &ToolRegistry,
44 parent_id: Option<String>,
45 metadata: HashMap<String, serde_json::Value>,
46 ) -> Self {
47 let now = now_ms();
48 Self {
49 agent_id,
50 config: config.clone(),
51 state: state.clone(),
52 tool_manifest: ToolManifest::from_registry(tools),
53 parent_id,
54 created_at_ms: now,
55 snapshot_at_ms: now,
56 metrics: MetricsSnapshot::default(),
57 metadata,
58 }
59 }
60
61 pub fn to_bytes(&self) -> anyhow::Result<Vec<u8>> {
63 Ok(serde_json::to_vec(self)?)
64 }
65
66 pub fn from_bytes(bytes: &[u8]) -> anyhow::Result<Self> {
68 Ok(serde_json::from_slice(bytes)?)
69 }
70
71 pub fn estimated_size_bytes(&self) -> usize {
73 serde_json::to_vec(self).map(|b| b.len()).unwrap_or(0)
74 }
75}
76
77#[derive(Debug, Clone, Serialize, Deserialize)]
84pub struct ToolManifest {
85 pub tools: Vec<ToolManifestEntry>,
87}
88
89impl ToolManifest {
90 pub fn from_registry(registry: &ToolRegistry) -> Self {
92 let tools = registry
93 .definitions()
94 .into_iter()
95 .map(|d| ToolManifestEntry {
96 name: d.name,
97 description: d.description,
98 essential: false,
99 })
100 .collect();
101 Self { tools }
102 }
103
104 pub fn missing_from(&self, registry: &ToolRegistry) -> Vec<&str> {
106 let names: std::collections::HashSet<_> = registry.names().into_iter().collect();
107 self.tools
108 .iter()
109 .filter(|t| !names.contains(&t.name))
110 .map(|t| t.name.as_str())
111 .collect()
112 }
113}
114
115#[derive(Debug, Clone, Serialize, Deserialize)]
117pub struct ToolManifestEntry {
118 pub name: String,
120 pub description: String,
122 #[serde(default)]
124 pub essential: bool,
125}
126
127pub trait SnapshotStore: Send + Sync {
133 fn save<'a>(
135 &'a self,
136 snapshot: &'a AgentSnapshot,
137 ) -> Pin<Box<dyn Future<Output = anyhow::Result<()>> + Send + 'a>>;
138
139 fn load<'a>(
141 &'a self,
142 agent_id: &'a str,
143 ) -> Pin<Box<dyn Future<Output = anyhow::Result<Option<AgentSnapshot>>> + Send + 'a>>;
144
145 fn list(&self) -> Pin<Box<dyn Future<Output = anyhow::Result<Vec<String>>> + Send + '_>>;
147
148 fn delete<'a>(
150 &'a self,
151 agent_id: &'a str,
152 ) -> Pin<Box<dyn Future<Output = anyhow::Result<()>> + Send + 'a>>;
153}
154
155#[derive(Debug)]
161pub struct FileSnapshotStore {
162 base_dir: PathBuf,
163}
164
165impl FileSnapshotStore {
166 pub fn new(base_dir: impl Into<PathBuf>) -> anyhow::Result<Self> {
170 let base_dir = base_dir.into();
171 std::fs::create_dir_all(&base_dir)?;
172 Ok(Self { base_dir })
173 }
174
175 fn snapshot_path(&self, agent_id: &str) -> PathBuf {
176 self.base_dir.join(format!("{agent_id}.json"))
177 }
178}
179
180impl SnapshotStore for FileSnapshotStore {
181 fn save<'a>(
182 &'a self,
183 snapshot: &'a AgentSnapshot,
184 ) -> Pin<Box<dyn Future<Output = anyhow::Result<()>> + Send + 'a>> {
185 Box::pin(async {
186 let path = self.snapshot_path(&snapshot.agent_id);
187 let bytes = serde_json::to_vec_pretty(snapshot)?;
188 tokio::fs::write(&path, bytes).await?;
189 Ok(())
190 })
191 }
192
193 fn load<'a>(
194 &'a self,
195 agent_id: &'a str,
196 ) -> Pin<Box<dyn Future<Output = anyhow::Result<Option<AgentSnapshot>>> + Send + 'a>> {
197 Box::pin(async {
198 let path = self.snapshot_path(agent_id);
199 if !path.is_file() {
200 return Ok(None);
201 }
202 let bytes = tokio::fs::read(&path).await?;
203 let snapshot: AgentSnapshot = serde_json::from_slice(&bytes)?;
204 Ok(Some(snapshot))
205 })
206 }
207
208 fn list(&self) -> Pin<Box<dyn Future<Output = anyhow::Result<Vec<String>>> + Send + '_>> {
209 Box::pin(async {
210 let mut entries = Vec::new();
211 let mut dir = tokio::fs::read_dir(&self.base_dir).await?;
212 while let Some(entry) = dir.next_entry().await? {
213 if entry.path().extension().is_some_and(|e| e == "json")
214 && let Some(name) = entry.path().file_stem()
215 {
216 entries.push(name.to_string_lossy().to_string());
217 }
218 }
219 Ok(entries)
220 })
221 }
222
223 fn delete<'a>(
224 &'a self,
225 agent_id: &'a str,
226 ) -> Pin<Box<dyn Future<Output = anyhow::Result<()>> + Send + 'a>> {
227 Box::pin(async {
228 let path = self.snapshot_path(agent_id);
229 if path.is_file() {
230 tokio::fs::remove_file(&path).await?;
231 }
232 Ok(())
233 })
234 }
235}
236
237fn now_ms() -> u64 {
240 std::time::SystemTime::now()
241 .duration_since(std::time::UNIX_EPOCH)
242 .map(|d| d.as_millis() as u64)
243 .unwrap_or(0)
244}
245
246#[cfg(test)]
247mod tests {
248 use super::*;
249 use std::sync::Arc;
250 use tempfile::TempDir;
251
252 fn test_snapshot() -> AgentSnapshot {
253 AgentSnapshot {
254 agent_id: "test-agent".into(),
255 config: AgentConfig::default(),
256 state: AgentState::default(),
257 tool_manifest: ToolManifest { tools: vec![] },
258 parent_id: None,
259 created_at_ms: 1_000_000_000_000,
260 snapshot_at_ms: 1_000_000_000_100,
261 metrics: MetricsSnapshot {
262 total_runs: 5,
263 successful_runs: 4,
264 failed_runs: 1,
265 total_input_tokens: 35_000,
266 total_output_tokens: 15_000,
267 total_tokens: 50_000,
268 tool_calls: 20,
269 total_duration_ms: 30_000,
270 },
271 metadata: HashMap::new(),
272 }
273 }
274
275 #[test]
276 fn snapshot_roundtrip_json() {
277 let snapshot = test_snapshot();
278 let json = serde_json::to_string(&snapshot).unwrap();
279 let back: AgentSnapshot = serde_json::from_str(&json).unwrap();
280 assert_eq!(back.agent_id, "test-agent");
281 assert_eq!(back.metrics.total_runs, 5);
282 }
283
284 #[test]
285 fn snapshot_roundtrip_bytes() {
286 let snapshot = test_snapshot();
287 let bytes = snapshot.to_bytes().unwrap();
288 let back = AgentSnapshot::from_bytes(&bytes).unwrap();
289 assert_eq!(back.agent_id, "test-agent");
290 }
291
292 #[test]
293 fn snapshot_estimated_size() {
294 let snapshot = test_snapshot();
295 assert!(snapshot.estimated_size_bytes() > 0);
296 }
297
298 #[test]
299 fn tool_manifest_from_empty_registry() {
300 let registry = Arc::new(ToolRegistry::new());
301 let manifest = ToolManifest::from_registry(®istry);
302 assert!(manifest.tools.is_empty());
303 assert!(manifest.missing_from(®istry).is_empty());
304 }
305
306 #[tokio::test]
307 async fn file_snapshot_store_save_load() {
308 let tmp = TempDir::new().unwrap();
309 let store = FileSnapshotStore::new(tmp.path()).unwrap();
310
311 let snapshot = test_snapshot();
312 store.save(&snapshot).await.unwrap();
313
314 let loaded = store.load("test-agent").await.unwrap().unwrap();
315 assert_eq!(loaded.agent_id, "test-agent");
316 assert_eq!(loaded.metrics.total_runs, 5);
317 }
318
319 #[tokio::test]
320 async fn file_snapshot_store_load_missing() {
321 let tmp = TempDir::new().unwrap();
322 let store = FileSnapshotStore::new(tmp.path()).unwrap();
323 let result = store.load("does-not-exist").await.unwrap();
324 assert!(result.is_none());
325 }
326
327 #[tokio::test]
328 async fn file_snapshot_store_delete() {
329 let tmp = TempDir::new().unwrap();
330 let store = FileSnapshotStore::new(tmp.path()).unwrap();
331
332 let snapshot = test_snapshot();
333 store.save(&snapshot).await.unwrap();
334 store.delete("test-agent").await.unwrap();
335
336 let result = store.load("test-agent").await.unwrap();
337 assert!(result.is_none());
338 }
339
340 #[tokio::test]
341 async fn file_snapshot_store_list() {
342 let tmp = TempDir::new().unwrap();
343 let store = FileSnapshotStore::new(tmp.path()).unwrap();
344
345 let mut s1 = test_snapshot();
346 s1.agent_id = "alpha".into();
347 store.save(&s1).await.unwrap();
348
349 let mut s2 = test_snapshot();
350 s2.agent_id = "beta".into();
351 store.save(&s2).await.unwrap();
352
353 let ids = store.list().await.unwrap();
354 assert_eq!(ids.len(), 2);
355 assert!(ids.contains(&"alpha".into()));
356 assert!(ids.contains(&"beta".into()));
357 }
358}