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:
@@ -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()),
|
||||
¬ice,
|
||||
);
|
||||
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"
|
||||
]
|
||||
);
|
||||
|
||||
@@ -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"]);
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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 || {
|
||||
|
||||
Reference in New Issue
Block a user