Skip to content

Commit 11ca6e8

Browse files
KooshaPariforge-code-agent
authored andcommitted
fix(repo): scope daemon deletes to workspace
Co-Authored-By: ForgeCode <noreply@forgecode.dev>
1 parent baf55f6 commit 11ca6e8

4 files changed

Lines changed: 31 additions & 26 deletions

File tree

crates/forge_dbd/src/protocol.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@ pub enum Request {
3232
},
3333
DeleteConversation {
3434
conversation_id: ConversationId,
35+
workspace_id: i64,
3536
},
3637
OptimizeFts,
3738
RefreshFts,

crates/forge_dbd/src/server.rs

Lines changed: 17 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -584,18 +584,10 @@ impl DbServer {
584584
)?;
585585
Ok(Response::Ack)
586586
}
587-
Request::DeleteConversation { conversation_id } => {
588-
// conversation_id is the table's PRIMARY KEY (a globally
589-
// unique UUID; see forge_repo's database/schema.rs), so it
590-
// uniquely identifies the row. forge_repo additionally
591-
// filters by workspace_id as a cross-workspace guard, but the
592-
// daemon derives workspace_id from ITS OWN current_dir hash,
593-
// which can diverge from the client's (e.g. --directory mode
594-
// canonicalizes paths on Windows). Reusing that predicate here
595-
// would silently no-op the delete, so we key on the unique id.
587+
Request::DeleteConversation { conversation_id, workspace_id } => {
596588
conn.execute(
597-
"DELETE FROM conversations WHERE conversation_id = ?",
598-
rusqlite::params![conversation_id.into_string()],
589+
"DELETE FROM conversations WHERE conversation_id = ? AND workspace_id = ?",
590+
rusqlite::params![conversation_id.into_string(), workspace_id],
599591
)?;
600592
Ok(Response::Ack)
601593
}
@@ -1143,15 +1135,10 @@ mod db_tests {
11431135
assert_eq!(is_compressed, 0);
11441136
}
11451137

1146-
/// Delete keys on the conversation_id PRIMARY KEY alone: the row is
1147-
/// removed even when its workspace_id differs from the daemon's, because
1148-
/// the daemon's own current_dir hash can diverge from the client's (e.g.
1149-
/// --directory canonicalization on Windows) and a workspace-filtered
1150-
/// predicate would silently no-op. forge_repo's workspace guard is a
1151-
/// cross-user boundary that has no meaning inside a local single-user
1152-
/// daemon.
1138+
/// A delete request must not remove an identically-addressed conversation
1139+
/// from a different workspace.
11531140
#[test]
1154-
fn delete_conversation_removes_row_by_unique_id_across_workspaces() {
1141+
fn delete_conversation_does_not_remove_row_from_another_workspace() {
11551142
let dir = TempDir::new().unwrap();
11561143
let conn = rusqlite::Connection::open(dir.path().join("test.db")).unwrap();
11571144
create_conversations_schema(&conn);
@@ -1167,18 +1154,20 @@ mod db_tests {
11671154
Response::Ack
11681155
));
11691156

1170-
// Simulate a workspace_id mismatch (client resolved a different cwd):
1171-
// the daemon must still delete the row by its unique id.
1157+
let other_workspace_id = DbServer::workspace_id() + 1;
11721158
conn.execute(
11731159
"UPDATE conversations SET workspace_id = ?1 WHERE conversation_id = ?2",
1174-
rusqlite::params![DbServer::workspace_id() + 1, id.into_string()],
1160+
rusqlite::params![other_workspace_id, id.into_string()],
11751161
)
11761162
.unwrap();
11771163

11781164
assert!(matches!(
11791165
DbServer::execute_with_conn(
11801166
&conn,
1181-
&Request::DeleteConversation { conversation_id: id }
1167+
&Request::DeleteConversation {
1168+
conversation_id: id,
1169+
workspace_id: DbServer::workspace_id(),
1170+
}
11821171
)
11831172
.expect("delete"),
11841173
Response::Ack
@@ -1187,7 +1176,7 @@ mod db_tests {
11871176
let total: i64 = conn
11881177
.query_row("SELECT COUNT(*) FROM conversations", [], |row| row.get(0))
11891178
.unwrap();
1190-
assert_eq!(total, 0, "row deleted despite the workspace_id mismatch");
1179+
assert_eq!(total, 1, "row from the other workspace must remain");
11911180
}
11921181

11931182
/// update_parent_id mirrors forge_repo: it sets parent_id (and stamps
@@ -1258,7 +1247,10 @@ mod db_tests {
12581247

12591248
let (response_tx, response_rx) = tokio::sync::oneshot::channel();
12601249
let mut batch = vec![QueuedRequest {
1261-
request: Request::DeleteConversation { conversation_id: ConversationId::default() },
1250+
request: Request::DeleteConversation {
1251+
conversation_id: ConversationId::default(),
1252+
workspace_id: DbServer::workspace_id(),
1253+
},
12621254
response_tx,
12631255
}];
12641256
DbServer::flush_batch(&mut batch, &mut conn, &db_path).await;

crates/forge_repo/src/conversation/conversation_repo.rs

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,11 @@ impl ConversationRepositoryImpl {
7171
Self { pool, wid: workspace_id }
7272
}
7373

74+
/// Returns the workspace scope used for all conversation mutations.
75+
pub(crate) fn workspace_id(&self) -> i64 {
76+
self.wid.id() as i64
77+
}
78+
7479
async fn run_blocking<F, T>(&self, operation: F) -> anyhow::Result<T>
7580
where
7681
F: FnOnce(Arc<DatabasePool>, WorkspaceHash) -> anyhow::Result<T> + Send + 'static,

crates/forge_repo/src/daemon_repo.rs

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -340,7 +340,10 @@ impl ConversationRepository for DaemonConversationRepository {
340340

341341
async fn delete_conversation(&self, conversation_id: &ConversationId) -> anyhow::Result<()> {
342342
self.write_or_fallback(
343-
Request::DeleteConversation { conversation_id: *conversation_id },
343+
Request::DeleteConversation {
344+
conversation_id: *conversation_id,
345+
workspace_id: self.inner.workspace_id(),
346+
},
344347
|| self.inner.delete_conversation(conversation_id),
345348
)
346349
.await
@@ -514,10 +517,13 @@ mod tests {
514517
use std::path::PathBuf;
515518
use std::sync::Arc;
516519

520+
#[cfg(unix)]
517521
use forge_dbd::protocol::read_frame;
518522
use forge_domain::{Conversation, ConversationId, WorkspaceHash};
519523
use pretty_assertions::assert_eq;
524+
#[cfg(unix)]
520525
use tokio::net::UnixListener;
526+
#[cfg(unix)]
521527
use tokio::sync::oneshot;
522528

523529
use super::*;
@@ -618,6 +624,7 @@ mod tests {
618624
/// Once a daemon accepts a write request, a lost ACK is indeterminate: it
619625
/// may have committed the write, so the decorator must return an explicit
620626
/// error instead of replaying the request through the direct repository.
627+
#[cfg(unix)]
621628
#[tokio::test]
622629
async fn does_not_fallback_after_daemon_records_request_then_loses_ack() -> anyhow::Result<()> {
623630
let temp_dir = tempfile::tempdir()?;

0 commit comments

Comments
 (0)