M4b review, and follow-up tasks 16 to 18
The review of tasks 01 to 15 goes in the implementer log, with the device check against the owner's Mattermost. Three follow-ups: - 16: the refusal without a runner says why. - 17: the texts that say who asked. - 18: approvals from non-approvers in direct or group messages go to an approver. Task 14's given serve_approvals.rs becomes serve_approvals.rs-task14, since task 18 hands over a new version. Tip T31 is added. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,274 @@
|
||||
# M4b task 18: an approval a non-approver asked for goes to an approver
|
||||
|
||||
**Branch:** `m4b` (run `git switch m4b`; `git status --short` must be empty, otherwise stop)
|
||||
**Commit subject:** `gatewayd: approvals asked by non-approvers in direct messages go to an approver`
|
||||
|
||||
## Goal
|
||||
|
||||
M4b spec, section 6, as changed after the review of 2026-09-24. Today an approval always goes
|
||||
into the thread whose turn asked for it. When that turn was asked by someone who may talk to the
|
||||
bot but not approve (in `[allow] users`, not in `[approvals] users`), in a **direct or group
|
||||
message**, no approver can answer it there: it could only be answered with `bxctl`. Now:
|
||||
|
||||
1. **Redirected.** Such an approval goes to the first approver's direct messages as a new thread.
|
||||
Its first post is task 17's `asked_post`: who asked (`@username`), where, and their message.
|
||||
Then come the approval's own posts, in that thread, and the asking thread gets one line,
|
||||
`sent_to_approver`. The approver answers in the new thread.
|
||||
2. **Stays.** An approval asked by an approver, or asked in a channel (`O` or `P`: an approver
|
||||
can reply in the thread there), stays in its thread, as today.
|
||||
3. **Not followed.** An approval whose session has no turn in flight here already goes to the
|
||||
first approver's direct messages; now its first post is task 17's `unfollowed_post`, and the
|
||||
approval's posts follow in that thread.
|
||||
|
||||
To know who asked, the serve loop keeps two maps in memory, not in the state file (after a
|
||||
restart the turn is interrupted anyway): `authors`, the author and channel type of the last
|
||||
message queued for each session, and `askers`, for each turn running, its author, channel type
|
||||
and text. `start` fills `askers` from `authors`; `finished` removes both.
|
||||
|
||||
## Files
|
||||
|
||||
- Copy: `crates/gatewayd/tests/serve_redirect.rs`, and a **new version** of the given
|
||||
`crates/gatewayd/tests/serve_approvals.rs` (an unfollowed session's approval now has a first
|
||||
post: 3 posts, not 2)
|
||||
- Modify: `crates/gatewayd/src/serve/approvals.rs`, `crates/gatewayd/src/serve/mod.rs`,
|
||||
`crates/gatewayd/src/serve/handle.rs`, `docs/implementer-log.md`
|
||||
|
||||
## The changes, exactly
|
||||
|
||||
### `serve/mod.rs`
|
||||
|
||||
In `struct Gateway`, after the field `open`, add:
|
||||
|
||||
```rust
|
||||
/// 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>,
|
||||
```
|
||||
|
||||
and in `Gateway::new`, after `open: HashMap::new(),`, add `authors: HashMap::new(),` and
|
||||
`askers: HashMap::new(),`. `HashMap` and `SessionId` are already imported there.
|
||||
|
||||
### `serve/approvals.rs`
|
||||
|
||||
1. The import `use crate::approvals::text::{self, Ended};` becomes
|
||||
`use crate::approvals::text::{self, Asked, Ended};`.
|
||||
2. Insert these two types just before the doc comment of `pub(super) struct Open`:
|
||||
|
||||
```rust
|
||||
/// 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)>,
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
3. Replace the whole function `place`, from its doc comment (`/// Where an approval goes: its
|
||||
session's turn in flight, …`) to its closing `}`, including the numbered comment inside it,
|
||||
by these two functions:
|
||||
|
||||
```rust
|
||||
/// Where an approval goes (M4b spec, section 6): its turn's thread; or, when a non-approver
|
||||
/// asked in a direct or group message, the first approver's direct messages with who asked
|
||||
/// and what, and a note in the asking thread; or, for a session not followed here, the first
|
||||
/// approver's direct messages with a line saying so.
|
||||
fn place(&self, info: &PendingApproval) -> Result<Place, String> {
|
||||
let id = info.approval;
|
||||
let session = info.session.as_str();
|
||||
let Some(turn) = self.state.turn_of(session) else {
|
||||
return Ok(Place {
|
||||
channel: self.approver_dm()?,
|
||||
root: String::new(),
|
||||
first: Some(text::unfollowed_post(id, session)),
|
||||
note: None,
|
||||
});
|
||||
};
|
||||
let redirect = self.askers.get(session).filter(|a| {
|
||||
matches!(a.channel_type.as_str(), "D" | "G")
|
||||
&& !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)),
|
||||
})
|
||||
}
|
||||
|
||||
/// The first approver's direct channel with the bot.
|
||||
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}"))
|
||||
}
|
||||
```
|
||||
|
||||
`place` checks in this order: no turn in flight → not followed; an asker who is not an
|
||||
approver, in a `D` or `G` channel → redirected; anything else → its thread. A user name that
|
||||
cannot be looked up is shown as the user id, as it is elsewhere.
|
||||
|
||||
4. In `show`:
|
||||
- Its doc comment (two lines, `/// Post an approval's parts in order, …`) becomes:
|
||||
|
||||
```rust
|
||||
/// 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).
|
||||
```
|
||||
|
||||
- These lines
|
||||
|
||||
```rust
|
||||
let (channel, mut root) = match self.place(info) {
|
||||
Ok(place) => place,
|
||||
Err(why) => return Ok(Err(why)),
|
||||
};
|
||||
let shown = text::render(info);
|
||||
```
|
||||
|
||||
become
|
||||
|
||||
```rust
|
||||
let place = match self.place(info) {
|
||||
Ok(place) => place,
|
||||
Err(why) => return Ok(Err(why)),
|
||||
};
|
||||
let (channel, mut root) = (place.channel, place.root);
|
||||
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();
|
||||
```
|
||||
|
||||
- Everywhere else in `show`, `shown.posts` becomes `bodies`. There are three:
|
||||
`for body in &shown.posts {` → `for body in &bodies {`;
|
||||
`shown.posts.get(posts.len().wrapping_sub(1))` → `bodies.get(posts.len().wrapping_sub(1))`;
|
||||
`(posts.last(), shown.posts.last())` → `(posts.last(), bodies.last())`.
|
||||
`shown.answerable` stays: it is not a post.
|
||||
- Just before the last line, `Ok(Ok(posts))`, add:
|
||||
|
||||
```rust
|
||||
if let Some((note_channel, note_root)) = place.note {
|
||||
self.post(¬e_channel, ¬e_root, &text::sent_to_approver(id));
|
||||
}
|
||||
```
|
||||
|
||||
The note is posted only after the approval was posted and registered; a failure to post it is
|
||||
only logged (that is what `self.post` does).
|
||||
|
||||
### `serve/handle.rs`
|
||||
|
||||
1. Add the import `use crate::serve::approvals::Asker;` just before
|
||||
`use crate::serve::{Gateway, Stop};`.
|
||||
2. In `handle_post`, the arm `Route::Queue(message) => {` gets, as its first statement:
|
||||
|
||||
```rust
|
||||
self.authors.insert(
|
||||
message.session.clone(),
|
||||
(post.user_id.clone(), channel_type.to_string()),
|
||||
);
|
||||
```
|
||||
|
||||
3. In `start`, just before the line
|
||||
`let (registry, wait) = (Arc::clone(&self.registry), self.notice);`, add:
|
||||
|
||||
```rust
|
||||
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);
|
||||
}
|
||||
```
|
||||
|
||||
It must come before that line: the thread spawned below takes `batch` by `move`.
|
||||
4. Replace `finished`, from its doc comment to its closing `}`, by:
|
||||
|
||||
```rust
|
||||
/// 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> {
|
||||
while let Ok(session) = self.done_rx.try_recv() {
|
||||
self.state.end_turn(session.as_str())?;
|
||||
self.askers.remove(session.as_str());
|
||||
match self.queues.finish(&session) {
|
||||
Some(batch) => self.start(batch)?,
|
||||
None => {
|
||||
self.authors.remove(&session);
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
```
|
||||
|
||||
(The old numbered comment inside it goes: it described the loop before this change.)
|
||||
|
||||
## Steps
|
||||
|
||||
- [ ] **1. Copy.** `git switch m4b`, then
|
||||
`cp docs/plans/M4b/files/crates/gatewayd/tests/serve_redirect.rs docs/plans/M4b/files/crates/gatewayd/tests/serve_approvals.rs crates/gatewayd/tests/`
|
||||
- [ ] **2. See it fail.** `cargo test --no-fail-fast -p gatewayd --test serve_redirect --test serve_approvals`.
|
||||
Expected: in `serve_redirect`, 3 fail and 1 passes (`in_a_channel_it_stays_in_the_thread`); in
|
||||
`serve_approvals`, 1 fails (`without_a_turn_in_flight_…`) and 4 pass.
|
||||
- [ ] **3. Make the changes**, one file at a time, in the order above, with
|
||||
`cargo check -p gatewayd` after each. Run `cargo fmt --all`.
|
||||
- [ ] **4. See it pass.**
|
||||
`cargo test -p gatewayd --test serve_redirect --test serve_approvals --test serve_answers --test serve_restart`,
|
||||
three times: 4, 5, 4 and 7 passed. Then `cargo test -p gatewayd`: every suite passes.
|
||||
- [ ] **5. Run the gate.** `make gate`. Expected last line: `gate: ok`, with 4 more tests than
|
||||
before this task (about 854). No file over 500 lines: `serve/approvals.rs` is about 260.
|
||||
- [ ] **6. Log and commit.** `git add crates/gatewayd docs/implementer-log.md Cargo.lock && git commit`
|
||||
|
||||
This is the last task of M4b. Stop after the commit.
|
||||
|
||||
## Done when
|
||||
|
||||
- `cargo test -p gatewayd` passes; `make gate` prints `gate: ok`.
|
||||
|
||||
## Stop and report if
|
||||
|
||||
- A given test still fails after every change above is made exactly. Do not edit the tests.
|
||||
- A test passes only sometimes, or takes more than a few seconds.
|
||||
- The compiler needs a change in a file not listed here.
|
||||
Reference in New Issue
Block a user