Plan M4b: approvals over Mattermost in 15 tasks
Skeletons for new files only; existing files change by exact edits, so no task overwrites an earlier one's work. Each task's tests were run against a reference built in order from master, with the gate at each step and mutations checked; each skeleton compiles and fails its tests, and every todo!() comment was read beside its signature. The reference is kept off this machine. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,248 @@
|
||||
//! Approvals answered from Mattermost end to end: `!approve` and `!deny` from an approver in the
|
||||
//! approval's thread reach `brokerd`, the post shows what came of it, typing pauses while the turn
|
||||
//! waits, and everything else is answered with its reason; the notice comes only when `brokerd`
|
||||
//! never brought the approval (M4b spec, section 7). Do not edit.
|
||||
|
||||
#[path = "support/brokerd_client.rs"]
|
||||
mod brokerd_client;
|
||||
#[path = "support/fake_loop.rs"]
|
||||
mod fake_loop;
|
||||
#[path = "support/fake_mm.rs"]
|
||||
mod fake_mm;
|
||||
#[path = "support/gateway.rs"]
|
||||
mod gateway;
|
||||
#[path = "support/tmp.rs"]
|
||||
mod tmp;
|
||||
|
||||
use std::path::PathBuf;
|
||||
use std::sync::{Mutex, mpsc};
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use brokerd_client::{Asking, ask, pending};
|
||||
use fake_loop::{done, event, serve_loop};
|
||||
use fake_mm::{DM, EVE, FakeMm, KYLE, WsPeer, id, post};
|
||||
use gateway::{Running, WAIT, loop_dir, start};
|
||||
use gatewayd::approvals::NOT_APPROVER;
|
||||
use gatewayd::config::Config;
|
||||
use proto::{GatewayOutcome, Timestamp, Turn, TurnEvent};
|
||||
use tmp::TempDir;
|
||||
|
||||
/// KYLE and EVE may talk; only KYLE approves.
|
||||
fn config(home: &TempDir, url: &str) -> Config {
|
||||
let text = format!(
|
||||
"[mattermost]\nurl = \"{url}\"\n[secrets.mattermost_token]\nenv = \"NOT_READ_BY_RUN\"\n[allow]\nusers = [\"{KYLE}\", \"{EVE}\"]\n[approvals]\nusers = [\"{KYLE}\"]\n[paths]\nhome = \"{}\"\n[limits]\ntyping_every_ms = 100\n",
|
||||
home.path().display()
|
||||
);
|
||||
Config::parse(&text).unwrap()
|
||||
}
|
||||
|
||||
/// A loop whose first turn waits for `go`, then sends `before` and answers "done".
|
||||
fn held(home: &TempDir, before: Vec<TurnEvent>) -> (mpsc::Receiver<Turn>, mpsc::Sender<()>) {
|
||||
loop_dir(home);
|
||||
let (go_tx, go_rx) = mpsc::channel::<()>();
|
||||
let go = Mutex::new(go_rx);
|
||||
let before = Mutex::new(Some(before));
|
||||
let turns = serve_loop(&home.path().join("run/loop/loop.sock"), move |_, _| {
|
||||
let _ = go.lock().unwrap().recv_timeout(Duration::from_secs(20));
|
||||
let mut replies: Vec<_> = before
|
||||
.lock()
|
||||
.unwrap()
|
||||
.take()
|
||||
.unwrap_or_default()
|
||||
.into_iter()
|
||||
.map(event)
|
||||
.collect();
|
||||
replies.push(done("done"));
|
||||
replies
|
||||
});
|
||||
(turns, go_tx)
|
||||
}
|
||||
|
||||
struct Stage {
|
||||
_home: TempDir,
|
||||
mm: FakeMm,
|
||||
_running: Running,
|
||||
ws: WsPeer,
|
||||
go: mpsc::Sender<()>,
|
||||
socket: PathBuf,
|
||||
root: String,
|
||||
}
|
||||
|
||||
/// gatewayd up, a direct message from KYLE whose turn is held, and the approval's socket.
|
||||
fn stage(tag: &str, before: Vec<TurnEvent>) -> Stage {
|
||||
let home = TempDir::new(tag);
|
||||
let (turns, go) = held(&home, before);
|
||||
let mm = FakeMm::start();
|
||||
let running = start(config(&home, &mm.url()));
|
||||
let mut ws = mm.next_ws(WAIT);
|
||||
running.wait_log("gatewayd: connected to ");
|
||||
let root = id('p', 1);
|
||||
ws.posted(&post(&root, KYLE, DM, "", "read my notes", 5), "D");
|
||||
turns.recv_timeout(WAIT).expect("the turn started");
|
||||
let socket = home.path().join("run/gateway/gateway.sock");
|
||||
Stage {
|
||||
_home: home,
|
||||
mm,
|
||||
_running: running,
|
||||
ws,
|
||||
go,
|
||||
socket,
|
||||
root,
|
||||
}
|
||||
}
|
||||
|
||||
impl Stage {
|
||||
fn ask(&self, approval: u64) -> Asking {
|
||||
ask(
|
||||
&self.socket,
|
||||
pending(approval, &format!("mm-{}", self.root), r#"{"path":"/n/a"}"#),
|
||||
)
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
fn command(&mut self, n: u32, user: &str, root: &str, text: &str, at: i64) {
|
||||
self.ws
|
||||
.posted(&post(&id('c', n), user, DM, root, text, at), "D");
|
||||
}
|
||||
|
||||
/// The text posted in the thread after the approval, within five seconds.
|
||||
fn replies(&self, after: usize) -> Vec<String> {
|
||||
let posts = self.mm.wait_posts(after + 1, WAIT);
|
||||
posts[after..].iter().map(|(_, _, t)| t.clone()).collect()
|
||||
}
|
||||
}
|
||||
|
||||
fn wait_until(what: &str, mut done: impl FnMut() -> bool) {
|
||||
let until = Instant::now() + WAIT;
|
||||
while !done() {
|
||||
assert!(Instant::now() < until, "never happened: {what}");
|
||||
std::thread::sleep(Duration::from_millis(20));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn approved_by_the_approver_the_post_says_it_runs_and_typing_pauses_meanwhile() {
|
||||
let mut s = stage("ans-approve", vec![]);
|
||||
s.ws.typing_within(Duration::from_millis(300));
|
||||
let mut asking = s.ask(42);
|
||||
s.ws.typing_within(Duration::from_millis(200));
|
||||
assert!(
|
||||
s.ws.typing_within(Duration::from_millis(400)).is_empty(),
|
||||
"no typing while it waits for approval"
|
||||
);
|
||||
let root = s.root.clone();
|
||||
s.command(1, KYLE, &root, "!approve 42", 5);
|
||||
let reply = asking.answer(WAIT).expect("the answer reaches brokerd");
|
||||
assert_eq!(
|
||||
(
|
||||
reply.approval,
|
||||
reply.approved,
|
||||
reply.by.as_str(),
|
||||
reply.post.clone(),
|
||||
reply.reason.clone()
|
||||
),
|
||||
(42, true, KYLE, id('c', 1), None)
|
||||
);
|
||||
asking.outcome(GatewayOutcome::Runs {});
|
||||
let edits = s.mm.wait_edits(1, WAIT);
|
||||
assert_eq!(edits[0].0, FakeMm::post_id(0));
|
||||
assert!(
|
||||
edits[0]
|
||||
.1
|
||||
.ends_with("\n\n✅ Approved by @kyle: the call runs."),
|
||||
"{}",
|
||||
edits[0].1
|
||||
);
|
||||
assert_eq!(s.mm.posts().len(), 1, "nothing else posted in the thread");
|
||||
wait_until("typing again", || {
|
||||
!s.ws.typing_within(Duration::from_millis(300)).is_empty()
|
||||
});
|
||||
s.go.send(()).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn refused_with_a_reason_the_post_says_so() {
|
||||
let mut s = stage("ans-deny", vec![]);
|
||||
let mut asking = s.ask(42);
|
||||
let root = s.root.clone();
|
||||
s.command(1, KYLE, &root, "@boxmaker-straylight !deny 42 not now", 5);
|
||||
let reply = asking.answer(WAIT).expect("the answer reaches brokerd");
|
||||
assert_eq!(
|
||||
(reply.approved, reply.reason.as_deref()),
|
||||
(false, Some("not now"))
|
||||
);
|
||||
asking.outcome(GatewayOutcome::Refused {});
|
||||
let edits = s.mm.wait_edits(1, WAIT);
|
||||
assert!(
|
||||
edits[0].1.ends_with("\n\n❌ Refused by @kyle: `not now`."),
|
||||
"{}",
|
||||
edits[0].1
|
||||
);
|
||||
s.go.send(()).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn nobody_else_and_nothing_else_answers_it() {
|
||||
let mut s = stage("ans-refused", vec![]);
|
||||
let mut asking = s.ask(42);
|
||||
let root = s.root.clone();
|
||||
let elsewhere = id('p', 9);
|
||||
s.command(1, EVE, &root, "!approve 42", 5);
|
||||
s.command(2, KYLE, &elsewhere, "!approve 42", 6);
|
||||
s.command(3, KYLE, &root, "!approve 42", 1);
|
||||
s.command(4, KYLE, &root, "!approve 7", 7);
|
||||
s.command(5, KYLE, &root, "!approve 42 please", 8);
|
||||
assert_eq!(
|
||||
s.replies(1)[..5],
|
||||
[
|
||||
NOT_APPROVER.to_string(),
|
||||
"approval 42 is answered in its own thread".to_string(),
|
||||
"approval 42 was answered before it was shown in full".to_string(),
|
||||
"approval 7 is not pending here".to_string(),
|
||||
"use !approve <id> or !deny <id> <reason>".to_string(),
|
||||
]
|
||||
);
|
||||
assert_eq!(
|
||||
asking.answer(Duration::from_millis(300)),
|
||||
None,
|
||||
"nothing reached brokerd"
|
||||
);
|
||||
s.command(6, KYLE, &root, "!approve 42", 9);
|
||||
assert!(
|
||||
asking.answer(WAIT).is_some(),
|
||||
"the approver, in the thread, after the post"
|
||||
);
|
||||
asking.outcome(GatewayOutcome::Runs {});
|
||||
s.go.send(()).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_notice_only_when_brokerd_never_brought_it() {
|
||||
let expires = Timestamp::parse("2026-09-24T08:20:00.000Z").unwrap();
|
||||
let pending_event = TurnEvent::ApprovalPending {
|
||||
approval: 42,
|
||||
tool: "read_file".to_string(),
|
||||
expires,
|
||||
};
|
||||
let s = stage("ans-notice", vec![pending_event.clone()]);
|
||||
s.go.send(()).unwrap();
|
||||
let posts = s.mm.wait_posts(2, WAIT);
|
||||
let texts: Vec<&str> = posts.iter().map(|(_, _, t)| t.as_str()).collect();
|
||||
assert_eq!(
|
||||
texts,
|
||||
[
|
||||
"waiting for approval 42: brokerd has not sent it here; answer it with bxctl approvals",
|
||||
"done"
|
||||
]
|
||||
);
|
||||
|
||||
let s = stage("ans-no-notice", vec![pending_event]);
|
||||
let asking = s.ask(42);
|
||||
s.go.send(()).unwrap();
|
||||
let posts = s.mm.wait_posts(2, WAIT);
|
||||
assert!(posts[0].2.starts_with("**Approval 42**"), "{posts:?}");
|
||||
assert_eq!(posts[1].2, "done");
|
||||
std::thread::sleep(Duration::from_millis(500));
|
||||
assert_eq!(s.mm.posts().len(), 2, "no notice: {:?}", s.mm.posts());
|
||||
asking.close();
|
||||
}
|
||||
Reference in New Issue
Block a user