1use anyhow::Result;
13use async_trait::async_trait;
14use serde::{Deserialize, Serialize};
15use uuid::Uuid;
16
17#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
26pub struct WorkspaceSnapshot {
27 pub session_id: Uuid,
29 pub repo_id: Uuid,
31 pub agent_id: String,
33 pub agent_name: String,
35 pub changeset_id: Uuid,
37 pub intent: String,
39 pub base_commit: String,
41 pub state: String,
43 pub mode: String,
45}
46
47#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
54pub enum CachedOverlayEntry {
55 Modified { content: Vec<u8>, hash: String },
57 Added { content: Vec<u8>, hash: String },
59 Deleted,
61}
62
63#[async_trait]
75pub trait WorkspaceCache: Send + Sync + 'static {
76 async fn cache_workspace(&self, id: &Uuid, snapshot: &WorkspaceSnapshot) -> Result<()>;
80
81 async fn get_workspace(&self, id: &Uuid) -> Result<Option<WorkspaceSnapshot>>;
85
86 async fn cache_file(
90 &self,
91 workspace_id: &Uuid,
92 path: &str,
93 entry: &CachedOverlayEntry,
94 ) -> Result<()>;
95
96 async fn get_file(
100 &self,
101 workspace_id: &Uuid,
102 path: &str,
103 ) -> Result<Option<CachedOverlayEntry>>;
104
105 async fn list_files(&self, workspace_id: &Uuid) -> Result<Vec<String>>;
109
110 async fn cache_graph(&self, workspace_id: &Uuid, graph_data: &[u8]) -> Result<()>;
114
115 async fn get_graph(&self, workspace_id: &Uuid) -> Result<Option<Vec<u8>>>;
119
120 async fn evict(&self, id: &Uuid) -> Result<()>;
124
125 async fn touch(&self, id: &Uuid) -> Result<()>;
130}
131
132#[derive(Debug, Clone, Default)]
140pub struct NoOpCache;
141
142#[async_trait]
143impl WorkspaceCache for NoOpCache {
144 async fn cache_workspace(&self, _id: &Uuid, _snapshot: &WorkspaceSnapshot) -> Result<()> {
145 Ok(())
146 }
147
148 async fn get_workspace(&self, _id: &Uuid) -> Result<Option<WorkspaceSnapshot>> {
149 Ok(None)
150 }
151
152 async fn cache_file(
153 &self,
154 _workspace_id: &Uuid,
155 _path: &str,
156 _entry: &CachedOverlayEntry,
157 ) -> Result<()> {
158 Ok(())
159 }
160
161 async fn get_file(
162 &self,
163 _workspace_id: &Uuid,
164 _path: &str,
165 ) -> Result<Option<CachedOverlayEntry>> {
166 Ok(None)
167 }
168
169 async fn list_files(&self, _workspace_id: &Uuid) -> Result<Vec<String>> {
170 Ok(vec![])
171 }
172
173 async fn cache_graph(&self, _workspace_id: &Uuid, _graph_data: &[u8]) -> Result<()> {
174 Ok(())
175 }
176
177 async fn get_graph(&self, _workspace_id: &Uuid) -> Result<Option<Vec<u8>>> {
178 Ok(None)
179 }
180
181 async fn evict(&self, _id: &Uuid) -> Result<()> {
182 Ok(())
183 }
184
185 async fn touch(&self, _id: &Uuid) -> Result<()> {
186 Ok(())
187 }
188}
189
190#[cfg(test)]
191mod tests {
192 use super::*;
193
194 #[test]
197 fn workspace_snapshot_roundtrips_json() {
198 let snap = WorkspaceSnapshot {
199 session_id: Uuid::new_v4(),
200 repo_id: Uuid::new_v4(),
201 agent_id: "clerk|abc".to_string(),
202 agent_name: "agent-1".to_string(),
203 changeset_id: Uuid::new_v4(),
204 intent: "fix the bug".to_string(),
205 base_commit: "deadbeef".to_string(),
206 state: "active".to_string(),
207 mode: "ephemeral".to_string(),
208 };
209 let json = serde_json::to_string(&snap).expect("serialize");
210 let back: WorkspaceSnapshot = serde_json::from_str(&json).expect("deserialize");
211 assert_eq!(snap, back);
212 }
213
214 #[test]
215 fn cached_overlay_entry_roundtrips_json() {
216 let entries = [
217 CachedOverlayEntry::Modified {
218 content: b"fn foo() {}".to_vec(),
219 hash: "abc123".to_string(),
220 },
221 CachedOverlayEntry::Added {
222 content: b"new file".to_vec(),
223 hash: "def456".to_string(),
224 },
225 CachedOverlayEntry::Deleted,
226 ];
227 for entry in &entries {
228 let json = serde_json::to_string(entry).expect("serialize");
229 let back: CachedOverlayEntry = serde_json::from_str(&json).expect("deserialize");
230 assert_eq!(entry, &back);
231 }
232 }
233
234 #[tokio::test]
237 async fn noop_get_workspace_returns_none() {
238 let cache = NoOpCache;
239 let id = Uuid::new_v4();
240 let result = cache.get_workspace(&id).await.expect("should not error");
241 assert!(result.is_none(), "NoOpCache must return None on get_workspace");
242 }
243
244 #[tokio::test]
245 async fn noop_cache_workspace_is_silent() {
246 let cache = NoOpCache;
247 let id = Uuid::new_v4();
248 let snap = WorkspaceSnapshot {
249 session_id: id,
250 repo_id: Uuid::new_v4(),
251 agent_id: "agent".to_string(),
252 agent_name: "agent-1".to_string(),
253 changeset_id: Uuid::new_v4(),
254 intent: "intent".to_string(),
255 base_commit: "abc".to_string(),
256 state: "active".to_string(),
257 mode: "ephemeral".to_string(),
258 };
259 cache.cache_workspace(&id, &snap).await.expect("should not error");
261 let result = cache.get_workspace(&id).await.expect("should not error");
263 assert!(result.is_none());
264 }
265
266 #[tokio::test]
267 async fn noop_get_file_returns_none() {
268 let cache = NoOpCache;
269 let id = Uuid::new_v4();
270 let result = cache.get_file(&id, "src/lib.rs").await.expect("should not error");
271 assert!(result.is_none());
272 }
273
274 #[tokio::test]
275 async fn noop_cache_file_is_silent() {
276 let cache = NoOpCache;
277 let id = Uuid::new_v4();
278 let entry = CachedOverlayEntry::Modified {
279 content: b"hello".to_vec(),
280 hash: "abc".to_string(),
281 };
282 cache.cache_file(&id, "src/lib.rs", &entry).await.expect("should not error");
283 let result = cache.get_file(&id, "src/lib.rs").await.expect("should not error");
285 assert!(result.is_none());
286 }
287
288 #[tokio::test]
289 async fn noop_list_files_returns_empty() {
290 let cache = NoOpCache;
291 let id = Uuid::new_v4();
292 let files = cache.list_files(&id).await.expect("should not error");
293 assert!(files.is_empty(), "NoOpCache must return empty vec from list_files");
294 }
295
296 #[tokio::test]
297 async fn noop_get_graph_returns_none() {
298 let cache = NoOpCache;
299 let id = Uuid::new_v4();
300 let result = cache.get_graph(&id).await.expect("should not error");
301 assert!(result.is_none());
302 }
303
304 #[tokio::test]
305 async fn noop_cache_graph_is_silent() {
306 let cache = NoOpCache;
307 let id = Uuid::new_v4();
308 let data = b"graph-bytes";
309 cache.cache_graph(&id, data).await.expect("should not error");
310 let result = cache.get_graph(&id).await.expect("should not error");
311 assert!(result.is_none());
312 }
313
314 #[tokio::test]
315 async fn noop_evict_is_noop() {
316 let cache = NoOpCache;
317 let id = Uuid::new_v4();
318 cache.evict(&id).await.expect("evict should not error");
319 }
320
321 #[tokio::test]
322 async fn noop_touch_is_noop() {
323 let cache = NoOpCache;
324 let id = Uuid::new_v4();
325 cache.touch(&id).await.expect("touch should not error");
326 }
327
328 #[tokio::test]
329 async fn noop_cache_is_send_sync() {
330 let cache: std::sync::Arc<dyn WorkspaceCache> = std::sync::Arc::new(NoOpCache);
332 let id = Uuid::new_v4();
333 let result = cache.get_workspace(&id).await.expect("should not error");
334 assert!(result.is_none());
335 }
336}