gatewayd: strip a leading mention of this bot; recognise approval commands
This commit is contained in:
@@ -7,7 +7,7 @@ use std::time::{SystemTime, UNIX_EPOCH};
|
|||||||
use crate::deliver::{LOOP_DOWN, deliver};
|
use crate::deliver::{LOOP_DOWN, deliver};
|
||||||
use crate::mm::{Post, typing};
|
use crate::mm::{Post, typing};
|
||||||
use crate::serve::{Gateway, Stop};
|
use crate::serve::{Gateway, Stop};
|
||||||
use crate::sessions::{BUSY, Batch, Ignored, Pushed, Route};
|
use crate::sessions::{BUSY, Batch, Ignored, M4B_COMMAND, Pushed, Route};
|
||||||
use crate::state::InFlight;
|
use crate::state::InFlight;
|
||||||
use crate::ws::WsError;
|
use crate::ws::WsError;
|
||||||
use crate::ws::conn::Ws;
|
use crate::ws::conn::Ws;
|
||||||
@@ -81,6 +81,8 @@ impl Gateway {
|
|||||||
}
|
}
|
||||||
Route::Ignore(_) => {}
|
Route::Ignore(_) => {}
|
||||||
Route::Reply { thread, text } => self.post(&thread.channel, &thread.root, &text),
|
Route::Reply { thread, text } => self.post(&thread.channel, &thread.root, &text),
|
||||||
|
// Until the approvals module is wired in (task 15), M4a's notice.
|
||||||
|
Route::Approval { thread, .. } => self.post(&thread.channel, &thread.root, M4B_COMMAND),
|
||||||
Route::Queue(message) => {
|
Route::Queue(message) => {
|
||||||
if message.joins_thread {
|
if message.joins_thread {
|
||||||
self.state.join_thread(&message.thread.root)?;
|
self.state.join_thread(&message.thread.root)?;
|
||||||
|
|||||||
@@ -51,6 +51,81 @@ pub enum Route {
|
|||||||
text: String,
|
text: String,
|
||||||
},
|
},
|
||||||
Queue(Message),
|
Queue(Message),
|
||||||
|
/// `!approve` or `!deny`, for the approvals module to judge.
|
||||||
|
Approval {
|
||||||
|
thread: Thread,
|
||||||
|
command: ApprovalCommand,
|
||||||
|
/// Who wrote it, which post it is, and when (Mattermost's `create_at`).
|
||||||
|
user: String,
|
||||||
|
post: String,
|
||||||
|
at: i64,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
/// An approval command as typed: exactly `!approve <id>`, `!deny <id>` or `!deny <id> <reason>`.
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
pub enum ApprovalCommand {
|
||||||
|
Approve(u64),
|
||||||
|
Deny(u64, Option<String>),
|
||||||
|
/// `!approve` or `!deny` in any other form.
|
||||||
|
Malformed,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The longest reason a `!deny` may give, in characters.
|
||||||
|
pub const MAX_REASON: usize = proto::MAX_REASON;
|
||||||
|
|
||||||
|
/// `!approve …` or `!deny …` as an approval command; `None` for any other text.
|
||||||
|
pub fn approval_command(text: &str) -> Option<ApprovalCommand> {
|
||||||
|
let after = text.strip_prefix('!')?;
|
||||||
|
let (word, rest) = match after.split_once(' ') {
|
||||||
|
Some((word, rest)) => (word, Some(rest)),
|
||||||
|
None => (after, None),
|
||||||
|
};
|
||||||
|
if !matches!(word, "approve" | "deny") {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let id = |s: &str| -> Option<u64> {
|
||||||
|
if s.is_empty() || s.len() > 20 || !s.bytes().all(|b| b.is_ascii_digit()) {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
s.parse().ok()
|
||||||
|
};
|
||||||
|
let parsed = match (word, rest) {
|
||||||
|
("approve", Some(rest)) => id(rest).map(ApprovalCommand::Approve),
|
||||||
|
("deny", Some(rest)) => match rest.split_once(' ') {
|
||||||
|
None => id(rest).map(|n| ApprovalCommand::Deny(n, None)),
|
||||||
|
Some((n, reason)) => {
|
||||||
|
let fits = !reason.is_empty() && reason.chars().count() <= MAX_REASON;
|
||||||
|
id(n)
|
||||||
|
.filter(|_| fits)
|
||||||
|
.map(|n| ApprovalCommand::Deny(n, Some(reason.to_string())))
|
||||||
|
}
|
||||||
|
},
|
||||||
|
_ => None,
|
||||||
|
};
|
||||||
|
Some(parsed.unwrap_or(ApprovalCommand::Malformed))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The text after a leading mention of this bot (`me`, lower-case), and the spaces after it. The
|
||||||
|
/// mention is matched as `named` matches names; nothing else is removed. A message that would be
|
||||||
|
/// left empty is kept as it was.
|
||||||
|
pub fn strip_mention<'a>(message: &'a str, me: &str) -> &'a str {
|
||||||
|
let Some(after) = message.strip_prefix('@') else {
|
||||||
|
return message;
|
||||||
|
};
|
||||||
|
let len = after
|
||||||
|
.find(|c: char| !(c.is_ascii_alphanumeric() || matches!(c, '.' | '-' | '_')))
|
||||||
|
.unwrap_or(after.len());
|
||||||
|
let raw = after.get(..len).unwrap_or_default();
|
||||||
|
let name = raw.trim_end_matches('.');
|
||||||
|
if name.is_empty() || !name.eq_ignore_ascii_case(me) {
|
||||||
|
return message;
|
||||||
|
}
|
||||||
|
let rest = after
|
||||||
|
.get(name.len()..)
|
||||||
|
.unwrap_or_default()
|
||||||
|
.trim_start_matches(' ');
|
||||||
|
if rest.is_empty() { message } else { rest }
|
||||||
}
|
}
|
||||||
|
|
||||||
pub struct Router {
|
pub struct Router {
|
||||||
@@ -126,8 +201,9 @@ impl Router {
|
|||||||
// Ignore(System); user not allowed -> Ignore(NotAllowed); then the channel: "D" is always
|
// Ignore(System); user not allowed -> Ignore(NotAllowed); then the channel: "D" is always
|
||||||
// ours; "O", "P" or "G" is ours when its id is allowed and `for_us`; anything else ->
|
// ours; "O", "P" or "G" is ours when its id is allowed and `for_us`; anything else ->
|
||||||
// Ignore(NotForUs). The thread root is root_id, or the post id when root_id is empty. Then
|
// Ignore(NotForUs). The thread root is root_id, or the post id when root_id is empty. Then
|
||||||
// commands: "!!..." drops one "!" and goes on as a message; "!" then a first word approve
|
// commands: Then a leading mention of this bot is stripped (M4b spec, section 7), and:
|
||||||
// or deny -> Reply M4B_COMMAND; any other "!" -> Reply UNKNOWN_COMMAND. Then Queue: session
|
// "!!..." drops one "!" and goes on as a message; `approval_command` -> Approval; any
|
||||||
|
// other "!" -> Reply UNKNOWN_COMMAND. Then Queue: session
|
||||||
// "mm-<root>" (if SessionId::new fails, Ignore(NotForUs)), resume = root_id not empty,
|
// "mm-<root>" (if SessionId::new fails, Ignore(NotForUs)), resume = root_id not empty,
|
||||||
// joins_thread = not "D".
|
// joins_thread = not "D".
|
||||||
if post.user_id == self.me_id {
|
if post.user_id == self.me_id {
|
||||||
@@ -162,7 +238,7 @@ impl Router {
|
|||||||
root: root.clone(),
|
root: root.clone(),
|
||||||
};
|
};
|
||||||
|
|
||||||
let message = &post.message;
|
let message = strip_mention(&post.message, &self.me_name);
|
||||||
if let Some(rest) = message.strip_prefix("!!") {
|
if let Some(rest) = message.strip_prefix("!!") {
|
||||||
return Route::Queue(Message {
|
return Route::Queue(Message {
|
||||||
session,
|
session,
|
||||||
@@ -172,14 +248,19 @@ impl Router {
|
|||||||
joins_thread: channel_type != "D",
|
joins_thread: channel_type != "D",
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
if let Some(after) = message.strip_prefix('!') {
|
if let Some(command) = approval_command(message) {
|
||||||
let answer = match after.split_whitespace().next() {
|
return Route::Approval {
|
||||||
Some("approve") | Some("deny") => M4B_COMMAND,
|
thread,
|
||||||
_ => UNKNOWN_COMMAND,
|
command,
|
||||||
|
user: post.user_id.clone(),
|
||||||
|
post: post.id.clone(),
|
||||||
|
at: post.create_at,
|
||||||
};
|
};
|
||||||
|
}
|
||||||
|
if message.starts_with('!') {
|
||||||
return Route::Reply {
|
return Route::Reply {
|
||||||
thread,
|
thread,
|
||||||
text: answer.to_string(),
|
text: UNKNOWN_COMMAND.to_string(),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -187,7 +268,7 @@ impl Router {
|
|||||||
session,
|
session,
|
||||||
thread,
|
thread,
|
||||||
resume: !post.root_id.is_empty(),
|
resume: !post.root_id.is_empty(),
|
||||||
text: message.clone(),
|
text: message.to_string(),
|
||||||
joins_thread: channel_type != "D",
|
joins_thread: channel_type != "D",
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -176,10 +176,7 @@ fn channels_are_answered_only_when_named_or_in_our_thread() {
|
|||||||
&post(&p1, KYLE, SHARED, "", "@boxmaker-straylight start", 5),
|
&post(&p1, KYLE, SHARED, "", "@boxmaker-straylight start", 5),
|
||||||
"O",
|
"O",
|
||||||
);
|
);
|
||||||
assert_eq!(
|
assert_eq!(turns.recv_timeout(WAIT).unwrap().content, "start");
|
||||||
turns.recv_timeout(WAIT).unwrap().content,
|
|
||||||
"@boxmaker-straylight start"
|
|
||||||
);
|
|
||||||
mm.wait_posts(1, WAIT);
|
mm.wait_posts(1, WAIT);
|
||||||
ws.posted(
|
ws.posted(
|
||||||
&post(&id('p', 2), KYLE, SHARED, &p1, "@hermes your turn", 6),
|
&post(&id('p', 2), KYLE, SHARED, &p1, "@hermes your turn", 6),
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
|
|
||||||
use gatewayd::mm::Post;
|
use gatewayd::mm::Post;
|
||||||
use gatewayd::sessions::{
|
use gatewayd::sessions::{
|
||||||
BUSY, Batch, Ignored, M4B_COMMAND, Message, Pushed, Queues, Route, Router, Thread,
|
ApprovalCommand, BUSY, Batch, Ignored, Message, Pushed, Queues, Route, Router, Thread,
|
||||||
UNKNOWN_COMMAND, named,
|
UNKNOWN_COMMAND, named,
|
||||||
};
|
};
|
||||||
use proto::SessionId;
|
use proto::SessionId;
|
||||||
@@ -185,7 +185,7 @@ fn sessions_and_threads() {
|
|||||||
assert!(reply.resume);
|
assert!(reply.resume);
|
||||||
let channel = queued(r.route(&post(SHARED, "", "@boxmaker-straylight hi"), "O", &unknown));
|
let channel = queued(r.route(&post(SHARED, "", "@boxmaker-straylight hi"), "O", &unknown));
|
||||||
assert!(channel.joins_thread && !channel.resume);
|
assert!(channel.joins_thread && !channel.resume);
|
||||||
assert_eq!(channel.text, "@boxmaker-straylight hi");
|
assert_eq!(channel.text, "hi", "the leading mention is stripped (M4b)");
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -195,21 +195,37 @@ fn commands() {
|
|||||||
channel: DM.to_string(),
|
channel: DM.to_string(),
|
||||||
root: ROOT.to_string(),
|
root: ROOT.to_string(),
|
||||||
};
|
};
|
||||||
let reply = |text: &str| Route::Reply {
|
for (message, command) in [
|
||||||
thread: thread.clone(),
|
("!approve 42", ApprovalCommand::Approve(42)),
|
||||||
text: text.to_string(),
|
(
|
||||||
};
|
"!deny 42 not now",
|
||||||
for (message, answer) in [
|
ApprovalCommand::Deny(42, Some("not now".to_string())),
|
||||||
("!approve 42", M4B_COMMAND),
|
),
|
||||||
("!deny 42 not now", M4B_COMMAND),
|
("!deny", ApprovalCommand::Malformed),
|
||||||
("!deny", M4B_COMMAND),
|
(
|
||||||
("!approved", UNKNOWN_COMMAND),
|
"@boxmaker-straylight !approve 7",
|
||||||
("!help", UNKNOWN_COMMAND),
|
ApprovalCommand::Approve(7),
|
||||||
("!", UNKNOWN_COMMAND),
|
),
|
||||||
] {
|
] {
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
r.route(&post(DM, ROOT, message), "D", &unknown),
|
r.route(&post(DM, ROOT, message), "D", &unknown),
|
||||||
reply(answer),
|
Route::Approval {
|
||||||
|
thread: thread.clone(),
|
||||||
|
command,
|
||||||
|
user: KYLE.to_string(),
|
||||||
|
post: POST.to_string(),
|
||||||
|
at: 5,
|
||||||
|
},
|
||||||
|
"{message}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
for message in ["!approved", "!help", "!"] {
|
||||||
|
assert_eq!(
|
||||||
|
r.route(&post(DM, ROOT, message), "D", &unknown),
|
||||||
|
Route::Reply {
|
||||||
|
thread: thread.clone(),
|
||||||
|
text: UNKNOWN_COMMAND.to_string(),
|
||||||
|
},
|
||||||
"{message}"
|
"{message}"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,142 @@
|
|||||||
|
//! A leading mention of this bot is stripped before anything reads a message, and approval
|
||||||
|
//! commands are recognised exactly (M4b spec, section 7). Do not edit.
|
||||||
|
|
||||||
|
use gatewayd::mm::Post;
|
||||||
|
use gatewayd::sessions::{
|
||||||
|
ApprovalCommand, MAX_REASON, Route, Router, approval_command, strip_mention,
|
||||||
|
};
|
||||||
|
|
||||||
|
const ME: &str = "boxmaker0";
|
||||||
|
const BOT: &str = "b0000000000000000000000000";
|
||||||
|
const KYLE: &str = "k0000000000000000000000000";
|
||||||
|
const SHARED: &str = "c0000000000000000000000000";
|
||||||
|
const ROOT: &str = "r0000000000000000000000000";
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_leading_mention_is_stripped() {
|
||||||
|
assert_eq!(
|
||||||
|
strip_mention("@boxmaker0 summarise the log", ME),
|
||||||
|
"summarise the log"
|
||||||
|
);
|
||||||
|
assert_eq!(strip_mention("@BoxMaker0 !approve 42", ME), "!approve 42");
|
||||||
|
assert_eq!(
|
||||||
|
strip_mention("@boxmaker0 @hermes compare notes", ME),
|
||||||
|
"@hermes compare notes"
|
||||||
|
);
|
||||||
|
assert_eq!(strip_mention("@boxmaker0, look", ME), ", look");
|
||||||
|
assert_eq!(
|
||||||
|
strip_mention("@boxmaker0\nsecond line", ME),
|
||||||
|
"\nsecond line",
|
||||||
|
"only spaces go with it"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn anything_else_is_kept() {
|
||||||
|
for message in [
|
||||||
|
"@boxmaker0",
|
||||||
|
"@boxmaker0 ",
|
||||||
|
"@boxmaker0x hello",
|
||||||
|
"@hermes @boxmaker0 hello",
|
||||||
|
"hello @boxmaker0",
|
||||||
|
" @boxmaker0 hello",
|
||||||
|
"@ boxmaker0 hello",
|
||||||
|
"@@boxmaker0 hello",
|
||||||
|
"",
|
||||||
|
] {
|
||||||
|
assert_eq!(strip_mention(message, ME), message, "{message:?}");
|
||||||
|
}
|
||||||
|
assert_eq!(
|
||||||
|
strip_mention("@boxmaker0. hi", ME),
|
||||||
|
". hi",
|
||||||
|
"the trailing dot is not part of the name"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn approval_commands_exactly() {
|
||||||
|
use ApprovalCommand::{Approve, Deny, Malformed};
|
||||||
|
let cases: &[(&str, Option<ApprovalCommand>)] = &[
|
||||||
|
("!approve 42", Some(Approve(42))),
|
||||||
|
("!approve 0", Some(Approve(0))),
|
||||||
|
("!approve 18446744073709551615", Some(Approve(u64::MAX))),
|
||||||
|
("!deny 42", Some(Deny(42, None))),
|
||||||
|
(
|
||||||
|
"!deny 42 not on a Friday",
|
||||||
|
Some(Deny(42, Some("not on a Friday".to_string()))),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"!deny 42 two spaces",
|
||||||
|
Some(Deny(42, Some(" two spaces".to_string()))),
|
||||||
|
),
|
||||||
|
("!approve", Some(Malformed)),
|
||||||
|
("!approve 42", Some(Malformed)),
|
||||||
|
("!approve 42 ", Some(Malformed)),
|
||||||
|
("!approve 42 now", Some(Malformed)),
|
||||||
|
("!approve +42", Some(Malformed)),
|
||||||
|
("!approve 4x2", Some(Malformed)),
|
||||||
|
("!approve 18446744073709551616", Some(Malformed)),
|
||||||
|
("!deny", Some(Malformed)),
|
||||||
|
("!deny 42 ", Some(Malformed)),
|
||||||
|
("!deny x reason", Some(Malformed)),
|
||||||
|
("!approved 42", None),
|
||||||
|
("!denied 42", None),
|
||||||
|
("!help", None),
|
||||||
|
("approve 42", None),
|
||||||
|
("!Approve 42", None),
|
||||||
|
];
|
||||||
|
for (text, want) in cases {
|
||||||
|
assert_eq!(&approval_command(text), want, "{text:?}");
|
||||||
|
}
|
||||||
|
let long = format!("!deny 42 {}", "é".repeat(MAX_REASON));
|
||||||
|
assert!(
|
||||||
|
matches!(approval_command(&long), Some(Deny(42, Some(_)))),
|
||||||
|
"characters, not bytes"
|
||||||
|
);
|
||||||
|
let over = format!("!deny 42 {}", "x".repeat(MAX_REASON + 1));
|
||||||
|
assert_eq!(approval_command(&over), Some(Malformed));
|
||||||
|
}
|
||||||
|
|
||||||
|
fn post(message: &str, root: &str) -> Post {
|
||||||
|
Post {
|
||||||
|
id: "p0000000000000000000000001".to_string(),
|
||||||
|
user_id: KYLE.to_string(),
|
||||||
|
channel_id: SHARED.to_string(),
|
||||||
|
root_id: root.to_string(),
|
||||||
|
message: message.to_string(),
|
||||||
|
create_at: 9,
|
||||||
|
delete_at: 0,
|
||||||
|
kind: String::new(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn in_a_shared_thread_the_mention_picks_the_bot() {
|
||||||
|
let me = Router::new(BOT, ME, &[KYLE.to_string()], &[SHARED.to_string()]);
|
||||||
|
let ours = |root: &str| root == ROOT;
|
||||||
|
let addressed = me.route(&post("@boxmaker0 !approve 42", ROOT), "O", &ours);
|
||||||
|
assert!(
|
||||||
|
matches!(
|
||||||
|
addressed,
|
||||||
|
Route::Approval {
|
||||||
|
command: ApprovalCommand::Approve(42),
|
||||||
|
..
|
||||||
|
}
|
||||||
|
),
|
||||||
|
"{addressed:?}"
|
||||||
|
);
|
||||||
|
let other = me.route(&post("@boxmaker1 !approve 42", ROOT), "O", &ours);
|
||||||
|
assert!(
|
||||||
|
matches!(other, Route::Ignore(_)),
|
||||||
|
"addressed to another bot: {other:?}"
|
||||||
|
);
|
||||||
|
let bare = me.route(&post("!approve 42", ROOT), "O", &ours);
|
||||||
|
assert!(
|
||||||
|
matches!(bare, Route::Approval { .. }),
|
||||||
|
"a bare command in our thread is ours too: {bare:?}"
|
||||||
|
);
|
||||||
|
match me.route(&post("@boxmaker0 hello there", ""), "O", &ours) {
|
||||||
|
Route::Queue(m) => assert_eq!(m.text, "hello there"),
|
||||||
|
other => panic!("{other:?}"),
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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/08-gatewayd-mention-commands | 2026-09-24 | done | 1 | pass | none | Copied `sessions_mention.rs` into `crates/gatewayd/tests/`, the new `sessions.rs` (commands now route to `Approval`, the mention stripped) and `serve.rs` (from `serve.rs-task08`, the turn's text is `start`). In `sessions.rs`: added `Route::Approval { thread, command, user, post, at }` after `Queue(Message)`; added the given `ApprovalCommand` enum, `MAX_REASON = proto::MAX_REASON`, and `approval_command` (returns `None` for text that is not a `!` command, else `Approve`/`Deny`/`Malformed`); wrote `strip_mention` (the five steps: strip a leading `@`, take the longest name run, trim a trailing dot, match `me` case-insensitively, return the message when the remainder is empty); in `route` bound `message` from `strip_mention(&post.message, &self.me_name)`, replaced the `!` command block with `approval_command` → Approval then any other `!` → Reply UNKNOWN_COMMAND, and changed the final Queue's `text: message.clone()` to `message.to_string()` since `message` is now `&str`; updated the step comment. In `handle.rs` `handle_post` added the `Route::Approval` arm posting M4B_COMMAND (approvals module wired in task 15) and `M4B_COMMAND` to the `use`. All three suites pass (serve 6, sessions 8, sessions_mention 4); `make gate` prints `gate: ok` on the first run. | ? |
|
||||||
| M4b/07-gatewayd-config-approvals | 2026-09-24 | done | 1 | pass | none | Copied `docs/plans/M4b/files/crates/gatewayd/tests/config_approvals.rs` into `crates/gatewayd/tests/`. In `config.rs`: added `#[serde(default)] pub approvals: ApprovalsConfig,` after `pub limits: Limits,` in `Config`, the `ApprovalsConfig { users: Vec<String>, socket: PathBuf }` struct (`#[serde(deny_unknown_fields, default)]`, with the doc comments) after `struct Config`, the `[approvals] users` check in `problem` right before the `[limits] queue` check (each id must be in `allow.users`), and the three methods before `state_path`: `approvers` (`&self.approvals.users`, or `&self.allow.users` when empty), `first_approver` (`.first().map(String::as_str)`), and `gateway_socket` (`self.approvals.socket`, or `<home>/run/gateway/gateway.sock` when `as_os_str().is_empty()`). The `ApprovalsConfig` derives `Default` so the empty `socket` deserialises from an absent field; `PathBuf`'s default is empty, matching the runbook's "empty" rule. `cargo fmt --all` then `cargo test -p gatewayd --test config_approvals --test config` green (4 and 7 passed); `make gate` prints `gate: ok` on the first run. | ? |
|
| M4b/07-gatewayd-config-approvals | 2026-09-24 | done | 1 | pass | none | Copied `docs/plans/M4b/files/crates/gatewayd/tests/config_approvals.rs` into `crates/gatewayd/tests/`. In `config.rs`: added `#[serde(default)] pub approvals: ApprovalsConfig,` after `pub limits: Limits,` in `Config`, the `ApprovalsConfig { users: Vec<String>, socket: PathBuf }` struct (`#[serde(deny_unknown_fields, default)]`, with the doc comments) after `struct Config`, the `[approvals] users` check in `problem` right before the `[limits] queue` check (each id must be in `allow.users`), and the three methods before `state_path`: `approvers` (`&self.approvals.users`, or `&self.allow.users` when empty), `first_approver` (`.first().map(String::as_str)`), and `gateway_socket` (`self.approvals.socket`, or `<home>/run/gateway/gateway.sock` when `as_os_str().is_empty()`). The `ApprovalsConfig` derives `Default` so the empty `socket` deserialises from an absent field; `PathBuf`'s default is empty, matching the runbook's "empty" rule. `cargo fmt --all` then `cargo test -p gatewayd --test config_approvals --test config` green (4 and 7 passed); `make gate` prints `gate: ok` on the first run. | ? |
|
||||||
| M4b/06-brokerd-courier | 2026-09-24 | done | 1 | pass | none | Filled the four `todo!()`s in the copied `courier.rs`: `finished` (`stop.load(SeqCst) || !broker.table().contains(id)`); `pause` (a `while Instant::now() < until && !finished` loop sleeping `timing.tick.min(until.saturating_duration_since(now))`); `frames` (`try_clone` the stream, spawn a reader thread that hands whole frames over an mpsc channel so the courier can `recv_timeout` without losing half a frame, `Ok(rx)`); `answer` (`take` the entry → `Gone {}` if gone, build the `Answer` from `reply.approved`, call `admin::answer`, map the returned `DecisionRecord` through the five-arm match to `Runs`/`Denied`/`Refused`/`Denied`/`Refused`). The four `broker.rs` changes: `RecvTimeoutError` import widened to include `Receiver`, `use crate::courier;` after the `config` use, `let shown = info.clone();` before the insert, step 4 replaced by `courier::alongside(broker, &shown, || wait_for_verdict(...))` matching `Waited`, and the old step 4 moved to `wait_for_verdict` (with the `Waited` enum) before `pub fn alive`. `cargo build -p brokerd` clean; courier test went 1 pass/9 fail then 10 passed three times running; full brokerd suite green; `cargo fmt --all` then `make gate` prints `gate: ok` on the first run. | ? |
|
| M4b/06-brokerd-courier | 2026-09-24 | done | 1 | pass | none | Filled the four `todo!()`s in the copied `courier.rs`: `finished` (`stop.load(SeqCst) || !broker.table().contains(id)`); `pause` (a `while Instant::now() < until && !finished` loop sleeping `timing.tick.min(until.saturating_duration_since(now))`); `frames` (`try_clone` the stream, spawn a reader thread that hands whole frames over an mpsc channel so the courier can `recv_timeout` without losing half a frame, `Ok(rx)`); `answer` (`take` the entry → `Gone {}` if gone, build the `Answer` from `reply.approved`, call `admin::answer`, map the returned `DecisionRecord` through the five-arm match to `Runs`/`Denied`/`Refused`/`Denied`/`Refused`). The four `broker.rs` changes: `RecvTimeoutError` import widened to include `Receiver`, `use crate::courier;` after the `config` use, `let shown = info.clone();` before the insert, step 4 replaced by `courier::alongside(broker, &shown, || wait_for_verdict(...))` matching `Waited`, and the old step 4 moved to `wait_for_verdict` (with the `Waited` enum) before `pub fn alive`. `cargo build -p brokerd` clean; courier test went 1 pass/9 fail then 10 passed three times running; full brokerd suite green; `cargo fmt --all` then `make gate` prints `gate: ok` on the first run. | ? |
|
||||||
| M4b/05-brokerd-config-gateway | 2026-09-24 | done | 1 | pass | none | Copied `docs/plans/M4b/files/crates/brokerd/tests/config_gateway.rs` and the new `support/rig.rs` (whose `Config` gained `gateway: None`) into `crates/brokerd/tests/`. In `config.rs`: added the `Gateway { socket: PathBuf }` struct (`#[serde(deny_unknown_fields, default)]`) before `default_podman`, the `gateway: Option<Gateway>` field (with `#[serde(default)]`) after `runner` in `Config`, and the `gateway_socket()` method before `audit_dir` — `None` when the section is absent, the default `<home>/run/gateway/gateway.sock` when the socket is empty, else the path as given. `cargo fmt --all` then `cargo test -p brokerd` green with `config_gateway` 4 passed; `make gate` prints `gate: ok` on the first run. | ? |
|
| M4b/05-brokerd-config-gateway | 2026-09-24 | done | 1 | pass | none | Copied `docs/plans/M4b/files/crates/brokerd/tests/config_gateway.rs` and the new `support/rig.rs` (whose `Config` gained `gateway: None`) into `crates/brokerd/tests/`. In `config.rs`: added the `Gateway { socket: PathBuf }` struct (`#[serde(deny_unknown_fields, default)]`) before `default_podman`, the `gateway: Option<Gateway>` field (with `#[serde(default)]`) after `runner` in `Config`, and the `gateway_socket()` method before `audit_dir` — `None` when the section is absent, the default `<home>/run/gateway/gateway.sock` when the socket is empty, else the path as given. `cargo fmt --all` then `cargo test -p brokerd` green with `config_gateway` 4 passed; `make gate` prints `gate: ok` on the first run. | ? |
|
||||||
|
|||||||
Reference in New Issue
Block a user