use dashmap::DashMap;
use dk_core::Result;
use sha2::{Digest, Sha256};
use sqlx::PgPool;
use uuid::Uuid;
#[derive(Debug, Clone)]
pub enum OverlayEntry {
Modified { content: Vec<u8>, hash: String },
Added { content: Vec<u8>, hash: String },
Deleted,
}
impl OverlayEntry {
pub fn content(&self) -> Option<&[u8]> {
match self {
Self::Modified { content, .. } | Self::Added { content, .. } => Some(content),
Self::Deleted => None,
}
}
pub fn hash(&self) -> Option<&str> {
match self {
Self::Modified { hash, .. } | Self::Added { hash, .. } => Some(hash),
Self::Deleted => None,
}
}
fn change_type_str(&self) -> &'static str {
match self {
Self::Modified { .. } => "modified",
Self::Added { .. } => "added",
Self::Deleted => "deleted",
}
}
}
pub struct FileOverlay {
entries: DashMap<String, OverlayEntry>,
workspace_id: Uuid,
db: PgPool,
}
impl FileOverlay {
pub fn new(workspace_id: Uuid, db: PgPool) -> Self {
Self {
entries: DashMap::new(),
workspace_id,
db,
}
}
pub fn get(&self, path: &str) -> Option<dashmap::mapref::one::Ref<'_, String, OverlayEntry>> {
self.entries.get(path)
}
pub fn contains(&self, path: &str) -> bool {
self.entries.contains_key(path)
}
pub async fn write(&self, path: &str, content: Vec<u8>, is_new: bool) -> Result<String> {
let hash = format!("{:x}", Sha256::digest(&content));
let entry = if is_new {
OverlayEntry::Added {
content: content.clone(),
hash: hash.clone(),
}
} else {
OverlayEntry::Modified {
content: content.clone(),
hash: hash.clone(),
}
};
let change_type = entry.change_type_str();
sqlx::query(
r#"
INSERT INTO session_overlay_files (workspace_id, file_path, content, content_hash, change_type)
VALUES ($1, $2, $3, $4, $5)
ON CONFLICT (workspace_id, file_path) DO UPDATE
SET content = EXCLUDED.content,
content_hash = EXCLUDED.content_hash,
change_type = EXCLUDED.change_type,
updated_at = NOW()
"#,
)
.bind(self.workspace_id)
.bind(path)
.bind(&content)
.bind(&hash)
.bind(change_type)
.execute(&self.db)
.await?;
self.entries.insert(path.to_string(), entry);
Ok(hash)
}
pub async fn delete(&self, path: &str) -> Result<()> {
let entry = OverlayEntry::Deleted;
let change_type = entry.change_type_str();
sqlx::query(
r#"
INSERT INTO session_overlay_files (workspace_id, file_path, content, content_hash, change_type)
VALUES ($1, $2, '', '', $3)
ON CONFLICT (workspace_id, file_path) DO UPDATE
SET content = EXCLUDED.content,
content_hash = EXCLUDED.content_hash,
change_type = EXCLUDED.change_type,
updated_at = NOW()
"#,
)
.bind(self.workspace_id)
.bind(path)
.bind(change_type)
.execute(&self.db)
.await?;
self.entries.insert(path.to_string(), entry);
Ok(())
}
pub async fn revert(&self, path: &str) -> Result<()> {
self.entries.remove(path);
sqlx::query("DELETE FROM session_overlay_files WHERE workspace_id = $1 AND file_path = $2")
.bind(self.workspace_id)
.bind(path)
.execute(&self.db)
.await?;
Ok(())
}
pub fn list_changes(&self) -> Vec<(String, OverlayEntry)> {
self.entries
.iter()
.map(|r| (r.key().clone(), r.value().clone()))
.collect()
}
pub fn list_paths(&self) -> Vec<String> {
self.entries.iter().map(|r| r.key().clone()).collect()
}
pub fn len(&self) -> usize {
self.entries.len()
}
pub fn is_empty(&self) -> bool {
self.entries.is_empty()
}
pub fn total_bytes(&self) -> usize {
self.entries
.iter()
.filter_map(|r| r.value().content().map(|c| c.len()))
.sum()
}
pub async fn restore_from_db(&self) -> Result<()> {
let rows: Vec<(String, Vec<u8>, String, String)> = sqlx::query_as(
r#"
SELECT file_path, content, content_hash, change_type
FROM session_overlay_files
WHERE workspace_id = $1
"#,
)
.bind(self.workspace_id)
.fetch_all(&self.db)
.await?;
for (path, content, hash, change_type) in rows {
let entry = match change_type.as_str() {
"added" => OverlayEntry::Added { content, hash },
"deleted" => OverlayEntry::Deleted,
_ => OverlayEntry::Modified { content, hash },
};
self.entries.insert(path, entry);
}
Ok(())
}
pub async fn restore_from_workspace_id(
&self,
db: &sqlx::PgPool,
source_workspace_id: uuid::Uuid,
) -> Result<()> {
let rows: Vec<(String, Vec<u8>, String, String)> = sqlx::query_as(
r#"
SELECT file_path, content, content_hash, change_type
FROM session_overlay_files
WHERE workspace_id = $1
"#,
)
.bind(source_workspace_id)
.fetch_all(db)
.await?;
for (path, content, hash, change_type) in rows {
let entry = match change_type.as_str() {
"added" => OverlayEntry::Added { content, hash },
"deleted" => OverlayEntry::Deleted,
_ => OverlayEntry::Modified { content, hash },
};
self.entries.insert(path, entry);
}
if source_workspace_id != self.workspace_id {
sqlx::query(
"UPDATE session_overlay_files
SET workspace_id = $1
WHERE workspace_id = $2",
)
.bind(self.workspace_id)
.bind(source_workspace_id)
.execute(db)
.await?;
}
Ok(())
}
pub async fn drop_for_workspace(db: &sqlx::PgPool, workspace_id: uuid::Uuid) -> Result<()> {
sqlx::query("DELETE FROM session_overlay_files WHERE workspace_id = $1")
.bind(workspace_id)
.execute(db)
.await?;
Ok(())
}
}
impl FileOverlay {
#[doc(hidden)]
pub fn new_inmemory(workspace_id: Uuid) -> Self {
let opts = sqlx::postgres::PgConnectOptions::new()
.host("__nsi_test_dummy__")
.port(1);
let pool = sqlx::PgPool::connect_lazy_with(opts);
Self {
entries: DashMap::new(),
workspace_id,
db: pool,
}
}
#[doc(hidden)]
pub fn write_local(&self, path: &str, content: Vec<u8>, is_new: bool) -> String {
let hash = format!("{:x}", Sha256::digest(&content));
let entry = if is_new {
OverlayEntry::Added {
content,
hash: hash.clone(),
}
} else {
OverlayEntry::Modified {
content,
hash: hash.clone(),
}
};
self.entries.insert(path.to_string(), entry);
hash
}
#[doc(hidden)]
pub fn delete_local(&self, path: &str) {
self.entries.insert(path.to_string(), OverlayEntry::Deleted);
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn overlay_entry_content_and_hash() {
let entry = OverlayEntry::Modified {
content: b"hello".to_vec(),
hash: "abc".into(),
};
assert_eq!(entry.content(), Some(b"hello".as_slice()));
assert_eq!(entry.hash(), Some("abc"));
let deleted = OverlayEntry::Deleted;
assert!(deleted.content().is_none());
assert!(deleted.hash().is_none());
}
#[test]
fn overlay_entry_change_type() {
assert_eq!(
OverlayEntry::Modified {
content: vec![],
hash: String::new()
}
.change_type_str(),
"modified"
);
assert_eq!(
OverlayEntry::Added {
content: vec![],
hash: String::new()
}
.change_type_str(),
"added"
);
assert_eq!(OverlayEntry::Deleted.change_type_str(), "deleted");
}
#[sqlx::test]
async fn drop_for_workspace_removes_all_overlay_rows(pool: sqlx::PgPool) {
let workspace_id = Uuid::new_v4();
let repo_id = Uuid::new_v4();
sqlx::query(
"INSERT INTO repositories (id, name, path, created_at)
VALUES ($1, $2, $3, now())
ON CONFLICT DO NOTHING",
)
.bind(repo_id)
.bind(format!("test-repo-{}", workspace_id))
.bind(format!("/tmp/repo-{}", workspace_id))
.execute(&pool)
.await
.unwrap();
sqlx::query(
"INSERT INTO session_workspaces (id, session_id, repo_id, agent_id, base_commit_hash, intent)
VALUES ($1, $1, $2, 'agent-test', 'initial', 'test')",
)
.bind(workspace_id)
.bind(repo_id)
.execute(&pool)
.await
.unwrap();
for p in ["a.rs", "b.rs"] {
sqlx::query(
"INSERT INTO session_overlay_files (workspace_id, file_path, content, content_hash, change_type)
VALUES ($1, $2, $3, 'h', 'modified')",
)
.bind(workspace_id)
.bind(p)
.bind(b"c".as_slice())
.execute(&pool)
.await
.unwrap();
}
let (count_before,): (i64,) = sqlx::query_as(
"SELECT COUNT(*) FROM session_overlay_files WHERE workspace_id = $1",
)
.bind(workspace_id)
.fetch_one(&pool)
.await
.unwrap();
assert_eq!(count_before, 2);
FileOverlay::drop_for_workspace(&pool, workspace_id)
.await
.unwrap();
let (count_after,): (i64,) = sqlx::query_as(
"SELECT COUNT(*) FROM session_overlay_files WHERE workspace_id = $1",
)
.bind(workspace_id)
.fetch_one(&pool)
.await
.unwrap();
assert_eq!(count_after, 0);
}
}