Skip to content

Commit 34d4d23

Browse files
BunsDevCopilot
andauthored
fix(webhook,worker,store): permission-gate cancel, memory acks, and command supersession (#13) (#49)
Drive-by commenters could steer queued work: the cancel command tombstoned queued reviews at the route (pre-gate), memory acknowledgements replied ungated, and a command review superseded queued reviews at durable insert — all before anyone checked the commander's write access. Now every mutating or acknowledging command lane passes the worker's write gate first: - cancel rides a new adapter-only CancelReviews task; the tombstone happens in the worker after the gate, and below-write commanders get the decline body instead - remember/forget acknowledgements carry the commander and decline below write; status and clarification replies stay ungated - insert-time supersession is auto-review-only: command reviews supersede older queued reviews post-gate via supersede_queued_except, sparing the commanding task itself Re-lands the intent of fix/issue-13-command-gates (pre-durable-store) adapted to the SQLite queue from #2. Signed-off-by: Val Alexander <bunsthedev@gmail.com> Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent dedab27 commit 34d4d23

6 files changed

Lines changed: 469 additions & 85 deletions

File tree

crates/github/src/lib.rs

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -238,6 +238,11 @@ pub enum TaskKind {
238238
issue_number: u64,
239239
body: String,
240240
},
241+
/// Adapter-only cancellation of queued PR reviews (issue #13). The worker
242+
/// gates the commander's write access before tombstoning anything.
243+
CancelReviews {
244+
pr_number: u64,
245+
},
241246
}
242247

243248
/// Structured result envelope written by coven-code --headless.

crates/github/src/tasks.rs

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,9 @@ pub fn surface_of(kind: &TaskKind) -> (u64, String) {
6363
TaskKind::CommandReply { issue_number, .. } => {
6464
(*issue_number, format!("Reply on #{issue_number}"))
6565
}
66+
TaskKind::CancelReviews { pr_number } => {
67+
(*pr_number, format!("Cancel queued reviews on PR #{pr_number}"))
68+
}
6669
}
6770
}
6871

crates/store/src/lib.rs

