diff --git a/crates/gatewayd/src/lib.rs b/crates/gatewayd/src/lib.rs index f74fc8a..3f502d6 100644 --- a/crates/gatewayd/src/lib.rs +++ b/crates/gatewayd/src/lib.rs @@ -6,4 +6,5 @@ pub mod http; pub mod mm; pub mod net; pub mod secrets; +pub mod sessions; pub mod ws; diff --git a/crates/gatewayd/src/sessions.rs b/crates/gatewayd/src/sessions.rs new file mode 100644 index 0000000..d9b78c7 --- /dev/null +++ b/crates/gatewayd/src/sessions.rs @@ -0,0 +1,294 @@ +//! 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, + channels: BTreeSet, +} + +/// Every `@name` in a message, lower-cased: `a-z`, `0-9`, `.`, `-` and `_` after an `@`, without +/// trailing dots. +pub fn named(message: &str) -> Vec { + // 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. + let mut names = Vec::new(); + let bytes = message.as_bytes(); + let mut i = 0; + while i < bytes.len() { + if bytes[i] != b'@' { + i += 1; + continue; + } + let start = i + 1; + let mut j = start; + while j < bytes.len() { + let c = bytes[j]; + if c.is_ascii_alphanumeric() || c == b'.' || c == b'-' || c == b'_' { + j += 1; + } else { + break; + } + } + if j > start { + let trimmed = message[start..j].trim_end_matches('.').to_lowercase(); + if !trimmed.is_empty() { + names.push(trimmed); + } + } + i = j; + } + names +} + +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. + Router { + me_id: me_id.to_string(), + me_name: me_name.to_lowercase(), + users: users.iter().cloned().collect(), + channels: channels.iter().cloned().collect(), + } + } + + /// 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. + let names = named(&post.message); + if names.iter().any(|n| n == &self.me_name) { + return true; + } + if post.root_id.is_empty() || !known(&post.root_id) { + return false; + } + names.iter().all(|n| EVERYONE.contains(&n.as_str())) + } + + /// 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-" (if SessionId::new fails, Ignore(NotForUs)), resume = root_id not empty, + // joins_thread = not "D". + if post.user_id == self.me_id { + return Route::Ignore(Ignored::Own); + } + if !post.kind.is_empty() { + return Route::Ignore(Ignored::System); + } + if !self.users.contains(&post.user_id) { + return Route::Ignore(Ignored::NotAllowed); + } + let channel_ok = match channel_type { + "D" => true, + "O" | "P" | "G" => self.channels.contains(&post.channel_id) && self.for_us(post, known), + _ => false, + }; + if !channel_ok { + return Route::Ignore(Ignored::NotForUs); + } + + let root = if post.root_id.is_empty() { + post.id.clone() + } else { + post.root_id.clone() + }; + let session = match SessionId::new(&format!("mm-{root}")) { + Ok(session) => session, + Err(_) => return Route::Ignore(Ignored::NotForUs), + }; + let thread = Thread { + channel: post.channel_id.clone(), + root: root.clone(), + }; + + let message = &post.message; + if let Some(rest) = message.strip_prefix("!!") { + return Route::Queue(Message { + session, + thread, + resume: !post.root_id.is_empty(), + text: format!("!{rest}"), + joins_thread: channel_type != "D", + }); + } + if let Some(after) = message.strip_prefix('!') { + let answer = match after.split_whitespace().next() { + Some("approve") | Some("deny") => M4B_COMMAND, + _ => UNKNOWN_COMMAND, + }; + return Route::Reply { + thread, + text: answer.to_string(), + }; + } + + Route::Queue(Message { + session, + thread, + resume: !post.root_id.is_empty(), + text: message.clone(), + joins_thread: channel_type != "D", + }) + } +} + +/// 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, + waiting: Vec, +} + +/// The sessions with a turn running, and the messages waiting for each. +pub struct Queues { + limit: usize, + running: HashMap, +} + +impl Queues { + /// `limit` is the most messages that may wait per session. + pub fn new(limit: usize) -> Queues { + // An empty map. + Queues { + limit, + running: HashMap::new(), + } + } + + 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. + let limit = self.limit; + if let Some(pending) = self.running.get_mut(&message.session) { + if pending.waiting.len() >= limit { + return Pushed::Full(pending.thread.clone()); + } + pending.waiting.push(message.text.clone()); + return Pushed::Waiting; + } + let batch = Batch { + session: message.session.clone(), + thread: message.thread.clone(), + resume: message.resume, + text: message.text.clone(), + }; + self.running.insert( + message.session, + Pending { + thread: message.thread.clone(), + waiting: Vec::new(), + }, + ); + Pushed::Start(batch) + } + + /// 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 { + // Not running -> None. Nothing waiting -> remove it, None. Otherwise take every waiting + // text, joined with "\n\n", as the next Batch (it stays running). + let mut pending = self.running.remove(session)?; + if pending.waiting.is_empty() { + return None; + } + let batch = Batch { + session: session.clone(), + thread: pending.thread.clone(), + resume: true, + text: pending.waiting.join("\n\n"), + }; + pending.waiting.clear(); + self.running.insert(session.clone(), pending); + Some(batch) + } + + /// How many sessions have a turn running. + pub fn running(&self) -> usize { + // How many sessions are running. + self.running.len() + } + + /// The threads with a turn running, for showing this bot as typing in them. + pub fn threads(&self) -> Vec { + // The thread of every running session. + self.running.values().map(|p| p.thread.clone()).collect() + } +} diff --git a/crates/gatewayd/tests/sessions.rs b/crates/gatewayd/tests/sessions.rs new file mode 100644 index 0000000..3c48d77 --- /dev/null +++ b/crates/gatewayd/tests/sessions.rs @@ -0,0 +1,298 @@ +//! Which posts become turns, in which session, and how messages wait for a running turn (M4a spec, +//! section 7, including its table of examples). Do not edit. + +use gatewayd::mm::Post; +use gatewayd::sessions::{ + BUSY, Batch, Ignored, M4B_COMMAND, Message, Pushed, Queues, Route, Router, Thread, + UNKNOWN_COMMAND, named, +}; +use proto::SessionId; + +const BOT: &str = "b0000000000000000000000000"; +const KYLE: &str = "k0000000000000000000000000"; +const EVE: &str = "e0000000000000000000000000"; +const DM: &str = "d0000000000000000000000000"; +const SHARED: &str = "c0000000000000000000000000"; +const OTHER: &str = "o0000000000000000000000000"; +const ROOT: &str = "r0000000000000000000000000"; +const POST: &str = "p0000000000000000000000000"; + +fn router() -> Router { + Router::new( + BOT, + "boxmaker-straylight", + &[KYLE.to_string()], + &[SHARED.to_string()], + ) +} + +fn post(channel: &str, root: &str, message: &str) -> Post { + Post { + id: POST.to_string(), + user_id: KYLE.to_string(), + channel_id: channel.to_string(), + root_id: root.to_string(), + message: message.to_string(), + create_at: 5, + delete_at: 0, + kind: String::new(), + } +} + +fn known(root: &str) -> bool { + root == ROOT +} + +fn unknown(_: &str) -> bool { + false +} + +fn queued(route: Route) -> Message { + match route { + Route::Queue(m) => m, + other => panic!("not queued: {other:?}"), + } +} + +#[test] +fn the_examples_in_the_spec() { + let r = router(); + let cases = [ + ("", "@boxmaker-straylight summarise the audit log", true), + (ROOT, "and the older files?", true), + (ROOT, "@hermes what do you think?", false), + ("", "@boxmaker-straylight @hermes compare notes", true), + ("", "@boxmaker-straylightx hello", false), + ("", "@channel standup in five", false), + ]; + for (root, message, yes) in cases { + let got = r.route(&post(SHARED, root, message), "O", &known); + assert_eq!(matches!(got, Route::Queue(_)), yes, "{message}: {got:?}"); + if !yes { + assert_eq!(got, Route::Ignore(Ignored::NotForUs), "{message}"); + } + } +} + +#[test] +fn naming() { + assert_eq!(named("@Boxmaker-Straylight, look"), ["boxmaker-straylight"]); + assert_eq!(named("ask @boxmaker-straylight."), ["boxmaker-straylight"]); + assert_eq!(named("@a.b_c-d... and @e"), ["a.b_c-d", "e"]); + assert_eq!(named("@ alone, @@x, trailing @"), ["x"]); + assert_eq!(named("ünïcødé @ʙob @bob"), ["bob"]); + assert!(named("no names here").is_empty()); + let r = router(); + for message in [ + "hi @BOXMAKER-STRAYLIGHT", + "@boxmaker-straylight.", + "(@boxmaker-straylight)", + ] { + assert!( + matches!( + r.route(&post(SHARED, "", message), "P", &unknown), + Route::Queue(_) + ), + "{message}" + ); + } +} + +#[test] +fn replies_in_our_thread_that_name_everyone_are_still_ours() { + let r = router(); + for message in ["@here any news?", "@all done", "thanks @channel"] { + assert!( + matches!( + r.route(&post(SHARED, ROOT, message), "O", &known), + Route::Queue(_) + ), + "{message}" + ); + } + let got = r.route( + &post( + SHARED, + "q0000000000000000000000000", + "a reply in someone else's thread", + ), + "O", + &known, + ); + assert_eq!(got, Route::Ignore(Ignored::NotForUs)); +} + +#[test] +fn who_and_where() { + let r = router(); + let mut own = post(DM, "", "hello"); + own.user_id = BOT.to_string(); + assert_eq!(r.route(&own, "D", &unknown), Route::Ignore(Ignored::Own)); + let mut system = post(DM, "", "joined"); + system.kind = "system_join_channel".to_string(); + assert_eq!( + r.route(&system, "D", &unknown), + Route::Ignore(Ignored::System) + ); + let mut stranger = post(DM, "", "@boxmaker-straylight hello"); + stranger.user_id = EVE.to_string(); + assert_eq!( + r.route(&stranger, "D", &unknown), + Route::Ignore(Ignored::NotAllowed) + ); + let mut stranger_cmd = post(DM, "", "!approve 1"); + stranger_cmd.user_id = EVE.to_string(); + assert_eq!( + r.route(&stranger_cmd, "D", &unknown), + Route::Ignore(Ignored::NotAllowed) + ); + let naming = "@boxmaker-straylight hello"; + assert_eq!( + r.route(&post(OTHER, "", naming), "O", &unknown), + Route::Ignore(Ignored::NotForUs) + ); + assert_eq!( + r.route(&post(SHARED, "", naming), "X", &unknown), + Route::Ignore(Ignored::NotForUs) + ); + assert!(matches!( + r.route(&post(SHARED, "", naming), "G", &unknown), + Route::Queue(_) + )); + assert!(matches!( + r.route(&post(DM, "", "no name needed"), "D", &unknown), + Route::Queue(_) + )); +} + +#[test] +fn sessions_and_threads() { + let r = router(); + let top = queued(r.route(&post(DM, "", "hello"), "D", &unknown)); + assert_eq!(top.session.as_str(), format!("mm-{POST}")); + assert_eq!( + top.thread, + Thread { + channel: DM.to_string(), + root: POST.to_string() + } + ); + assert!(!top.resume && !top.joins_thread); + assert_eq!(top.text, "hello"); + let reply = queued(r.route(&post(DM, ROOT, "more"), "D", &unknown)); + assert_eq!(reply.session.as_str(), format!("mm-{ROOT}")); + assert_eq!(reply.thread.root, ROOT); + assert!(reply.resume); + let channel = queued(r.route(&post(SHARED, "", "@boxmaker-straylight hi"), "O", &unknown)); + assert!(channel.joins_thread && !channel.resume); + assert_eq!(channel.text, "@boxmaker-straylight hi"); +} + +#[test] +fn commands() { + let r = router(); + let thread = Thread { + channel: DM.to_string(), + root: ROOT.to_string(), + }; + let reply = |text: &str| Route::Reply { + thread: thread.clone(), + text: text.to_string(), + }; + for (message, answer) in [ + ("!approve 42", M4B_COMMAND), + ("!deny 42 not now", M4B_COMMAND), + ("!deny", M4B_COMMAND), + ("!approved", UNKNOWN_COMMAND), + ("!help", UNKNOWN_COMMAND), + ("!", UNKNOWN_COMMAND), + ] { + assert_eq!( + r.route(&post(DM, ROOT, message), "D", &unknown), + reply(answer), + "{message}" + ); + } + assert_eq!( + queued(r.route(&post(DM, ROOT, "!!approve is a word"), "D", &unknown)).text, + "!approve is a word" + ); + assert_eq!( + queued(r.route(&post(DM, ROOT, "!!"), "D", &unknown)).text, + "!" + ); + assert_eq!( + queued(r.route(&post(DM, ROOT, " !help"), "D", &unknown)).text, + " !help" + ); +} + +fn message(root: &str, resume: bool, text: &str) -> Message { + Message { + session: SessionId::new(&format!("mm-{root}")).unwrap(), + thread: Thread { + channel: DM.to_string(), + root: root.to_string(), + }, + resume, + text: text.to_string(), + joins_thread: false, + } +} + +#[test] +fn messages_wait_for_the_running_turn_and_go_together() { + let mut q = Queues::new(3); + let first = message(ROOT, false, "one"); + let Pushed::Start(batch) = q.push(first.clone()) else { + panic!("not started") + }; + assert_eq!( + batch, + Batch { + session: first.session.clone(), + thread: first.thread.clone(), + resume: false, + text: "one".to_string() + } + ); + assert_eq!(q.push(message(ROOT, true, "two")), Pushed::Waiting); + assert_eq!(q.push(message(ROOT, true, "three\nlines")), Pushed::Waiting); + let other = message(POST, false, "elsewhere"); + assert!( + matches!(q.push(other.clone()), Pushed::Start(_)), + "another session starts at once" + ); + assert_eq!(q.running(), 2); + let mut roots: Vec = q.threads().into_iter().map(|t| t.root).collect(); + roots.sort(); + assert_eq!(roots, [POST, ROOT]); + let next = q.finish(&first.session).unwrap(); + assert_eq!( + (next.text.as_str(), next.resume), + ("two\n\nthree\nlines", true) + ); + assert_eq!(q.finish(&first.session), None); + assert_eq!(q.finish(&other.session), None); + assert_eq!(q.running(), 0); + assert!( + matches!(q.push(message(ROOT, true, "later")), Pushed::Start(_)), + "idle again" + ); +} + +#[test] +fn a_full_queue_drops_the_message() { + let mut q = Queues::new(2); + let m = message(ROOT, false, "run"); + assert!(matches!(q.push(m.clone()), Pushed::Start(_))); + assert_eq!(q.push(message(ROOT, true, "a")), Pushed::Waiting); + assert_eq!(q.push(message(ROOT, true, "b")), Pushed::Waiting); + assert_eq!( + q.push(message(ROOT, true, "c")), + Pushed::Full(m.thread.clone()) + ); + assert_eq!(q.finish(&m.session).unwrap().text, "a\n\nb"); + assert!(!BUSY.is_empty()); + assert_eq!(q.finish(&SessionId::new("mm-never").unwrap()), None); +} diff --git a/docs/implementer-log.md b/docs/implementer-log.md index d792ac2..858a4bd 100644 --- a/docs/implementer-log.md +++ b/docs/implementer-log.md @@ -6,6 +6,7 @@ reviewer adds findings under "Reviews" once per milestone. | Task | Date | Status | Gate runs | First gate | Deviations | Notes | Model | |---|---|---|---|---|---|---|---| +| M4a/11-gatewayd-sessions | 2026-09-23 | done | 2 | fail | Removed the `resume` field from the copied `Pending` struct (written by the skeleton but never read) | Filled `named` (byte scan for `@`, the longest run of ASCII alnum/`.` `-` `_` after it, trailing `.` trimmed, lower-cased, empty runs skipped, then continue past the name) and the `Router` (`route` is a straight line of early returns in spec order: own -> Ignore(Own); kind not empty -> System; user not in allow.users -> NotAllowed; then the channel where "D" is always ours and "O"/"P"/"G" needs an allowed channel id plus `for_us`; the thread root is `root_id` or the post id; `!!...` keeps one `!` and queues, a lone `!` or `!approve`/`!deny` is a command where approve/deny answers M4B_COMMAND and anything else UNKNOWN_COMMAND, a command never reaches loopd; then Queue with session `mm-`, `resume = root_id not empty`, `joins_thread = channel_type != "D"`). `for_us` is true when it names this bot (case-insensitive), otherwise a reply in a known thread that names only channel/here/all. `Queues`: `push` starts a turn when idle (Start with that message alone), waits while running and returns Full(thread) at the limit; `finish` joins the waiting texts with "\n\n" as a resume:true Batch, clears the queue and stays running, removing the session when nothing waits. `Pending.resume` was dead code (the next turn is always a continuation so `finish` hardcodes resume:true) so I removed it rather than allow a lint. First gate failed on clippy `manual_strip`; switched `starts_with("!!")`/`starts_with('!')` plus `&message[2..]`/`&message[1..]` slicing to `strip_prefix`. All 8 sessions tests pass; `make gate` prints `gate: ok` on the second run. | ? | | M4a/10-gatewayd-mm | 2026-09-23 | done | 1 | pass | none | Copied `tests/mm_json.rs`, `tests/mm_rest.rs` and `tests/support/http_server.rs`, added the `src/mm/mod.rs` and `src/mm/rest.rs` skeletons to `crates/gatewayd/src/mm/` and `pub mod mm;` to `lib.rs` (before `secrets`, alphabetical). Filled `mod.rs`: `Post::check` requires `id`/`user_id`/`channel_id` `valid_id` and `root_id` empty-or-`valid_id`, else `Json` quoting the offending id with `{:?}`; `json` is `serde_json::from_slice` mapped to `Json(e.to_string())`; `parse_event` matches `hello`/`posted`/other — `posted` takes `data.post` as a JSON *string* (an object or missing is `Json`), parses it, `check`s it, and reads `data.channel_type` (else ""), any other name (or an empty-name reply) is `Other(name)`; `typing` is `serde_json::json!` compacted; `since_list` walks `order` only (skipping ids not in `posts`, keeping `create_at > since && delete_at == 0` after `check`, deduping, then sorting by `(create_at, id)`), `full` when `order.len() >= SINCE_LIMIT`. Filled `rest.rs`: `Client::new` stores the three fields; `once` connects within `timeout`, sets the read timeout, sends `Authorization: Bearer ` (the only `expose`), `Accept`/`Content-Type` headers and `host_header`, mapping every error to `Net(" : ")`; `call` loops `once` — 2xx returns the body, 401/403 `Auth`, 429 waits `rate_limit_wait` up to `RETRIES` then `RateLimited`, 5xx retried up to `RETRIES` times sleeping `RETRY_5XX`, else `Status` with the first `BODY_KEPT` lossy-UTF-8 chars via a `status_error` helper; `me`/`create_post`/`posts_since`/`direct_channel` build the four calls, `posts_since` and `me`/`direct_channel` reject non-`valid_id` ids as `Json` before sending. All 7 `mm_json` and 10 `mm_rest` tests pass (the latter ~3 s on two deliberate rate-limit waits); `make gate` prints `gate: ok` first run. | ? | | M4a/09-gatewayd-ws-conn | 2026-09-23 | done | 1 | pass | none | Filled the eight functions in the copied `crates/gatewayd/src/ws/conn.rs` skeleton (the written `poll` was the glue). `open`: `connector.connect(dead_after)` mapped to `Handshake(e.to_string())`, then `handshake` with `host_header(connector.server())`, a Ws with a new `Decoder` and `last_heard`/`last_ping` both `now`. `send`: `read_exact` 4 mask bytes from `random`, then `encode(opcode, payload, mask)` written and flushed. `send_text`: `send(TEXT, text.as_bytes())`. `take_messages`: loop `next_message`, `Text` returns, `Ping` answered with `send(PONG, &payload)`, `Pong` ignored, `Close` replies the code as 2 big-endian bytes (empty when none) via a best-effort `send(CLOSE, ...)` (the peer may be gone) and returns `Closed`. `keep_alive`: `now.duration_since(last_heard) >= dead_after` is `Dead`, else `now.duration_since(last_ping) >= ping_every` pings and stamps `last_ping`. `read_timeout`: the least of next-ping, next-dead and until-left (each `saturating_duration_since`), then `.max(1ms)`. `read_some`: `set_read_timeout`, a 16 KiB buffer, `Ok(0)` -> `Closed`, `Ok(n)` feeds `buf.get(..n).unwrap_or_default()` and stamps `last_heard`, `WouldBlock`/`TimedOut`/`Interrupted` -> `Ok(())`, any other `Err` -> `Io`. `close`: best-effort `send(CLOSE, &1000u16.to_be_bytes())`. `host_header`: host alone when the port is the scheme default (443 for tls, 80 otherwise) else `host:port`. All 10 tests in `tests/ws_conn.rs` pass five runs under a second; `make gate` prints `gate: ok` first run. | ? | | M4a/08-gatewayd-ws-frames | 2026-09-23 | done | 1 | pass | none | Copied `tests/ws_frame.rs` and the `src/ws/frame.rs` skeleton, added `pub mod frame;` (before `handshake`, alphabetical). Filled the seven functions the comments specified verbatim: `check_first_bytes` (reserved bits `b0 & 0x70`, mask `b1 & 0x80`, opcode `matches!(b0 & 0x0F, CONTINUATION | TEXT | CLOSE | PING | PONG)`); `check_control` (not fin, then >125); `length` (match on `short`: 0..=125, 126 reading `buf.get(2..4)` into `u16::from_be_bytes` with `len < 126` refused, 127 reading `buf.get(2..10)` into `u64::from_be_bytes` with top-bit and `<= 0xFFFF` refused, `usize::try_from(len).unwrap_or(usize::MAX)`); `check_data` (TEXT while partial, CONTINUATION while none, `payload_len > MAX_MESSAGE.saturating_sub(so_far)`); `data_frame` (empty Vec for TEXT else `partial.take().unwrap_or_default()`, append, defer when not fin, `String::from_utf8` at fin); `close` (slice-pattern match, `u16::from_be_bytes` code, UTF-8 reason); `encode` (FIN+opcode, three length branches with mask bit, XOR with `mask.iter().cycle()`). All string literals got `.to_string()` for the `Protocol(String)` variant, matching http.rs. `length`'s match needed a defensive `_` arm (`128..=u8::MAX` unreachable since `short = b1 & 0x7F`) so the codec stays exhaustive without a panic. All 7 tests in `tests/ws_frame.rs` pass including the 300-seed property test against the naive decoder; `make gate` prints `gate: ok` first run. | ? |