Skip to main content

dk_engine/workspace/
overlay.rs

1//! In-memory file overlay with DashMap and async PostgreSQL sync.
2//!
3//! Every write is reflected in a `DashMap` for O(1) reads and is
4//! simultaneously persisted to the `session_overlay_files` table so that
5//! workspaces survive process restarts.
6
7use dashmap::DashMap;
8use dk_core::Result;
9use sha2::{Digest, Sha256};
10use sqlx::PgPool;
11use uuid::Uuid;
12
13// ── Overlay entry ────────────────────────────────────────────────────
14
15/// Represents a single file change within a session overlay.
16#[derive(Debug, Clone)]
17pub enum OverlayEntry {
18    /// File was modified (or newly created with content from the session).
19    Modified { content: Vec<u8>, hash: String },
20    /// File was added (did not exist in the base commit).
21    Added { content: Vec<u8>, hash: String },
22    /// File was deleted from the base tree.
23    Deleted,
24}
25
26impl OverlayEntry {
27    /// Return the content bytes if this entry carries data.
28    pub fn content(&self) -> Option<&[u8]> {
29        match self {
30            Self::Modified { content, .. } | Self::Added { content, .. } => Some(content),
31            Self::Deleted => None,
32        }
33    }
34
35    /// Return the content hash, or `None` for deletions.
36    pub fn hash(&self) -> Option<&str> {
37        match self {
38            Self::Modified { hash, .. } | Self::Added { hash, .. } => Some(hash),
39            Self::Deleted => None,
40        }
41    }
42
43    /// The SQL `change_type` label.
44    fn change_type_str(&self) -> &'static str {
45        match self {
46            Self::Modified { .. } => "modified",
47            Self::Added { .. } => "added",
48            Self::Deleted => "deleted",
49        }
50    }
51}
52
53// ── FileOverlay ──────────────────────────────────────────────────────
54
55/// Concurrent, overlay-based file store for a single workspace.
56///
57/// Reads are lock-free via `DashMap`. Writes are O(1) in memory and
58/// issue a single `INSERT … ON CONFLICT UPDATE` to PostgreSQL.
59pub struct FileOverlay {
60    entries: DashMap<String, OverlayEntry>,
61    workspace_id: Uuid,
62    db: PgPool,
63}
64
65impl FileOverlay {
66    /// Create a new, empty overlay for the given workspace.
67    pub fn new(workspace_id: Uuid, db: PgPool) -> Self {
68        Self {
69            entries: DashMap::new(),
70            workspace_id,
71            db,
72        }
73    }
74
75    /// Get a reference to an overlay entry by path.
76    pub fn get(&self, path: &str) -> Option<dashmap::mapref::one::Ref<'_, String, OverlayEntry>> {
77        self.entries.get(path)
78    }
79
80    /// Check whether the overlay contains an entry for `path`.
81    pub fn contains(&self, path: &str) -> bool {
82        self.entries.contains_key(path)
83    }
84
85    /// Write (or overwrite) a file in the overlay.
86    ///
87    /// `is_new` indicates whether the file did not previously exist in the
88    /// base tree — it controls whether the entry is `Added` vs `Modified`.
89    ///
90    /// The write is persisted to the database before returning.
91    pub async fn write(&self, path: &str, content: Vec<u8>, is_new: bool) -> Result<String> {
92        let hash = format!("{:x}", Sha256::digest(&content));
93
94        let entry = if is_new {
95            OverlayEntry::Added {
96                content: content.clone(),
97                hash: hash.clone(),
98            }
99        } else {
100            OverlayEntry::Modified {
101                content: content.clone(),
102                hash: hash.clone(),
103            }
104        };
105
106        let change_type = entry.change_type_str();
107
108        // Persist to DB
109        sqlx::query(
110            r#"
111            INSERT INTO session_overlay_files (workspace_id, file_path, content, content_hash, change_type)
112            VALUES ($1, $2, $3, $4, $5)
113            ON CONFLICT (workspace_id, file_path) DO UPDATE
114                SET content      = EXCLUDED.content,
115                    content_hash = EXCLUDED.content_hash,
116                    change_type  = EXCLUDED.change_type,
117                    updated_at   = NOW()
118            "#,
119        )
120        .bind(self.workspace_id)
121        .bind(path)
122        .bind(&content)
123        .bind(&hash)
124        .bind(change_type)
125        .execute(&self.db)
126        .await?;
127
128        self.entries.insert(path.to_string(), entry);
129        Ok(hash)
130    }
131
132    /// Mark a file as deleted in the overlay.
133    ///
134    /// The deletion is persisted to the database.
135    pub async fn delete(&self, path: &str) -> Result<()> {
136        let entry = OverlayEntry::Deleted;
137        let change_type = entry.change_type_str();
138
139        sqlx::query(
140            r#"
141            INSERT INTO session_overlay_files (workspace_id, file_path, content, content_hash, change_type)
142            VALUES ($1, $2, '', '', $3)
143            ON CONFLICT (workspace_id, file_path) DO UPDATE
144                SET content      = EXCLUDED.content,
145                    content_hash = EXCLUDED.content_hash,
146                    change_type  = EXCLUDED.change_type,
147                    updated_at   = NOW()
148            "#,
149        )
150        .bind(self.workspace_id)
151        .bind(path)
152        .bind(change_type)
153        .execute(&self.db)
154        .await?;
155
156        self.entries.insert(path.to_string(), entry);
157        Ok(())
158    }
159
160    /// Revert a file in the overlay, removing it from both memory and DB.
161    pub async fn revert(&self, path: &str) -> Result<()> {
162        self.entries.remove(path);
163
164        sqlx::query("DELETE FROM session_overlay_files WHERE workspace_id = $1 AND file_path = $2")
165            .bind(self.workspace_id)
166            .bind(path)
167            .execute(&self.db)
168            .await?;
169
170        Ok(())
171    }
172
173    /// Return a snapshot of all changed paths and their entries.
174    pub fn list_changes(&self) -> Vec<(String, OverlayEntry)> {
175        self.entries
176            .iter()
177            .map(|r| (r.key().clone(), r.value().clone()))
178            .collect()
179    }
180
181    /// Returns just the file paths in the overlay without cloning content.
182    pub fn list_paths(&self) -> Vec<String> {
183        self.entries.iter().map(|r| r.key().clone()).collect()
184    }
185
186    /// Number of entries (files touched) in the overlay.
187    pub fn len(&self) -> usize {
188        self.entries.len()
189    }
190
191    /// Whether the overlay is empty.
192    pub fn is_empty(&self) -> bool {
193        self.entries.is_empty()
194    }
195
196    /// Total bytes stored in the overlay (excluding deleted entries).
197    pub fn total_bytes(&self) -> usize {
198        self.entries
199            .iter()
200            .filter_map(|r| r.value().content().map(|c| c.len()))
201            .sum()
202    }
203
204    /// Restore overlay state from the database.
205    ///
206    /// Used when recovering a workspace after a process restart.
207    pub async fn restore_from_db(&self) -> Result<()> {
208        let rows: Vec<(String, Vec<u8>, String, String)> = sqlx::query_as(
209            r#"
210            SELECT file_path, content, content_hash, change_type
211            FROM session_overlay_files
212            WHERE workspace_id = $1
213            "#,
214        )
215        .bind(self.workspace_id)
216        .fetch_all(&self.db)
217        .await?;
218
219        for (path, content, hash, change_type) in rows {
220            let entry = match change_type.as_str() {
221                "added" => OverlayEntry::Added { content, hash },
222                "deleted" => OverlayEntry::Deleted,
223                _ => OverlayEntry::Modified { content, hash },
224            };
225            self.entries.insert(path, entry);
226        }
227
228        Ok(())
229    }
230
231    /// Restore overlay state from a *specific* workspace_id in the database,
232    /// then re-key the rows to the current workspace's id.
233    ///
234    /// Used by [`WorkspaceManager::resume`] to rehydrate a resumed workspace's
235    /// overlay from the old (stranded) workspace's persisted overlay rows.
236    /// After loading, the rows are UPDATE'd to point at `self.workspace_id` so
237    /// that a subsequent eviction of the resumed session can still find them by
238    /// the new workspace id.
239    pub async fn restore_from_workspace_id(
240        &self,
241        db: &sqlx::PgPool,
242        source_workspace_id: uuid::Uuid,
243    ) -> Result<()> {
244        let rows: Vec<(String, Vec<u8>, String, String)> = sqlx::query_as(
245            r#"
246            SELECT file_path, content, content_hash, change_type
247              FROM session_overlay_files
248             WHERE workspace_id = $1
249            "#,
250        )
251        .bind(source_workspace_id)
252        .fetch_all(db)
253        .await?;
254
255        for (path, content, hash, change_type) in rows {
256            let entry = match change_type.as_str() {
257                "added" => OverlayEntry::Added { content, hash },
258                "deleted" => OverlayEntry::Deleted,
259                _ => OverlayEntry::Modified { content, hash },
260            };
261            self.entries.insert(path, entry);
262        }
263
264        // Re-key the persisted rows to the current workspace_id so that a
265        // future eviction of this (resumed) workspace can restore from the DB
266        // using the new workspace id.
267        if source_workspace_id != self.workspace_id {
268            sqlx::query(
269                "UPDATE session_overlay_files
270                    SET workspace_id = $1
271                  WHERE workspace_id = $2",
272            )
273            .bind(self.workspace_id)
274            .bind(source_workspace_id)
275            .execute(db)
276            .await?;
277        }
278
279        Ok(())
280    }
281
282    /// Delete every `session_overlay_files` row for a given workspace.
283    /// Used by `abandon_stranded` to release persisted overlay bytes.
284    pub async fn drop_for_workspace(db: &sqlx::PgPool, workspace_id: uuid::Uuid) -> Result<()> {
285        sqlx::query("DELETE FROM session_overlay_files WHERE workspace_id = $1")
286            .bind(workspace_id)
287            .execute(db)
288            .await?;
289        Ok(())
290    }
291}
292
293// ── Test helpers ─────────────────────────────────────────────────────
294
295impl FileOverlay {
296    /// Create an overlay backed only by an in-memory DashMap (no DB).
297    ///
298    /// Intended for unit/integration tests that do not have a PostgreSQL
299    /// connection. Writes via [`write_local`] go straight to the DashMap.
300    #[doc(hidden)]
301    pub fn new_inmemory(workspace_id: Uuid) -> Self {
302        // Build a PgPool that will never actually connect.  We use
303        // connect_lazy with a dummy DSN — it only errors when a query
304        // is executed, and write_local never touches the pool.
305        let opts = sqlx::postgres::PgConnectOptions::new()
306            .host("__nsi_test_dummy__")
307            .port(1);
308        let pool = sqlx::PgPool::connect_lazy_with(opts);
309        Self {
310            entries: DashMap::new(),
311            workspace_id,
312            db: pool,
313        }
314    }
315
316    /// Write a file to the in-memory overlay WITHOUT touching the database.
317    ///
318    /// This is the test-friendly counterpart of [`write`].
319    #[doc(hidden)]
320    pub fn write_local(&self, path: &str, content: Vec<u8>, is_new: bool) -> String {
321        let hash = format!("{:x}", Sha256::digest(&content));
322
323        let entry = if is_new {
324            OverlayEntry::Added {
325                content,
326                hash: hash.clone(),
327            }
328        } else {
329            OverlayEntry::Modified {
330                content,
331                hash: hash.clone(),
332            }
333        };
334
335        self.entries.insert(path.to_string(), entry);
336        hash
337    }
338
339    /// Mark a file as deleted in the in-memory overlay WITHOUT touching DB.
340    #[doc(hidden)]
341    pub fn delete_local(&self, path: &str) {
342        self.entries.insert(path.to_string(), OverlayEntry::Deleted);
343    }
344}
345
346#[cfg(test)]
347mod tests {
348    use super::*;
349
350    #[test]
351    fn overlay_entry_content_and_hash() {
352        let entry = OverlayEntry::Modified {
353            content: b"hello".to_vec(),
354            hash: "abc".into(),
355        };
356        assert_eq!(entry.content(), Some(b"hello".as_slice()));
357        assert_eq!(entry.hash(), Some("abc"));
358
359        let deleted = OverlayEntry::Deleted;
360        assert!(deleted.content().is_none());
361        assert!(deleted.hash().is_none());
362    }
363
364    #[test]
365    fn overlay_entry_change_type() {
366        assert_eq!(
367            OverlayEntry::Modified {
368                content: vec![],
369                hash: String::new()
370            }
371            .change_type_str(),
372            "modified"
373        );
374        assert_eq!(
375            OverlayEntry::Added {
376                content: vec![],
377                hash: String::new()
378            }
379            .change_type_str(),
380            "added"
381        );
382        assert_eq!(OverlayEntry::Deleted.change_type_str(), "deleted");
383    }
384
385    #[sqlx::test]
386    async fn drop_for_workspace_removes_all_overlay_rows(pool: sqlx::PgPool) {
387        let workspace_id = Uuid::new_v4();
388        let repo_id = Uuid::new_v4();
389
390        // Insert repo (referenced by session_workspaces FK)
391        sqlx::query(
392            "INSERT INTO repositories (id, name, path, created_at)
393             VALUES ($1, $2, $3, now())
394             ON CONFLICT DO NOTHING",
395        )
396        .bind(repo_id)
397        .bind(format!("test-repo-{}", workspace_id))
398        .bind(format!("/tmp/repo-{}", workspace_id))
399        .execute(&pool)
400        .await
401        .unwrap();
402
403        // Insert session workspace (required by FK); id must equal workspace_id so that
404        // the session_overlay_files FK (workspace_id → session_workspaces.id) resolves.
405        sqlx::query(
406            "INSERT INTO session_workspaces (id, session_id, repo_id, agent_id, base_commit_hash, intent)
407             VALUES ($1, $1, $2, 'agent-test', 'initial', 'test')",
408        )
409        .bind(workspace_id)
410        .bind(repo_id)
411        .execute(&pool)
412        .await
413        .unwrap();
414
415        // Insert overlay rows
416        for p in ["a.rs", "b.rs"] {
417            sqlx::query(
418                "INSERT INTO session_overlay_files (workspace_id, file_path, content, content_hash, change_type)
419                 VALUES ($1, $2, $3, 'h', 'modified')",
420            )
421            .bind(workspace_id)
422            .bind(p)
423            .bind(b"c".as_slice())
424            .execute(&pool)
425            .await
426            .unwrap();
427        }
428
429        // Verify rows exist
430        let (count_before,): (i64,) = sqlx::query_as(
431            "SELECT COUNT(*) FROM session_overlay_files WHERE workspace_id = $1",
432        )
433        .bind(workspace_id)
434        .fetch_one(&pool)
435        .await
436        .unwrap();
437        assert_eq!(count_before, 2);
438
439        // Execute drop_for_workspace
440        FileOverlay::drop_for_workspace(&pool, workspace_id)
441            .await
442            .unwrap();
443
444        // Verify all rows deleted
445        let (count_after,): (i64,) = sqlx::query_as(
446            "SELECT COUNT(*) FROM session_overlay_files WHERE workspace_id = $1",
447        )
448        .bind(workspace_id)
449        .fetch_one(&pool)
450        .await
451        .unwrap();
452        assert_eq!(count_after, 0);
453    }
454}