gatewayd: the texts that say who asked for a redirected approval

Implemented-By: OpenCode session (model recorded in docs/implementer-log.md)
This commit is contained in:
2026-09-24 11:51:43 -07:00
parent bf89512832
commit 989548419d
3 changed files with 136 additions and 0 deletions
+49
View File
@@ -173,3 +173,52 @@ pub fn ended_line(ended: &Ended) -> String {
pub fn with_ending(last: &str, ended: &Ended) -> String {
format!("{last}\n\n{}", ended_line(ended))
}
/// The most characters of an asker's message shown to the approver: it is context, not what is
/// approved, so it may be cut.
pub const PROMPT_SHOWN: usize = 2_000;
/// Who asked for an approval that is shown elsewhere (M4b spec, section 6).
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Asked {
/// `@username`, or the user id when the name could not be found.
pub by: String,
/// The channel's type: `D` a direct message, `G` a group message.
pub channel_type: String,
/// The text of the turn that asked.
pub prompt: String,
}
/// The first post of a redirected approval: who asked, where, and their message.
pub fn asked_post(id: u64, asked: &Asked) -> String {
let place = if asked.channel_type == "G" {
"a group message"
} else {
"a direct message"
};
let escaped = proto::escape::escape_model_text(&asked.prompt);
let total = escaped.chars().count();
let shown: String = escaped.chars().take(PROMPT_SHOWN).collect();
let mut post = format!(
"**Approval {id}** was asked by {} in {place} with the bot. Their message:\n{}",
asked.by,
fence(&shown)
);
if total > PROMPT_SHOWN {
post.push_str(&format!("\n(… {} more characters)", total - PROMPT_SHOWN));
}
post
}
/// The first post of an approval whose session `gatewayd` does not follow.
pub fn unfollowed_post(id: u64, session: &str) -> String {
format!(
"**Approval {id}** was asked in session {}, which gatewayd does not follow now (a restart, or a session from outside Mattermost).",
code(session)
)
}
/// The line left in the asking thread when its approval goes to an approver.
pub fn sent_to_approver(id: u64) -> String {
format!("waiting for approval {id}: it was sent to an approver")
}
+86
View File
@@ -0,0 +1,86 @@
//! The posts that tell an approver who asked for a redirected approval and what, and the line left
//! in the asking thread (M4b spec, section 6, after the review). Do not edit.
use gatewayd::approvals::text::{
Asked, PROMPT_SHOWN, asked_post, sent_to_approver, unfollowed_post,
};
/// The escape for one code point, built here so this file holds no such character itself.
fn esc(code: u32) -> String {
format!("{}u{code:04x}", char::from(92))
}
fn asked(prompt: &str, channel_type: &str) -> Asked {
Asked {
by: "@alex".to_string(),
channel_type: channel_type.to_string(),
prompt: prompt.to_string(),
}
}
#[test]
fn who_where_and_what_they_asked() {
assert_eq!(
asked_post(
3,
&asked("Read the file /srv/notes/q3.txt and summarise it.", "D")
),
"**Approval 3** was asked by @alex in a direct message with the bot. Their message:\n```\nRead the file /srv/notes/q3.txt and summarise it.\n```"
);
let group = asked_post(4, &asked("first\n\nsecond", "G"));
assert_eq!(
group,
"**Approval 4** was asked by @alex in a group message with the bot. Their message:\n```\nfirst\n\nsecond\n```",
"a burst keeps its lines"
);
}
#[test]
fn the_message_is_escaped_and_fenced() {
let hostile = format!(
"see ``` and @channel{}{}",
char::from_u32(0x202e).unwrap(),
char::from(7)
);
let post = asked_post(5, &asked(&hostile, "D"));
assert!(
post.contains(&format!(
"\n````\nsee ``` and @channel{}{}\n````",
esc(0x202e),
esc(7)
)),
"{post}"
);
assert!(!post.contains(char::from_u32(0x202e).unwrap()) && !post.contains(char::from(7)));
}
#[test]
fn a_long_message_is_cut_and_says_how_much() {
let long = "é".repeat(PROMPT_SHOWN + 25);
let post = asked_post(6, &asked(&long, "D"));
assert!(
post.ends_with(&format!(
"\n```\n{}\n```\n(… 25 more characters)",
"é".repeat(PROMPT_SHOWN)
)),
"{}",
&post[post.len() - 80..]
);
let exact = asked_post(6, &asked(&"x".repeat(PROMPT_SHOWN), "D"));
assert!(
!exact.contains("more characters"),
"nothing cut, nothing said"
);
}
#[test]
fn an_unfollowed_session_and_the_note() {
assert_eq!(
unfollowed_post(9, "chat-1789700000-1"),
"**Approval 9** was asked in session `chat-1789700000-1`, which gatewayd does not follow now (a restart, or a session from outside Mattermost)."
);
assert_eq!(
sent_to_approver(3),
"waiting for approval 3: it was sent to an approver"
);
}
+1
View File
@@ -6,6 +6,7 @@ reviewer adds findings under "Reviews" once per milestone.
| Task | Date | Status | Gate runs | First gate | Deviations | Notes | Model |
|---|---|---|---|---|---|---|---|
| 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/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/14-gatewayd-serve-approvals | 2026-09-24 | done | 1 | pass | none | Copied the skeleton `approvals.rs`, `serve_approvals.rs`, the new `support/brokerd_client.rs`, and a new `support/fake_mm.rs` (which gains `edits`/`wait_edits`, `username` via `GET /api/v4/users/{user}`, and `refuse_posts` returning 400 on `POST /api/v4/posts`). In `serve/mod.rs`: added `mod approvals;` before `mod handle;` and the `HashMap`, `Registry` and `gateway::{self, ToLoop}` imports; the three `registry`/`gateway_rx`/`open` fields on `Gateway`; `Gateway::new` gained a `gateway_rx: Receiver<ToLoop>` last parameter and set `registry`, `gateway_rx`, `open`; `run` now `listen`s `gateway.sock` and `serve`s it on its own thread before `Client::new`, returning `Stop::Start` on either failure and passing `gateway_rx` to `Gateway::new`; `connected` calls `self.mark_left_behind()?` after the `take_in_flight` loop; `event_loop` calls `self.gateway_requests()?` after `self.finished()?`. Filled the four `todo!()`s in `approvals.rs`: `place` (`turn_of` the session's in-flight turn, else the first approver's `direct_channel` as a new empty thread, each error string as given); `show` was already written; `ended` removes from the registry and state, returns when there is no `open`, maps `Ending::NoLonger`/`Answered` to the `Ended` (the `by` run through `username` as `@<name>` or left the id, and `outcome` → `Runs`/`Denied`/`Refused{reason:None}`/`Gone`); `mark_left_behind` edits each left-behind approval's last post with the title and the `Restarted` line; `mark` patches the post and logs a failure. The one clippy warning was the unused `reason` of `Ending::Answered` (the real reason comes from `outcome`), ignored with `reason: _`. All five `serve_approvals` tests pass three times; the whole gatewayd suite is green; `cargo fmt --all` then `make gate` prints `gate: ok` on the first run. | ? |