Skip to main content

dk_engine/workspace/
cache.rs

1//! WorkspaceCache — L2 cache interface for session workspace state.
2//!
3//! This module defines the [`WorkspaceCache`] trait, supporting types, and the
4//! [`NoOpCache`] implementation that satisfies the trait with no-op behaviour.
5//! The trait is the seam used by multi-pod deployments to share workspace
6//! snapshots across replicas via an external store (e.g. Valkey/Redis).
7//!
8//! Available implementations are [`NoOpCache`] (single-pod / local dev) and
9//! [`ValkeyCache`](super::valkey_cache::ValkeyCache) (production multi-pod, behind the `valkey` feature).
10//! For single-pod deployments (and tests), [`NoOpCache`] is the default.
11
12use anyhow::Result;
13use async_trait::async_trait;
14use serde::{Deserialize, Serialize};
15use uuid::Uuid;
16
17// ── WorkspaceSnapshot ────────────────────────────────────────────────
18
19/// A serializable point-in-time snapshot of a session workspace's metadata.
20///
21/// This is what gets written to the L2 cache so that any pod in the cluster
22/// can reconstruct the workspace context for a session it did not originally
23/// create. It intentionally contains only plain, serializable fields — no
24/// in-process state such as `DashMap` or `Instant`.
25#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
26pub struct WorkspaceSnapshot {
27    /// The workspace (changeset) UUID.
28    pub session_id: Uuid,
29    /// The repository this workspace operates on.
30    pub repo_id: Uuid,
31    /// The authenticated agent identifier (e.g. Clerk user ID or API key prefix).
32    pub agent_id: String,
33    /// Human-readable agent name assigned by the server (e.g. `"agent-3"`).
34    pub agent_name: String,
35    /// The changeset UUID associated with this session.
36    pub changeset_id: Uuid,
37    /// The user-provided intent string for this session.
38    pub intent: String,
39    /// The Git commit hash that serves as the read-base for this workspace.
40    pub base_commit: String,
41    /// Lifecycle state label (e.g. `"active"`, `"submitted"`, `"merged"`).
42    pub state: String,
43    /// Mode label (`"ephemeral"` or `"persistent"`).
44    pub mode: String,
45}
46
47// ── CachedOverlayEntry ───────────────────────────────────────────────
48
49/// A file-level cache entry mirroring [`OverlayEntry`](super::overlay::OverlayEntry).
50///
51/// Carrying `content` inline keeps the cache self-contained — a pod
52/// recovering a session can reconstruct the overlay without a DB round-trip.
53#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
54pub enum CachedOverlayEntry {
55    /// File was modified relative to the base commit.
56    Modified { content: Vec<u8>, hash: String },
57    /// File was added (did not exist in the base commit).
58    Added { content: Vec<u8>, hash: String },
59    /// File was deleted.
60    Deleted,
61}
62
63// ── WorkspaceCache trait ─────────────────────────────────────────────
64
65/// L2 cache interface for workspace state across pods.
66///
67/// Implementors store and retrieve [`WorkspaceSnapshot`]s, per-file
68/// [`CachedOverlayEntry`]s, and serialised session graphs keyed by
69/// workspace UUID. All methods are async and infallible in the "cache miss"
70/// sense — they return `Ok(None)` rather than an error when an entry is absent.
71///
72/// The trait is `Send + Sync` so it can be stored behind `Arc<dyn WorkspaceCache>`
73/// and shared across Tokio tasks.
74#[async_trait]
75pub trait WorkspaceCache: Send + Sync + 'static {
76    // ── Workspace-level operations ───────────────────────────────────
77
78    /// Persist a workspace snapshot to the cache under its session ID.
79    async fn cache_workspace(&self, id: &Uuid, snapshot: &WorkspaceSnapshot) -> Result<()>;
80
81    /// Retrieve a previously cached workspace snapshot.
82    ///
83    /// Returns `Ok(None)` on a cache miss.
84    async fn get_workspace(&self, id: &Uuid) -> Result<Option<WorkspaceSnapshot>>;
85
86    // ── File overlay operations ──────────────────────────────────────
87
88    /// Cache a single overlay file entry for `(workspace_id, path)`.
89    async fn cache_file(
90        &self,
91        workspace_id: &Uuid,
92        path: &str,
93        entry: &CachedOverlayEntry,
94    ) -> Result<()>;
95
96    /// Retrieve a cached overlay file entry.
97    ///
98    /// Returns `Ok(None)` on a cache miss.
99    async fn get_file(
100        &self,
101        workspace_id: &Uuid,
102        path: &str,
103    ) -> Result<Option<CachedOverlayEntry>>;
104
105    /// List all file paths cached in the overlay for a workspace.
106    ///
107    /// Returns an empty `Vec` when no files are cached.
108    async fn list_files(&self, workspace_id: &Uuid) -> Result<Vec<String>>;
109
110    // ── Session graph operations ─────────────────────────────────────
111
112    /// Persist a serialised session graph blob for a workspace.
113    async fn cache_graph(&self, workspace_id: &Uuid, graph_data: &[u8]) -> Result<()>;
114
115    /// Retrieve a serialised session graph blob.
116    ///
117    /// Returns `Ok(None)` on a cache miss.
118    async fn get_graph(&self, workspace_id: &Uuid) -> Result<Option<Vec<u8>>>;
119
120    // ── Lifecycle operations ─────────────────────────────────────────
121
122    /// Remove all cache entries for a workspace (snapshot, files, graph).
123    async fn evict(&self, id: &Uuid) -> Result<()>;
124
125    /// Update the cache TTL / last-access timestamp for a workspace.
126    ///
127    /// Implementations that support TTL-based expiry should reset the clock
128    /// on this call. No-op implementations accept silently.
129    async fn touch(&self, id: &Uuid) -> Result<()>;
130}
131
132// ── NoOpCache ────────────────────────────────────────────────────────
133
134/// A [`WorkspaceCache`] implementation that does nothing.
135///
136/// All writes are silently discarded. All reads return `Ok(None)` /
137/// `Ok(vec![])`. This is the default used by single-pod deployments and
138/// in unit tests where no external cache is needed.
139#[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    // ── Serialization round-trips ────────────────────────────────────
195
196    #[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    // ── NoOpCache unit tests ─────────────────────────────────────────
235
236    #[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        // Write should succeed silently.
260        cache.cache_workspace(&id, &snap).await.expect("should not error");
261        // Read-back must still return None (nothing was stored).
262        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        // Read-back must return None.
284        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        // Verify the trait object can be shared across threads.
331        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}