Lines changed: 120 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -303,14 +303,35 @@ impl Store {
303303
/// Tombstones every still-queued task for a supersession key (the
304304
/// maintainer `cancel` command). Returns how many were cancelled.
305305
pub async fn cancel_queued(&self, supersede_key: &str) -> Result<usize> {
306+
self.supersede_queued(supersede_key, None).await
307+
}
308+
309+
/// Post-gate supersession for a command-initiated review (issue #13):
310+
/// once the worker has verified the commander's write access, older
311+
/// queued reviews of the same PR yield to `current_task_id`.
312+
pub async fn supersede_queued_except(
313+
&self,
314+
supersede_key: &str,
315+
current_task_id: &str,
316+
) -> Result<usize> {
317+
self.supersede_queued(supersede_key, Some(current_task_id.to_string()))
318+
.await
319+
}
320+
321+
async fn supersede_queued(
322+
&self,
323+
supersede_key: &str,
324+
except_task_id: Option<String>,
325+
) -> Result<usize> {
306326
let conn = self.conn.clone();
307327
let key = supersede_key.to_string();
308328
tokio::task::spawn_blocking(move || {
309329
let conn = conn.lock().expect("store mutex poisoned");
310330
let n = conn.execute(
311331
"UPDATE tasks SET state = 'superseded', updated_at = ?1
312-
WHERE supersede_key = ?2 AND state = 'queued'",
313-
params![now_rfc3339(), key],
332+
WHERE supersede_key = ?2 AND state = 'queued'
333+
AND (?3 IS NULL OR id <> ?3)",
334+
params![now_rfc3339(), key, except_task_id],
314335
)?;
315336
Ok(n)
316337
})
@@ -542,13 +563,19 @@ fn record_delivery_sync(
542563
if let Some(task) = task {
543564
let repo = format!("{}/{}", task.repo_owner, task.repo_name);
544565
let supersede_key = supersede_key(task);
545-
if let Some(key) = &supersede_key {
546-
// A newer review of the same PR supersedes anything still queued.
547-
tx.execute(
548-
"UPDATE tasks SET state = 'superseded', updated_at = ?1
549-
WHERE supersede_key = ?2 AND state = 'queued'",
550-
params![now, key],
551-
)?;
566+
// Only adapter-initiated (auto) reviews may tombstone at insert.
567+
// Command-initiated reviews carry a commander whose write access the
568+
// worker has not yet verified — they supersede older queued reviews
569+
// post-gate instead (issue #13), so a drive-by `review` comment can
570+
// never displace legitimate queued work.
571+
if task.commander.is_none() {
572+
if let Some(key) = &supersede_key {
573+
tx.execute(
574+
"UPDATE tasks SET state = 'superseded', updated_at = ?1
575+
WHERE supersede_key = ?2 AND state = 'queued'",
576+
params![now, key],
577+
)?;
578+
}
552579
}
553580
tx.execute(
554581
"INSERT INTO tasks
@@ -929,3 +956,87 @@ mod queue_tests {
929956
assert_eq!(items[0].status, TaskListStatus::Queued);
930957
}
931958
}
959+
960+
#[cfg(test)]
961+
mod command_gate_tests {
962+
//! Insert-time supersession is an auto-review privilege; command reviews
963+
//! wait for the worker's write-access gate (issue #13).
964+
use super::*;
965+
966+
fn delivery(id: &str) -> Delivery {
967+
Delivery {
968+
delivery_id: id.to_string(),
969+
event: "pull_request".to_string(),
970+
action: Some("synchronize".to_string()),
971+
installation_id: Some(1),
972+
repo: Some("OpenCoven/demo".to_string()),
973+
payload_hash: "h".to_string(),
974+
}
975+
}
976+
977+
fn review(id: &str, commander: Option<&str>) -> Task {
978+
Task {
979+
id: id.to_string(),
980+
installation_id: 1,
981+
repo_owner: "OpenCoven".to_string(),
982+
repo_name: "demo".to_string(),
983+
familiar_id: "cody".to_string(),
984+
commander: commander.map(str::to_string),
985+
kind: TaskKind::ReviewPullRequest {
986+
pr_number: 88,
987+
pr_title: "t".to_string(),
988+
reason: "synchronize".to_string(),
989+
},
990+
}
991+
}
992+
993+
#[tokio::test]
994+
async fn command_review_does_not_tombstone_at_insert() {
995+
let store = Store::open_in_memory().expect("open");
996+
store
997+
.record_delivery(delivery("d1"), Routing::Task(&review("auto", None)))
998+
.await
999+
.expect("auto review");
1000+
// An unverified commander's review must not displace queued work.
1001+
store
1002+
.record_delivery(
1003+
delivery("d2"),
1004+
Routing::Task(&review("commanded", Some("drive-by"))),
1005+
)
1006+
.await
1007+
.expect("commanded review");
1008+
1009+
let states: HashMap<String, String> =
1010+
store.task_states().await.unwrap().into_iter().collect();
1011+
assert_eq!(states["auto"], "queued", "insert-time tombstone is auto-only");
1012+
assert_eq!(states["commanded"], "queued");
1013+
}
1014+
1015+
#[tokio::test]
1016+
async fn post_gate_supersession_spares_the_current_task() {
1017+
let store = Store::open_in_memory().expect("open");
1018+
store
1019+
.record_delivery(delivery("d1"), Routing::Task(&review("older", None)))
1020+
.await
1021+
.expect("older review");
1022+
store
1023+
.record_delivery(
1024+
delivery("d2"),
1025+
Routing::Task(&review("commanded", Some("octocat"))),
1026+
)
1027+
.await
1028+
.expect("commanded review");
1029+
1030+
// The worker calls this once the commander passed the write gate.
1031+
let n = store
1032+
.supersede_queued_except("OpenCoven/demo#88", "commanded")
1033+
.await
1034+
.expect("supersede");
1035+
assert_eq!(n, 1);
1036+
1037+
let states: HashMap<String, String> =
1038+
store.task_states().await.unwrap().into_iter().collect();
1039+
assert_eq!(states["older"], "superseded");
1040+
assert_eq!(states["commanded"], "queued", "the commanding task survives");
1041+
}
1042+
}

crates/webhook/src/routes.rs

Lines changed: 35 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -405,8 +405,9 @@ struct CommandSurface<'a> {
405405
commander: &'a str,
406406
}
407407

408-
/// Maps a typed maintainer command to a task. Work commands carry the
409-
/// commander for the worker's permission gate; replies carry none.
408+
/// Maps a typed maintainer command to a task. Work commands and gated
409+
/// acknowledgements (cancel, remember/forget) carry the commander for the
410+
/// worker's permission gate; clarifications and status replies carry none.
410411
async fn command_task(
411412
state: &AppState,
412413
familiar: &coven_github_config::FamiliarConfig,
@@ -472,29 +473,27 @@ async fn command_task(
472473
},
473474
commander,
474475
),
475-
Command::Cancel if s.on_pull_request => {
476-
// Tombstone queued reviews for this PR. In-flight work is not
477-
// interrupted (documented limitation); the next review command or
478-
// PR event re-arms the lane.
479-
let cancelled = state
480-
.store
481-
.cancel_queued(&format!("{repo}#{}", s.number))
482-
.await
483-
.unwrap_or_else(|e| {
484-
warn!("cancel could not reach the store: {e:#}");
485-
0
486-
});
487-
reply(format!(
488-
"Cancelled {cancelled} queued review(s) for PR #{}. Work already running will finish; `@{} review` re-arms the lane.",
489-
s.number,
490-
familiar.bot_username.trim_end_matches("[bot]")
491-
))
492-
}
476+
// Cancellation mutates queued work, so it rides a gated adapter task:
477+
// the worker verifies the commander's write access before tombstoning
478+
// anything (issue #13). In-flight work is not interrupted (documented
479+
// limitation); the next review command or PR event re-arms the lane.
480+
Command::Cancel if s.on_pull_request => make(
481+
TaskKind::CancelReviews {
482+
pr_number: s.number,
483+
},
484+
commander,
485+
),
493486
Command::Cancel => reply("`cancel` currently applies to queued pull-request reviews only.".to_string()),
494-
Command::Remember { .. } | Command::Forget { .. } => reply(
495-
"Noted, but memory persistence is not wired up yet — it lands with the hosted \
496-
memory governance contract (#6). Nothing was stored or deleted."
497-
.to_string(),
487+
// Memory acknowledgements are gated too: only maintainers should hear
488+
// how the familiar handles memory intents.
489+
Command::Remember { .. } | Command::Forget { .. } => make(
490+
TaskKind::CommandReply {
491+
issue_number: s.number,
492+
body: "Noted, but memory persistence is not wired up yet — it lands with the \
493+
hosted memory governance contract (#6). Nothing was stored or deleted."
494+
.to_string(),
495+
},
496+
commander,
498497
),
499498
Command::Status => {
500499
let items = state
@@ -1096,7 +1095,7 @@ mod command_routing_tests {
10961095
}
10971096

10981097
#[tokio::test]
1099-
async fn cancel_command_tombstones_queued_reviews_and_acknowledges() {
1098+
async fn cancel_command_rides_a_gated_task_not_a_route_side_mutation() {
11001099
let state = app_state();
11011100
let queued = Task {
11021101
id: "task-queued".to_string(),
@@ -1115,21 +1114,21 @@ mod command_routing_tests {
11151114

11161115
let task = event_to_task(&state, pr_comment("@coven-cody cancel"))
11171116
.await
1118-
.expect("cancel should acknowledge");
1117+
.expect("cancel should route");
11191118

1119+
// The route must NOT tombstone anything — the worker does, after the
1120+
// commander's write access is verified (issue #13).
11201121
let states = state.store.task_states().await.expect("states");
11211122
assert_eq!(
11221123
states,
1123-
vec![("task-queued".to_string(), "superseded".to_string())],
1124-
"queued review must be superseded by the cancel tombstone"
1124+
vec![("task-queued".to_string(), "queued".to_string())],
1125+
"queued review must be untouched until the gate passes"
11251126
);
11261127
match task.kind {
1127-
TaskKind::CommandReply { issue_number, body } => {
1128-
assert_eq!(issue_number, 88);
1129-
assert!(body.contains("Cancelled 1 queued review"));
1130-
}
1131-
other => panic!("expected CommandReply, got {other:?}"),
1128+
TaskKind::CancelReviews { pr_number } => assert_eq!(pr_number, 88),
1129+
other => panic!("expected CancelReviews, got {other:?}"),
11321130
}
1131+
assert_eq!(task.commander.as_deref(), Some("octocat"));
11331132
}
11341133

11351134
#[tokio::test]
@@ -1138,6 +1137,9 @@ mod command_routing_tests {
11381137
let task = event_to_task(&state, pr_comment("@coven-cody remember we ship Fridays"))
11391138
.await
11401139
.expect("remember should acknowledge");
1140+
// The acknowledgement is gated: it carries the commander so the
1141+
// worker verifies write access before replying (issue #13).
1142+
assert_eq!(task.commander.as_deref(), Some("octocat"));
11411143
match task.kind {
11421144
TaskKind::CommandReply { body, .. } => {
11431145
assert!(body.contains("#6"));

crates/worker/src/brief.rs

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -114,9 +114,11 @@ pub fn build(
114114
// adapter-initiated review lane rides on pr_review_comment plus
115115
// review_context until native pull_request/push triggers land in v3.
116116
TaskKind::ReviewPullRequest { .. } => "pr_review_comment",
117-
// CommandReply is executed adapter-side before briefing (issue #13);
118-
// this arm is a safe fallback, not an expected path.
117+
// CommandReply and CancelReviews are executed adapter-side before
118+
// briefing (issue #13); these arms are safe fallbacks, not expected
119+
// paths.
119120
TaskKind::CommandReply { .. } => "issue_mention",
121+
TaskKind::CancelReviews { .. } => "issue_mention",
120122
};
121123

122124
let task_brief = match &task.kind {
@@ -149,6 +151,10 @@ pub fn build(
149151
issue_number: *issue_number,
150152
comment_body: body.clone(),
151153
},
154+
TaskKind::CancelReviews { pr_number } => TaskBrief::RespondToMention {
155+
issue_number: *pr_number,
156+
comment_body: format!("Cancel queued reviews for PR #{pr_number}."),
157+
},
152158
TaskKind::ReviewPullRequest {
153159
pr_number,
154160
pr_title,

0 commit comments

Comments
 (0)