gatewayd: approvals answered from Mattermost; typing paused; the notice

Implemented-By: OpenCode session (model recorded in docs/implementer-log.md)
This commit is contained in:
2026-09-24 10:19:41 -07:00
parent af7137dafe
commit edf9ba3515
12 changed files with 472 additions and 25 deletions
+15 -6
View File
@@ -1,6 +1,6 @@
//! A turn on `loop.sock` and its answer in the thread, against a fake `loopd`: the answer, long
//! answers, approvals, errors, an unknown session, and a loop that is not there (M4a spec, section
//! 8). Do not edit.
//! 8; M4b spec, section 7, for the notice). Do not edit.
#[path = "support/fake_loop.rs"]
mod fake_loop;
@@ -11,7 +11,7 @@ use std::sync::Mutex;
use std::time::Duration;
use fake_loop::{Reply, done, error, event, serve_loop};
use gatewayd::deliver::{EMPTY_ANSWER, LOOP_DOWN, MAX_POST, Poster, deliver, split_answer};
use gatewayd::deliver::{EMPTY_ANSWER, LOOP_DOWN, MAX_POST, Notice, Poster, deliver, split_answer};
use gatewayd::mm::MmError;
use gatewayd::sessions::{Batch, Thread};
use proto::{DataClass, ErrorCode, SessionId, Timestamp, TurnEvent};
@@ -64,9 +64,18 @@ fn batch(resume: bool, text: &str) -> Batch {
fn run(dir: &TempDir, poster: &Record, batch: &Batch) -> Vec<String> {
let log = Mutex::new(Vec::new());
deliver(poster, &dir.path().join("loop.sock"), batch, &|line| {
log.lock().unwrap().push(line.to_string())
});
let never = |_: u64| false;
let notice = Notice {
posted: &never,
wait: Duration::ZERO,
};
deliver(
poster,
&dir.path().join("loop.sock"),
batch,
&|line| log.lock().unwrap().push(line.to_string()),
&notice,
);
log.into_inner().unwrap()
}
@@ -127,7 +136,7 @@ fn an_approval_is_announced_once_before_the_answer() {
assert_eq!(
poster.texts(),
[
"waiting for approval 42: approve or deny it with `bxctl` (Mattermost approvals arrive in M4b)",
"waiting for approval 42: brokerd has not sent it here; answer it with bxctl approvals",
"done"
]
);
+95
View File
@@ -0,0 +1,95 @@
//! The notice for an approval that `brokerd` did not bring: posted only when it has not arrived
//! within the wait (M4b spec, section 7). Do not edit.
#[path = "support/fake_loop.rs"]
mod fake_loop;
#[path = "support/tmp.rs"]
mod tmp;
use std::sync::Mutex;
use std::time::{Duration, Instant};
use fake_loop::{done, event, serve_loop};
use gatewayd::deliver::{Notice, Poster, deliver};
use gatewayd::mm::MmError;
use gatewayd::sessions::{Batch, Thread};
use proto::{SessionId, Timestamp, TurnEvent};
use tmp::TempDir;
const DM: &str = "d0000000000000000000000000";
const ROOT: &str = "r0000000000000000000000000";
const NOTICE: &str =
"waiting for approval 42: brokerd has not sent it here; answer it with bxctl approvals";
#[derive(Default)]
struct Record(Mutex<Vec<String>>);
impl Poster for Record {
fn post(&self, _: &str, _: &str, text: &str) -> Result<(), MmError> {
self.0.lock().unwrap().push(text.to_string());
Ok(())
}
}
/// A turn whose loop says approval 42 is pending, then answers; what was posted, and how long it took.
fn turn(tag: &str, posted: &dyn Fn(u64) -> bool, wait: Duration) -> (Vec<String>, Duration) {
let dir = TempDir::new(tag);
let socket = dir.path().join("loop.sock");
let expires = Timestamp::parse("2026-09-24T08:20:00.000Z").unwrap();
let _turns = serve_loop(&socket, move |_, _| {
vec![
event(TurnEvent::ApprovalPending {
approval: 42,
tool: "read_file".to_string(),
expires,
}),
done("done"),
]
});
let batch = Batch {
session: SessionId::new(&format!("mm-{ROOT}")).unwrap(),
thread: Thread {
channel: DM.to_string(),
root: ROOT.to_string(),
},
resume: false,
text: "go".to_string(),
};
let poster = Record::default();
let started = Instant::now();
deliver(&poster, &socket, &batch, &|_| {}, &Notice { posted, wait });
let took = started.elapsed();
(poster.0.into_inner().unwrap(), took)
}
#[test]
fn brought_already_no_notice_and_no_wait() {
let (posts, took) = turn("notice-there", &|id| id == 42, Duration::from_secs(5));
assert_eq!(posts, ["done"]);
assert!(took < Duration::from_secs(1), "{took:?}");
}
#[test]
fn brought_during_the_wait_no_notice() {
let started = Instant::now();
let later = move |id: u64| id == 42 && started.elapsed() > Duration::from_millis(300);
let (posts, took) = turn("notice-later", &later, Duration::from_secs(5));
assert_eq!(posts, ["done"]);
assert!(took < Duration::from_secs(2), "{took:?}");
}
#[test]
fn never_brought_the_notice_after_the_wait() {
let (posts, took) = turn("notice-never", &|_| false, Duration::from_millis(400));
assert_eq!(posts, [NOTICE, "done"]);
assert!(
took >= Duration::from_millis(400) && took < Duration::from_secs(3),
"{took:?}"
);
}
#[test]
fn another_approval_brought_does_not_count() {
let (posts, _) = turn("notice-other", &|id| id == 7, Duration::from_millis(200));
assert_eq!(posts, [NOTICE, "done"]);
}
+6 -2
View File
@@ -17,7 +17,7 @@ use fake_loop::{done, serve_loop};
use fake_mm::{BOT, BOT_NAME, DM, EVE, EVE_DM, FakeMm, KYLE, id, post};
use gateway::{OTHER, SHARED, WAIT, config, loop_dir, read_state, start, up};
use gatewayd::serve::Stop;
use gatewayd::sessions::{BUSY, M4B_COMMAND};
use gatewayd::sessions::BUSY;
use tmp::TempDir;
#[test]
@@ -204,7 +204,11 @@ fn commands_are_answered_without_a_turn() {
ws.posted(&post(&p1, KYLE, DM, "", "!approve 3", 5), "D");
assert_eq!(
mm.wait_posts(1, WAIT),
[(DM.to_string(), p1, M4B_COMMAND.to_string())]
[(
DM.to_string(),
p1,
"approval 3 is not pending here".to_string()
)]
);
assert!(turns.recv_timeout(Duration::from_millis(200)).is_err());
}
+248
View File
@@ -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();
}
+1
View File
@@ -98,6 +98,7 @@ pub fn start(config: Config) -> Running {
backoff: vec![Duration::from_millis(50)],
poll: Duration::from_millis(20),
rest_timeout: WAIT,
notice: Duration::from_millis(300),
};
let (s, l) = (Arc::clone(&stop), Arc::clone(&log));
let handle = std::thread::spawn(move || {