Plan M4a: gatewayd in 15 tasks, with skeletons and given tests

Each task's tests were run against a reference at its end state; the end states were replayed
from master in order with the gate at each step (650 to 762 tests); each skeleton compiles
against its tests and fails them. The reference is kept off this machine. Lessons T27 (every
wait in a test has a limit) and T28 (mutate the reference before hand-over) come from this work.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-09-23 19:05:44 -07:00
co-authored by Claude Opus 5.5
parent f38dc8d474
commit 0339dc13b2
69 changed files with 7790 additions and 3 deletions
@@ -0,0 +1,162 @@
//! Which posts `gatewayd` acts on, which session each belongs to, commands, and the queue of
//! messages per session (M4a spec, section 7). Pure: the state file and the network are elsewhere.
use std::collections::{BTreeSet, HashMap};
use proto::SessionId;
use crate::mm::Post;
pub const M4B_COMMAND: &str = "approvals over Mattermost arrive in M4b; use `bxctl approvals`";
pub const UNKNOWN_COMMAND: &str = "unknown command; the commands are !approve and !deny";
pub const BUSY: &str = "busy: too many messages are waiting in this conversation";
/// Names that name nobody: every agent in the channel would answer them.
const EVERYONE: [&str; 3] = ["channel", "here", "all"];
/// Why a post was not acted on. Only `NotAllowed` is logged, by user id and post id.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Ignored {
Own,
System,
NotAllowed,
NotForUs,
}
/// Where an answer goes: a channel, and the root of the thread in it.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Thread {
pub channel: String,
pub root: String,
}
/// A message for a session.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Message {
pub session: SessionId,
pub thread: Thread,
/// A reply in a thread: the session should exist already.
pub resume: bool,
pub text: String,
/// A thread in a channel or group message that this Boxmaker now takes part in.
pub joins_thread: bool,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Route {
Ignore(Ignored),
/// Answer in the thread without a turn (a command).
Reply {
thread: Thread,
text: String,
},
Queue(Message),
}
pub struct Router {
me_id: String,
me_name: String,
users: BTreeSet<String>,
channels: BTreeSet<String>,
}
/// Every `@name` in a message, lower-cased: `a-z`, `0-9`, `.`, `-` and `_` after an `@`, without
/// trailing dots.
pub fn named(message: &str) -> Vec<String> {
// Find each "@". The name after it is the longest run of ASCII letters, digits, ".", "-" and
// "_", with trailing "." removed and lower-cased. Skip empty names. Continue after the name.
todo!()
}
impl Router {
pub fn new(me_id: &str, me_name: &str, users: &[String], channels: &[String]) -> Router {
// Store the ids, the username lower-cased, and the two lists as sets.
todo!()
}
/// Is a post in a channel or group message for this Boxmaker? `known` says whether it has a
/// session for a thread root.
fn for_us(&self, post: &Post, known: &dyn Fn(&str) -> bool) -> bool {
// Named this bot -> true. Otherwise true only for a reply (root_id not empty) in a known
// thread (`known(root_id)`) that names nobody but channel, here or all.
todo!()
}
/// What to do with a new post (a post seen before is dropped by the caller first).
pub fn route(&self, post: &Post, channel_type: &str, known: &dyn Fn(&str) -> bool) -> Route {
// Section 7 of the spec, in this order: own post -> Ignore(Own); kind not empty ->
// 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 ->
// 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
// or deny -> Reply M4B_COMMAND; any other "!" -> Reply UNKNOWN_COMMAND. Then Queue: session
// "mm-<root>" (if SessionId::new fails, Ignore(NotForUs)), resume = root_id not empty,
// joins_thread = not "D".
todo!()
}
}
/// A turn to send: every message that was waiting, joined with a blank line.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Batch {
pub session: SessionId,
pub thread: Thread,
pub resume: bool,
pub text: String,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Pushed {
/// No turn was running: send this one now.
Start(Batch),
/// A turn is running; the message waits for the next.
Waiting,
/// Too many are waiting: the message is dropped, answer `BUSY`.
Full(Thread),
}
struct Pending {
thread: Thread,
resume: bool,
waiting: Vec<String>,
}
/// The sessions with a turn running, and the messages waiting for each.
pub struct Queues {
limit: usize,
running: HashMap<SessionId, Pending>,
}
impl Queues {
/// `limit` is the most messages that may wait per session.
pub fn new(limit: usize) -> Queues {
// An empty map.
todo!()
}
pub fn push(&mut self, message: Message) -> Pushed {
// If the session is running: with `limit` messages already waiting -> Full(thread); else
// add the text to waiting -> Waiting. Otherwise insert it as running (its later batches
// resume: true) and return Start with this message alone.
todo!()
}
/// A session's turn ended: the next batch, or `None`, and the session is no longer running.
pub fn finish(&mut self, session: &SessionId) -> Option<Batch> {
// Not running -> None. Nothing waiting -> remove it, None. Otherwise take every waiting
// text, joined with "\n\n", as the next Batch (it stays running).
todo!()
}
/// How many sessions have a turn running.
pub fn running(&self) -> usize {
// How many sessions are running.
todo!()
}
/// The threads with a turn running, for showing this bot as typing in them.
pub fn threads(&self) -> Vec<Thread> {
// The thread of every running session.
todo!()
}
}