1use dashmap::DashMap;
8use dk_core::Result;
9use sha2::{Digest, Sha256};
10use sqlx::PgPool;
11use uuid::Uuid;
12
13#[derive(Debug, Clone)]
17pub enum OverlayEntry {
18 Modified { content: Vec<u8>, hash: String },
20 Added { content: Vec<u8>, hash: String },
22 Deleted,
24}
25
26impl OverlayEntry {
27 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 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 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
53pub struct FileOverlay {
60 entries: DashMap<String, OverlayEntry>,
61 workspace_id: Uuid,
62 db: PgPool,
63}
64
65impl FileOverlay {
66 pub fn new(workspace_id: Uuid, db: PgPool) -> Self {
68 Self {
69 entries: DashMap::new(),
70 workspace_id,
71 db,
72 }
73 }
74
75 pub fn get(&self, path: &str) -> Option<dashmap::mapref::one::Ref<'_, String, OverlayEntry>> {
77 self.entries.get(path)
78 }
79
80 pub fn contains(&self, path: &str) -> bool {
82 self.entries.contains_key(path)
83 }
84
85 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 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 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 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 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 pub fn list_paths(&self) -> Vec<String> {
183 self.entries.iter().map(|r| r.key().clone()).collect()
184 }
185
186 pub fn len(&self) -> usize {
188 self.entries.len()
189 }
190
191 pub fn is_empty(&self) -> bool {
193 self.entries.is_empty()
194 }
195
196 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 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 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 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 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
293impl FileOverlay {
296 #[doc(hidden)]
301 pub fn new_inmemory(workspace_id: Uuid) -> Self {
302 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 #[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 #[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 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 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 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 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 FileOverlay::drop_for_workspace(&pool, workspace_id)
441 .await
442 .unwrap();
443
444 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}