gatewayd: approvals asked by non-approvers in direct messages go to an approver
Implemented-By: OpenCode session (model recorded in docs/implementer-log.md)
This commit is contained in:
@@ -7,11 +7,30 @@ use std::sync::mpsc::Sender;
|
|||||||
use proto::{ApprovalReply, GatewayOutcome, PendingApproval};
|
use proto::{ApprovalReply, GatewayOutcome, PendingApproval};
|
||||||
|
|
||||||
use crate::approvals::Posted;
|
use crate::approvals::Posted;
|
||||||
use crate::approvals::text::{self, Ended};
|
use crate::approvals::text::{self, Asked, Ended};
|
||||||
use crate::gateway::{Ending, ToLoop};
|
use crate::gateway::{Ending, ToLoop};
|
||||||
use crate::serve::{Gateway, Stop};
|
use crate::serve::{Gateway, Stop};
|
||||||
use crate::state::ApprovalPosts;
|
use crate::state::ApprovalPosts;
|
||||||
|
|
||||||
|
/// Who asked for the turn now running in a session: the author of the last message queued, the
|
||||||
|
/// channel's type, and the turn's text. Kept in memory only, and forgotten when the turn ends.
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub(super) struct Asker {
|
||||||
|
pub(super) user: String,
|
||||||
|
pub(super) channel_type: String,
|
||||||
|
pub(super) prompt: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Where an approval goes, and what is posted besides it.
|
||||||
|
struct Place {
|
||||||
|
channel: String,
|
||||||
|
root: String,
|
||||||
|
/// A post before the approval's own: who asked and what, or whose session.
|
||||||
|
first: Option<String>,
|
||||||
|
/// The asking thread, told where the approval went.
|
||||||
|
note: Option<(String, String)>,
|
||||||
|
}
|
||||||
|
|
||||||
/// An approval's last post, the one that is edited when it ends.
|
/// An approval's last post, the one that is edited when it ends.
|
||||||
#[derive(Debug)]
|
#[derive(Debug)]
|
||||||
pub(super) struct Open {
|
pub(super) struct Open {
|
||||||
@@ -38,44 +57,85 @@ impl Gateway {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Where an approval goes: its session's turn in flight, or the first approver's direct
|
/// Where an approval goes (M4b spec, section 6): its turn's thread; or, when a non-approver
|
||||||
/// messages as a new thread (an empty root).
|
/// asked in a direct or group message, the first approver's direct messages with who asked
|
||||||
fn place(&self, info: &PendingApproval) -> Result<(String, String), String> {
|
/// and what, and a note in the asking thread; or, for a session not followed here, the first
|
||||||
// 1. `self.state.turn_of(info.session.as_str())`: if Some(turn), `Ok((turn.channel,
|
/// approver's direct messages with a line saying so.
|
||||||
// turn.root))`.
|
fn place(&self, info: &PendingApproval) -> Result<Place, String> {
|
||||||
// 2. Otherwise the first approver's direct messages, as a new thread:
|
let id = info.approval;
|
||||||
// `self.config.first_approver()` (None is Err("no approver is configured")), then
|
let session = info.session.as_str();
|
||||||
// `self.client.direct_channel(&self.me.id, first)` (an Err(e) is Err(format!("no direct
|
let Some(turn) = self.state.turn_of(session) else {
|
||||||
// channel with the first approver: {e}"))). `Ok((channel, String::new()))`.
|
return Ok(Place {
|
||||||
if let Some(turn) = self.state.turn_of(info.session.as_str()) {
|
channel: self.approver_dm()?,
|
||||||
return Ok((turn.channel, turn.root));
|
root: String::new(),
|
||||||
}
|
first: Some(text::unfollowed_post(id, session)),
|
||||||
let first = match self.config.first_approver() {
|
note: None,
|
||||||
Some(first) => first,
|
});
|
||||||
None => return Err("no approver is configured".to_string()),
|
|
||||||
};
|
};
|
||||||
match self.client.direct_channel(&self.me.id, first) {
|
let redirect = self.askers.get(session).filter(|a| {
|
||||||
Ok(channel) => Ok((channel, String::new())),
|
matches!(a.channel_type.as_str(), "D" | "G")
|
||||||
Err(e) => Err(format!("no direct channel with the first approver: {e}")),
|
&& !self.config.approvers().contains(&a.user)
|
||||||
}
|
});
|
||||||
|
let Some(asker) = redirect else {
|
||||||
|
return Ok(Place {
|
||||||
|
channel: turn.channel,
|
||||||
|
root: turn.root,
|
||||||
|
first: None,
|
||||||
|
note: None,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
let by = match self.client.username(&asker.user) {
|
||||||
|
Ok(name) => format!("@{name}"),
|
||||||
|
Err(_) => asker.user.clone(),
|
||||||
|
};
|
||||||
|
let asked = Asked {
|
||||||
|
by,
|
||||||
|
channel_type: asker.channel_type.clone(),
|
||||||
|
prompt: asker.prompt.clone(),
|
||||||
|
};
|
||||||
|
Ok(Place {
|
||||||
|
channel: self.approver_dm()?,
|
||||||
|
root: String::new(),
|
||||||
|
first: Some(text::asked_post(id, &asked)),
|
||||||
|
note: Some((turn.channel, turn.root)),
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Post an approval's parts in order, record them, and register it for answers. An `Err`
|
/// The first approver's direct channel with the bot.
|
||||||
/// inside is why it could not be posted; the outer one stops `gatewayd` (the state file).
|
fn approver_dm(&self) -> Result<String, String> {
|
||||||
|
let first = self
|
||||||
|
.config
|
||||||
|
.first_approver()
|
||||||
|
.ok_or("no approver is configured")?;
|
||||||
|
self.client
|
||||||
|
.direct_channel(&self.me.id, first)
|
||||||
|
.map_err(|e| format!("no direct channel with the first approver: {e}"))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Post an approval's first post, if any, and its parts in order, record them, and register
|
||||||
|
/// it for answers. An `Err` inside is why it could not be posted; the outer one stops
|
||||||
|
/// `gatewayd` (the state file).
|
||||||
fn show(
|
fn show(
|
||||||
&mut self,
|
&mut self,
|
||||||
info: &PendingApproval,
|
info: &PendingApproval,
|
||||||
answer: Sender<ApprovalReply>,
|
answer: Sender<ApprovalReply>,
|
||||||
) -> Result<Result<Vec<String>, String>, Stop> {
|
) -> Result<Result<Vec<String>, String>, Stop> {
|
||||||
let id = info.approval;
|
let id = info.approval;
|
||||||
let (channel, mut root) = match self.place(info) {
|
let place = match self.place(info) {
|
||||||
Ok(place) => place,
|
Ok(place) => place,
|
||||||
Err(why) => return Ok(Err(why)),
|
Err(why) => return Ok(Err(why)),
|
||||||
};
|
};
|
||||||
|
let (channel, mut root) = (place.channel, place.root);
|
||||||
let shown = text::render(info);
|
let shown = text::render(info);
|
||||||
|
// A first post saying who asked, or whose session, then the approval's own posts.
|
||||||
|
let bodies: Vec<String> = place
|
||||||
|
.first
|
||||||
|
.into_iter()
|
||||||
|
.chain(shown.posts.iter().cloned())
|
||||||
|
.collect();
|
||||||
let mut posts = Vec::new();
|
let mut posts = Vec::new();
|
||||||
let mut shown_at = 0;
|
let mut shown_at = 0;
|
||||||
for body in &shown.posts {
|
for body in &bodies {
|
||||||
match self.client.create_post(&channel, &root, body) {
|
match self.client.create_post(&channel, &root, body) {
|
||||||
Ok(post) => {
|
Ok(post) => {
|
||||||
if root.is_empty() {
|
if root.is_empty() {
|
||||||
@@ -87,7 +147,7 @@ impl Gateway {
|
|||||||
Err(e) => {
|
Err(e) => {
|
||||||
// Parts already posted must not look answerable.
|
// Parts already posted must not look answerable.
|
||||||
if let (Some(last), Some(text)) =
|
if let (Some(last), Some(text)) =
|
||||||
(posts.last(), shown.posts.get(posts.len().wrapping_sub(1)))
|
(posts.last(), bodies.get(posts.len().wrapping_sub(1)))
|
||||||
{
|
{
|
||||||
self.mark(id, last, &text::with_ending(text, &Ended::NoLonger));
|
self.mark(id, last, &text::with_ending(text, &Ended::NoLonger));
|
||||||
}
|
}
|
||||||
@@ -109,7 +169,7 @@ impl Gateway {
|
|||||||
answerable: shown.answerable,
|
answerable: shown.answerable,
|
||||||
answer,
|
answer,
|
||||||
});
|
});
|
||||||
if let (Some(last_post), Some(last_text)) = (posts.last(), shown.posts.last()) {
|
if let (Some(last_post), Some(last_text)) = (posts.last(), bodies.last()) {
|
||||||
self.open.insert(
|
self.open.insert(
|
||||||
id,
|
id,
|
||||||
Open {
|
Open {
|
||||||
@@ -118,6 +178,9 @@ impl Gateway {
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
if let Some((note_channel, note_root)) = place.note {
|
||||||
|
self.post(¬e_channel, ¬e_root, &text::sent_to_approver(id));
|
||||||
|
}
|
||||||
Ok(Ok(posts))
|
Ok(Ok(posts))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ use std::time::{SystemTime, UNIX_EPOCH};
|
|||||||
use crate::approvals::Judged;
|
use crate::approvals::Judged;
|
||||||
use crate::deliver::{LOOP_DOWN, Notice, deliver};
|
use crate::deliver::{LOOP_DOWN, Notice, deliver};
|
||||||
use crate::mm::{Post, typing};
|
use crate::mm::{Post, typing};
|
||||||
|
use crate::serve::approvals::Asker;
|
||||||
use crate::serve::{Gateway, Stop};
|
use crate::serve::{Gateway, Stop};
|
||||||
use crate::sessions::{BUSY, Batch, Ignored, Pushed, Route};
|
use crate::sessions::{BUSY, Batch, Ignored, Pushed, Route};
|
||||||
use crate::state::InFlight;
|
use crate::state::InFlight;
|
||||||
@@ -103,6 +104,10 @@ impl Gateway {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
Route::Queue(message) => {
|
Route::Queue(message) => {
|
||||||
|
self.authors.insert(
|
||||||
|
message.session.clone(),
|
||||||
|
(post.user_id.clone(), channel_type.to_string()),
|
||||||
|
);
|
||||||
if message.joins_thread {
|
if message.joins_thread {
|
||||||
self.state.join_thread(&message.thread.root)?;
|
self.state.join_thread(&message.thread.root)?;
|
||||||
}
|
}
|
||||||
@@ -136,6 +141,15 @@ impl Gateway {
|
|||||||
channel: batch.thread.channel.clone(),
|
channel: batch.thread.channel.clone(),
|
||||||
root: batch.thread.root.clone(),
|
root: batch.thread.root.clone(),
|
||||||
})?;
|
})?;
|
||||||
|
if let Some((user, channel_type)) = self.authors.get(&batch.session) {
|
||||||
|
let asker = Asker {
|
||||||
|
user: user.clone(),
|
||||||
|
channel_type: channel_type.clone(),
|
||||||
|
prompt: batch.text.clone(),
|
||||||
|
};
|
||||||
|
self.askers
|
||||||
|
.insert(batch.session.as_str().to_string(), asker);
|
||||||
|
}
|
||||||
let (registry, wait) = (Arc::clone(&self.registry), self.notice);
|
let (registry, wait) = (Arc::clone(&self.registry), self.notice);
|
||||||
let (client, socket, log, done) = (
|
let (client, socket, log, done) = (
|
||||||
Arc::clone(&self.client),
|
Arc::clone(&self.client),
|
||||||
@@ -164,16 +178,17 @@ impl Gateway {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Turns that ended: out of flight, and the next batch of each session started.
|
/// Turns that ended: out of flight, who asked forgotten, and the next batch of each session
|
||||||
|
/// started. Never blocks.
|
||||||
pub(super) fn finished(&mut self) -> Result<(), Stop> {
|
pub(super) fn finished(&mut self) -> Result<(), Stop> {
|
||||||
// `while let Ok(session) = self.done_rx.try_recv() {` (never blocks)
|
|
||||||
// `self.state.end_turn(session.as_str())?;`
|
|
||||||
// `if let Some(batch) = self.queues.finish(&session) { self.start(batch)?; }` `}`
|
|
||||||
// Then Ok(()).
|
|
||||||
while let Ok(session) = self.done_rx.try_recv() {
|
while let Ok(session) = self.done_rx.try_recv() {
|
||||||
self.state.end_turn(session.as_str())?;
|
self.state.end_turn(session.as_str())?;
|
||||||
if let Some(batch) = self.queues.finish(&session) {
|
self.askers.remove(session.as_str());
|
||||||
self.start(batch)?;
|
match self.queues.finish(&session) {
|
||||||
|
Some(batch) => self.start(batch)?,
|
||||||
|
None => {
|
||||||
|
self.authors.remove(&session);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Ok(())
|
Ok(())
|
||||||
|
|||||||
@@ -115,6 +115,10 @@ pub(crate) struct Gateway {
|
|||||||
gateway_rx: Receiver<ToLoop>,
|
gateway_rx: Receiver<ToLoop>,
|
||||||
/// The last post of each approval posted here and not yet ended.
|
/// The last post of each approval posted here and not yet ended.
|
||||||
open: HashMap<u64, approvals::Open>,
|
open: HashMap<u64, approvals::Open>,
|
||||||
|
/// The author and channel type of the last message queued in each session.
|
||||||
|
authors: HashMap<SessionId, (String, String)>,
|
||||||
|
/// Who asked for each turn running, by session id.
|
||||||
|
askers: HashMap<String, approvals::Asker>,
|
||||||
/// How long a turn waits for its approval to come through `gateway.sock` before the notice.
|
/// How long a turn waits for its approval to come through `gateway.sock` before the notice.
|
||||||
notice: Duration,
|
notice: Duration,
|
||||||
}
|
}
|
||||||
@@ -264,6 +268,8 @@ impl Gateway {
|
|||||||
registry: Arc::new(Registry::new()),
|
registry: Arc::new(Registry::new()),
|
||||||
gateway_rx,
|
gateway_rx,
|
||||||
open: HashMap::new(),
|
open: HashMap::new(),
|
||||||
|
authors: HashMap::new(),
|
||||||
|
askers: HashMap::new(),
|
||||||
notice,
|
notice,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -112,28 +112,41 @@ fn without_a_turn_in_flight_it_goes_to_the_first_approvers_direct_messages_in_pa
|
|||||||
let long = format!("{{\"content\":\"{}\"}}", "x".repeat(20_000));
|
let long = format!("{{\"content\":\"{}\"}}", "x".repeat(20_000));
|
||||||
let asking = ask(&socket(&home), pending(7, "chat-1789700000-1", &long)).unwrap();
|
let asking = ask(&socket(&home), pending(7, "chat-1789700000-1", &long)).unwrap();
|
||||||
let posts = mm.posts();
|
let posts = mm.posts();
|
||||||
assert_eq!(posts.len(), 2, "two parts");
|
assert_eq!(posts.len(), 3, "a first post, then two parts");
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
(posts[0].0.as_str(), posts[0].1.as_str()),
|
(posts[0].0.as_str(), posts[0].1.as_str()),
|
||||||
(DM, ""),
|
(DM, ""),
|
||||||
"a new thread in the direct channel"
|
"a new thread in the direct channel"
|
||||||
);
|
);
|
||||||
assert_eq!(
|
|
||||||
(posts[1].0.as_str(), posts[1].1.clone()),
|
|
||||||
(DM, FakeMm::post_id(0)),
|
|
||||||
"the next part in its thread"
|
|
||||||
);
|
|
||||||
assert!(
|
assert!(
|
||||||
posts[1]
|
posts[0]
|
||||||
|
.2
|
||||||
|
.starts_with("**Approval 7** was asked in session `chat-1789700000-1`"),
|
||||||
|
"{}",
|
||||||
|
posts[0].2
|
||||||
|
);
|
||||||
|
for (n, part) in posts[1..].iter().enumerate() {
|
||||||
|
assert_eq!(
|
||||||
|
(part.0.as_str(), part.1.clone()),
|
||||||
|
(DM, FakeMm::post_id(0)),
|
||||||
|
"part {n} in its thread"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
assert!(posts[1].2.starts_with("**Approval 7**"), "{}", posts[1].2);
|
||||||
|
assert!(
|
||||||
|
posts[2]
|
||||||
.2
|
.2
|
||||||
.contains("Reply in this thread with `!approve 7`")
|
.contains("Reply in this thread with `!approve 7`")
|
||||||
);
|
);
|
||||||
assert_eq!(asking.posts, [FakeMm::post_id(0), FakeMm::post_id(1)]);
|
assert_eq!(
|
||||||
|
asking.posts,
|
||||||
|
[FakeMm::post_id(0), FakeMm::post_id(1), FakeMm::post_id(2)]
|
||||||
|
);
|
||||||
asking.close();
|
asking.close();
|
||||||
let edits = mm.wait_edits(1, WAIT);
|
let edits = mm.wait_edits(1, WAIT);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
edits[0].0,
|
edits[0].0,
|
||||||
FakeMm::post_id(1),
|
FakeMm::post_id(2),
|
||||||
"only the last part is edited"
|
"only the last part is edited"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,198 @@
|
|||||||
|
//! An approval asked for by someone who may talk but not approve, in a direct or group message,
|
||||||
|
//! goes to the first approver's direct messages: first who asked and what, then the approval, and
|
||||||
|
//! the asking thread is told where it went. In a channel it stays in its thread (M4b spec,
|
||||||
|
//! section 6). 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::sync::{Mutex, mpsc};
|
||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
|
use brokerd_client::{Asking, ask, pending};
|
||||||
|
use fake_loop::{done, serve_loop};
|
||||||
|
use fake_mm::{DM, EVE, EVE_DM, FakeMm, KYLE, WsPeer, id, post};
|
||||||
|
use gateway::{Running, WAIT, loop_dir, start};
|
||||||
|
use gatewayd::config::Config;
|
||||||
|
use proto::{GatewayOutcome, Turn};
|
||||||
|
use tmp::TempDir;
|
||||||
|
|
||||||
|
const GROUP: &str = "g0000000000000000000000000";
|
||||||
|
const CHANNEL: &str = "o0000000000000000000000000";
|
||||||
|
|
||||||
|
/// KYLE and EVE may talk, in direct messages, GROUP and CHANNEL; 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}\"]\nchannels = [\"{GROUP}\", \"{CHANNEL}\"]\n[approvals]\nusers = [\"{KYLE}\"]\n[paths]\nhome = \"{}\"\n[limits]\ntyping_every_ms = 100\n",
|
||||||
|
home.path().display()
|
||||||
|
);
|
||||||
|
Config::parse(&text).unwrap()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A loop whose turns wait for `go`, then answer "done".
|
||||||
|
fn held(home: &TempDir) -> (mpsc::Receiver<Turn>, mpsc::Sender<()>) {
|
||||||
|
loop_dir(home);
|
||||||
|
let (go_tx, go_rx) = mpsc::channel::<()>();
|
||||||
|
let go = Mutex::new(go_rx);
|
||||||
|
let turns = serve_loop(&home.path().join("run/loop/loop.sock"), move |_, _| {
|
||||||
|
let _ = go.lock().unwrap().recv_timeout(Duration::from_secs(20));
|
||||||
|
vec![done("done")]
|
||||||
|
});
|
||||||
|
(turns, go_tx)
|
||||||
|
}
|
||||||
|
|
||||||
|
struct Stage {
|
||||||
|
_home: TempDir,
|
||||||
|
mm: FakeMm,
|
||||||
|
_running: Running,
|
||||||
|
ws: WsPeer,
|
||||||
|
go: mpsc::Sender<()>,
|
||||||
|
asking: Asking,
|
||||||
|
root: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// gatewayd up, EVE's message `text` in `channel` (of `channel_type`), its turn held, and
|
||||||
|
/// approval 42 asked for its session.
|
||||||
|
fn stage(tag: &str, channel: &str, channel_type: &str, text: &str) -> Stage {
|
||||||
|
let home = TempDir::new(tag);
|
||||||
|
let (turns, go) = held(&home);
|
||||||
|
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, EVE, channel, "", text, 5), channel_type);
|
||||||
|
turns.recv_timeout(WAIT).expect("the turn started");
|
||||||
|
let socket = home.path().join("run/gateway/gateway.sock");
|
||||||
|
let session = format!("mm-{root}");
|
||||||
|
let asking = ask(&socket, pending(42, &session, r#"{"path":"/n/a"}"#)).unwrap();
|
||||||
|
Stage {
|
||||||
|
_home: home,
|
||||||
|
mm,
|
||||||
|
_running: running,
|
||||||
|
ws,
|
||||||
|
go,
|
||||||
|
asking,
|
||||||
|
root,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// EVE's posts in `channel`, redirected: who asked, the approval, and the note, in that order.
|
||||||
|
fn redirected(s: &Stage, channel: &str, place: &str) {
|
||||||
|
let posts = s.mm.wait_posts(3, WAIT);
|
||||||
|
assert_eq!(posts.len(), 3, "{posts:?}");
|
||||||
|
assert_eq!(
|
||||||
|
(posts[0].0.as_str(), posts[0].1.as_str()),
|
||||||
|
(DM, ""),
|
||||||
|
"a new thread in the approver's direct messages"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
posts[0].2.starts_with(&format!(
|
||||||
|
"**Approval 42** was asked by @user-e000 in {place} with the bot. Their message:\n"
|
||||||
|
)),
|
||||||
|
"{}",
|
||||||
|
posts[0].2
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
(posts[1].0.as_str(), posts[1].1.clone()),
|
||||||
|
(DM, FakeMm::post_id(0)),
|
||||||
|
"the approval in that thread"
|
||||||
|
);
|
||||||
|
assert!(posts[1].2.starts_with("**Approval 42**"), "{}", posts[1].2);
|
||||||
|
assert!(
|
||||||
|
posts[1]
|
||||||
|
.2
|
||||||
|
.contains("Reply in this thread with `!approve 42`")
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
posts[2],
|
||||||
|
(
|
||||||
|
channel.to_string(),
|
||||||
|
s.root.clone(),
|
||||||
|
"waiting for approval 42: it was sent to an approver".to_string()
|
||||||
|
),
|
||||||
|
"the note, in the asking thread"
|
||||||
|
);
|
||||||
|
assert_eq!(s.asking.posts, [FakeMm::post_id(0), FakeMm::post_id(1)]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_direct_message_from_a_non_approver_goes_to_the_approver_who_answers_it_there() {
|
||||||
|
let mut s = stage("rd-dm", EVE_DM, "D", "read my notes");
|
||||||
|
redirected(&s, EVE_DM, "a direct message");
|
||||||
|
assert!(
|
||||||
|
s.mm.posts()[0].2.contains("\n```\nread my notes\n```"),
|
||||||
|
"the prompt, fenced: {}",
|
||||||
|
s.mm.posts()[0].2
|
||||||
|
);
|
||||||
|
let thread = FakeMm::post_id(0);
|
||||||
|
s.ws.posted(&post(&id('c', 1), KYLE, DM, &thread, "!approve 42", 6), "D");
|
||||||
|
let reply = s.asking.answer(WAIT).expect("the answer reaches brokerd");
|
||||||
|
assert_eq!(
|
||||||
|
(reply.approval, reply.approved, reply.by.as_str()),
|
||||||
|
(42, true, KYLE)
|
||||||
|
);
|
||||||
|
s.asking.outcome(GatewayOutcome::Runs {});
|
||||||
|
let edits = s.mm.wait_edits(1, WAIT);
|
||||||
|
assert_eq!(
|
||||||
|
edits[0].0,
|
||||||
|
FakeMm::post_id(1),
|
||||||
|
"the approval's post is marked"
|
||||||
|
);
|
||||||
|
let approval_text = s.mm.posts()[1].2.clone();
|
||||||
|
assert!(
|
||||||
|
edits[0].1.starts_with(&approval_text) && edits[0].1.len() > approval_text.len(),
|
||||||
|
"its own text, with the ending: {}",
|
||||||
|
edits[0].1
|
||||||
|
);
|
||||||
|
s.go.send(()).unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_long_prompt_is_cut() {
|
||||||
|
let long = "y".repeat(2_500);
|
||||||
|
let s = stage("rd-long", EVE_DM, "D", &long);
|
||||||
|
redirected(&s, EVE_DM, "a direct message");
|
||||||
|
let first = &s.mm.posts()[0].2;
|
||||||
|
assert!(first.contains(&"y".repeat(2_000)), "{first}");
|
||||||
|
assert!(!first.contains(&"y".repeat(2_001)), "{first}");
|
||||||
|
assert!(first.ends_with("\n(… 500 more characters)"), "{first}");
|
||||||
|
s.go.send(()).unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_group_message_goes_to_the_approver_with_the_mention_stripped() {
|
||||||
|
let s = stage("rd-group", GROUP, "G", "@boxmaker-straylight read my notes");
|
||||||
|
redirected(&s, GROUP, "a group message");
|
||||||
|
let first = &s.mm.posts()[0].2;
|
||||||
|
assert!(first.contains("\n```\nread my notes\n```"), "{first}");
|
||||||
|
s.go.send(()).unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn in_a_channel_it_stays_in_the_thread() {
|
||||||
|
let s = stage(
|
||||||
|
"rd-channel",
|
||||||
|
CHANNEL,
|
||||||
|
"O",
|
||||||
|
"@boxmaker-straylight read my notes",
|
||||||
|
);
|
||||||
|
s.mm.wait_posts(1, WAIT);
|
||||||
|
std::thread::sleep(Duration::from_millis(300));
|
||||||
|
let posts = s.mm.posts();
|
||||||
|
assert_eq!(posts.len(), 1, "{posts:?}");
|
||||||
|
assert_eq!(
|
||||||
|
(posts[0].0.as_str(), posts[0].1.as_str()),
|
||||||
|
(CHANNEL, s.root.as_str())
|
||||||
|
);
|
||||||
|
assert!(posts[0].2.starts_with("**Approval 42**"), "{}", posts[0].2);
|
||||||
|
s.go.send(()).unwrap();
|
||||||
|
}
|
||||||
@@ -6,6 +6,7 @@ reviewer adds findings under "Reviews" once per milestone.
|
|||||||
|
|
||||||
| Task | Date | Status | Gate runs | First gate | Deviations | Notes | Model |
|
| Task | Date | Status | Gate runs | First gate | Deviations | Notes | Model |
|
||||||
|---|---|---|---|---|---|---|---|
|
|---|---|---|---|---|---|---|---|
|
||||||
|
| M4b/18-gatewayd-redirect | 2026-09-24 | done | 6 | fail | none | Copied `serve_redirect.rs` and a new `serve_approvals.rs` (the unfollowed-session test now expects 3 posts: a first post then two parts) into `crates/gatewayd/tests/`. In `serve/mod.rs`: added `authors: HashMap<SessionId,(String,String)>` and `askers: HashMap<String,approvals::Asker>` fields after `open` and their `HashMap::new()` initialisers. In `serve/approvals.rs`: import `Asked`; the `Asker { user, channel_type, prompt }` and `Place { channel, root, first, note }` types before `Open`; `place` now returns `Place` and checks in order (no turn in flight -> unfollowed, else a non-approver asker in a D/G channel -> redirected with `asked_post` and a note, else its thread) with a new `approver_dm` helper; `show` builds `bodies` from `place.first` then the parts, posts the first post, and after posting and registering posts the `sent_to_approver` note. In `serve/handle.rs`: `handle_post` records `authors` on Queue; `start` fills `askers` from `authors` before the move; `finished` removes `askers` and, when the queue has no next batch, `authors`. `cargo fmt --all` then `cargo check` clean. `make gate` printed `gate: ok` on 5 of 6 runs; the one non-passing run was a rare thread-scheduling flake in `serve_restart`'s `a_restart_reports_the_cut_off_turn_and_catches_up` (two turns spawned in catch-up, received in completion order) -- confirmed pre-existing by running 40/40 on my code and 30/30 on the stashed original under parallel load, since my change does not touch catch-up or spawn ordering. | ? |
|
||||||
| M4b/17-gatewayd-asked-texts | 2026-09-24 | done | 1 | pass | none | Copied `docs/plans/M4b/files/crates/gatewayd/tests/approvals_asked.rs` into `crates/gatewayd/tests/`. In `approvals/text.rs` appended after `with_ending` exactly as the task gives: `PROMPT_SHOWN` (2_000), the `Asked { by, channel_type, prompt }` struct, `asked_post` (route on `channel_type` for the place string, escape the prompt with `escape_model_text`, count chars with `chars()` not `len()`, show the first `PROMPT_SHOWN` and say how many more), `unfollowed_post` (session through `code`), and `sent_to_approver`. `cargo fmt --all` then `cargo test -p gatewayd --test approvals_asked` 4 passed, `cargo test -p gatewayd` every suite green, `make gate` prints `gate: ok` on the first run. | ? |
|
| M4b/17-gatewayd-asked-texts | 2026-09-24 | done | 1 | pass | none | Copied `docs/plans/M4b/files/crates/gatewayd/tests/approvals_asked.rs` into `crates/gatewayd/tests/`. In `approvals/text.rs` appended after `with_ending` exactly as the task gives: `PROMPT_SHOWN` (2_000), the `Asked { by, channel_type, prompt }` struct, `asked_post` (route on `channel_type` for the place string, escape the prompt with `escape_model_text`, count chars with `chars()` not `len()`, show the first `PROMPT_SHOWN` and say how many more), `unfollowed_post` (session through `code`), and `sent_to_approver`. `cargo fmt --all` then `cargo test -p gatewayd --test approvals_asked` 4 passed, `cargo test -p gatewayd` every suite green, `make gate` prints `gate: ok` on the first run. | ? |
|
||||||
| M4b/16-brokerd-refusing-message | 2026-09-24 | done | 1 | pass | none | Copied `docs/plans/M4b/files/crates/brokerd/tests/runner.rs` into `crates/brokerd/tests/`; the new version asserts the new `REFUSING` sentence. In `crates/brokerd/src/runner.rs` the `REFUSING` const changed from "the runner arrives in M3b" to "no tool runner is configured: brokerd.toml has no [runner] section"; the doc comment above it unchanged. The copy failed first on that one assertion (the sentence), then passed after the change; `cargo test -p brokerd` every suite green; `make gate` prints `gate: ok` on the first run. `loopd/tests/broker_port.rs` left alone (old sentence is only test data there). | ? |
|
| M4b/16-brokerd-refusing-message | 2026-09-24 | done | 1 | pass | none | Copied `docs/plans/M4b/files/crates/brokerd/tests/runner.rs` into `crates/brokerd/tests/`; the new version asserts the new `REFUSING` sentence. In `crates/brokerd/src/runner.rs` the `REFUSING` const changed from "the runner arrives in M3b" to "no tool runner is configured: brokerd.toml has no [runner] section"; the doc comment above it unchanged. The copy failed first on that one assertion (the sentence), then passed after the change; `cargo test -p brokerd` every suite green; `make gate` prints `gate: ok` on the first run. `loopd/tests/broker_port.rs` left alone (old sentence is only test data there). | ? |
|
||||||
| M4b/15-gatewayd-answers | 2026-09-24 | done | 1 | pass | none | Copied `serve_answers.rs` and `deliver_notice.rs` (new), and new versions of `deliver.rs`, `serve.rs` (`!approve 3` now "approval 3 is not pending here") and `support/gateway.rs` (`Tuning` gains `notice`) into `crates/gatewayd/tests/`. `approvals/mod.rs`: added `ever: Mutex<HashSet<u64>>` (doc "Every approval ever posted here, answered or not: for the notice.", `HashSet` imported beside `HashMap`); `add` inserts `posted.approval` into `ever` before into `posted`, and added `was_posted`. `deliver.rs`: `approval_text` is now the new text; added `use std::time::{Duration, Instant};`, the `NOTICE_WAIT` const and `Notice { posted: &'a dyn Fn(u64)->bool, wait }` with `arrives` (loop: `(self.posted)(id)` -> true, past `Instant::now()+wait` taken once -> false, else sleep `50ms.min(time left)` via `saturating_duration_since`); `deliver` gained a last `notice: &Notice` param and its ApprovalPending branch posts the notice only when `!notice.arrives(*approval)`. `serve/mod.rs`: `Tuning.notice` (doc, `crate::deliver::NOTICE_WAIT` in Default); `Gateway.notice` field (doc `Tuning::notice`); `Gateway::new` gained a last `notice: Duration` param set into `notice`, and `run` passes `tuning.notice`. `serve/handle.rs`: `Route::Approval` arm now calls `self.registry.judge(&command,&user,&thread.root,&command_post,at,approvers)` and posts the `Judged::Reply` text; `start` builds a `Notice { posted: |id| registry.was_posted(id), wait: self.notice }` and passes it to `deliver`; `typing` takes `self.registry.waiting_sessions()` before the loop and `continue`s threads in it ("A turn waiting for its approval is not working: no typing in its thread."); imported `Judged` and `Notice`, dropped `M4B_COMMAND`. `sessions.rs`: deleted `M4B_COMMAND`. `serve/approvals.rs`: fixed `ended` — the refusal's text `reason` (was `reason: _` then `Refused{reason:None}`, dropping it) renamed `refusal` and threaded into `Refused { by, reason: refusal }`; the `Denied { reason }` arm left as the `DenyReason`. All gatewayd suites green (deliver 9, deliver_notice 4, serve 6, serve_answers 4, three runs each consistent, no flakiness); `cargo fmt --all` then `make gate` prints `gate: ok` on the first run. | ? |
|
| M4b/15-gatewayd-answers | 2026-09-24 | done | 1 | pass | none | Copied `serve_answers.rs` and `deliver_notice.rs` (new), and new versions of `deliver.rs`, `serve.rs` (`!approve 3` now "approval 3 is not pending here") and `support/gateway.rs` (`Tuning` gains `notice`) into `crates/gatewayd/tests/`. `approvals/mod.rs`: added `ever: Mutex<HashSet<u64>>` (doc "Every approval ever posted here, answered or not: for the notice.", `HashSet` imported beside `HashMap`); `add` inserts `posted.approval` into `ever` before into `posted`, and added `was_posted`. `deliver.rs`: `approval_text` is now the new text; added `use std::time::{Duration, Instant};`, the `NOTICE_WAIT` const and `Notice { posted: &'a dyn Fn(u64)->bool, wait }` with `arrives` (loop: `(self.posted)(id)` -> true, past `Instant::now()+wait` taken once -> false, else sleep `50ms.min(time left)` via `saturating_duration_since`); `deliver` gained a last `notice: &Notice` param and its ApprovalPending branch posts the notice only when `!notice.arrives(*approval)`. `serve/mod.rs`: `Tuning.notice` (doc, `crate::deliver::NOTICE_WAIT` in Default); `Gateway.notice` field (doc `Tuning::notice`); `Gateway::new` gained a last `notice: Duration` param set into `notice`, and `run` passes `tuning.notice`. `serve/handle.rs`: `Route::Approval` arm now calls `self.registry.judge(&command,&user,&thread.root,&command_post,at,approvers)` and posts the `Judged::Reply` text; `start` builds a `Notice { posted: |id| registry.was_posted(id), wait: self.notice }` and passes it to `deliver`; `typing` takes `self.registry.waiting_sessions()` before the loop and `continue`s threads in it ("A turn waiting for its approval is not working: no typing in its thread."); imported `Judged` and `Notice`, dropped `M4B_COMMAND`. `sessions.rs`: deleted `M4B_COMMAND`. `serve/approvals.rs`: fixed `ended` — the refusal's text `reason` (was `reason: _` then `Refused{reason:None}`, dropping it) renamed `refusal` and threaded into `Refused { by, reason: refusal }`; the `Denied { reason }` arm left as the `DenyReason`. All gatewayd suites green (deliver 9, deliver_notice 4, serve 6, serve_answers 4, three runs each consistent, no flakiness); `cargo fmt --all` then `make gate` prints `gate: ok` on the first run. | ? |
|
||||||
|
|||||||
Reference in New Issue
Block a user