diff --git a/crates/gatewayd/src/state.rs b/crates/gatewayd/src/state.rs index 0cfc438..39531a4 100644 --- a/crates/gatewayd/src/state.rs +++ b/crates/gatewayd/src/state.rs @@ -51,6 +51,19 @@ struct StateFile { recent: Vec, threads: Vec, in_flight: Vec, + /// Optional, so an M4a state file loads unchanged. + #[serde(default)] + approvals: Vec, +} + +/// The posts of an approval shown in Mattermost and not yet ended (M4b spec, section 8). +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct ApprovalPosts { + pub approval: u64, + pub channel: String, + /// First part first; the last is the one that is edited when it ends. + pub posts: Vec, } impl StateFile { @@ -84,6 +97,14 @@ impl StateFile { return Some(format!("a turn in flight is not valid: {:?}", turn.session)); } } + for shown in &self.approvals { + if !valid_id(&shown.channel) + || shown.posts.is_empty() + || !shown.posts.iter().all(|post| valid_id(post)) + { + return Some(format!("approval {}'s posts are not valid", shown.approval)); + } + } None } } @@ -250,4 +271,43 @@ impl State { } self.save().map(|()| taken) } + + /// The turn of a session, if one is in flight. + pub fn turn_of(&self, session: &str) -> Option { + // The entry of in_flight with that session, cloned. + self.file + .in_flight + .iter() + .find(|turn| turn.session == session) + .cloned() + } + + /// A new posting of an approval shown in Mattermost; it replaces any earlier one. + pub fn add_approval(&mut self, shown: ApprovalPosts) -> Result<(), StateError> { + // Remove any entry with the same approval, push this one, save. + self.file.approvals.retain(|a| a.approval != shown.approval); + self.file.approvals.push(shown); + self.save() + } + + /// Stop tracking an approval; nothing saved when it was not tracked. + pub fn remove_approval(&mut self, approval: u64) -> Result<(), StateError> { + // Remove the entries with that approval, save only if one was removed. + let before = self.file.approvals.len(); + self.file.approvals.retain(|a| a.approval != approval); + if self.file.approvals.len() == before { + return Ok(()); + } + self.save() + } + + /// The approval posts left behind by the last run, removed from the state. + pub fn take_approvals(&mut self) -> Result, StateError> { + // Take them all out (std::mem::take), save only when there were some, return them. + let taken = std::mem::take(&mut self.file.approvals); + if taken.is_empty() { + return Ok(taken); + } + self.save().map(|()| taken) + } } diff --git a/crates/gatewayd/tests/state_approvals.rs b/crates/gatewayd/tests/state_approvals.rs new file mode 100644 index 0000000..ee32a94 --- /dev/null +++ b/crates/gatewayd/tests/state_approvals.rs @@ -0,0 +1,95 @@ +//! The approval posts in the state file, for marking them at a restart; an M4a state file still +//! loads; and the turn of a session (M4b spec, sections 6 and 8). Do not edit. + +#[path = "support/tmp.rs"] +mod tmp; + +use gatewayd::state::{ApprovalPosts, InFlight, State, StateError}; +use tmp::TempDir; + +const DM: &str = "d0000000000000000000000000"; +const ROOT: &str = "r0000000000000000000000000"; + +fn id(n: u32) -> String { + format!("p{n:025}") +} + +fn shown(approval: u64, posts: &[u32]) -> ApprovalPosts { + ApprovalPosts { + approval, + channel: DM.to_string(), + posts: posts.iter().map(|n| id(*n)).collect(), + } +} + +#[test] +fn approval_posts_survive_a_restart_and_are_taken_once() { + let dir = TempDir::new("state-approvals"); + let path = dir.path().join("gateway/state.json"); + let mut s = State::load(&path).unwrap(); + s.add_approval(shown(42, &[1, 2])).unwrap(); + s.add_approval(shown(43, &[3])).unwrap(); + s.add_approval(shown(42, &[4])).unwrap(); + s.remove_approval(43).unwrap(); + s.remove_approval(99).unwrap(); + let mut again = State::load(&path).unwrap(); + assert_eq!( + again.take_approvals().unwrap(), + [shown(42, &[4])], + "the later posting of 42 replaced the first" + ); + assert!( + State::load(&path) + .unwrap() + .take_approvals() + .unwrap() + .is_empty(), + "taking is saved" + ); +} + +#[test] +fn an_m4a_state_file_loads() { + let dir = TempDir::new("state-m4a"); + let text = format!(r#"{{"channels":{{"{DM}":5}},"recent":[],"threads":[],"in_flight":[]}}"#); + let path = dir.write("state.json", &text); + let mut s = State::load(&path).unwrap(); + assert_eq!(s.since(DM), Some(5)); + assert!(s.take_approvals().unwrap().is_empty()); +} + +#[test] +fn bad_approval_posts_are_refused() { + let dir = TempDir::new("state-approvals-bad"); + for (n, approvals) in [ + r#"[{"approval":1,"channel":"../x","posts":["p0000000000000000000000001"]}]"#, + r#"[{"approval":1,"channel":"d0000000000000000000000000","posts":[]}]"#, + r#"[{"approval":1,"channel":"d0000000000000000000000000","posts":["short"]}]"#, + r#"[{"approval":1,"channel":"d0000000000000000000000000","posts":["p0000000000000000000000001"],"extra":1}]"#, + r#"[{"approval":-1,"channel":"d0000000000000000000000000","posts":["p0000000000000000000000001"]}]"#, + ] + .iter() + .enumerate() + { + let text = format!(r#"{{"channels":{{}},"recent":[],"threads":[],"in_flight":[],"approvals":{approvals}}}"#); + let path = dir.write(&format!("s{n}.json"), &text); + assert!(matches!(State::load(&path), Err(StateError::Read(..))), "{approvals}"); + } +} + +#[test] +fn the_turn_of_a_session() { + let dir = TempDir::new("state-turn-of"); + let mut s = State::load(&dir.path().join("state.json")).unwrap(); + let turn = InFlight { + session: format!("mm-{ROOT}"), + channel: DM.to_string(), + root: ROOT.to_string(), + }; + assert_eq!(s.turn_of(&turn.session), None); + s.start_turn(turn.clone()).unwrap(); + assert_eq!(s.turn_of(&turn.session), Some(turn.clone())); + assert_eq!(s.turn_of("mm-other"), None); + s.end_turn(&turn.session).unwrap(); + assert_eq!(s.turn_of(&turn.session), None); +} diff --git a/docs/implementer-log.md b/docs/implementer-log.md index 035b7a3..b3b6c42 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 | |---|---|---|---|---|---|---|---| +| M4b/12-gatewayd-state-approvals | 2026-09-24 | done | 1 | pass | none | Copied `docs/plans/M4b/files/crates/gatewayd/tests/state_approvals.rs` into `crates/gatewayd/tests/`. In `state.rs`: added the `#[serde(default)] approvals: Vec` field after `in_flight` (optional so an M4a state file loads unchanged) and the `ApprovalPosts { approval, channel, posts }` struct (`#[serde(deny_unknown_fields)`) after `struct StateFile`; in `problem` the `in_flight` loop now has a loop over `approvals` requiring a `valid_id` channel and at least one `valid_id` post, else "approval {}'s posts are not valid"; added `turn_of` (find the `in_flight` entry of a session, cloned), `add_approval` (replace any entry of the same approval, push, save), `remove_approval` (retain by approval, save only when one was removed) and `take_approvals` (`std::mem::take`, save only when there were some, return them) at the end of `impl State`. `cargo fmt --all` then `cargo test -p gatewayd --test state_approvals --test state --test serve_restart` green (4, 4 and 7 passed); `make gate` prints `gate: ok` on the first run. | ? | | M4b/11-gatewayd-approvals-registry | 2026-09-24 | done | 1 | pass | none | Copied `docs/plans/M4b/files/crates/gatewayd/src/approvals/mod.rs` (the structs `Posted`/`Judged`/`Registry`, the `pub mod text;` still there, and the five `todo!()`s) to `crates/gatewayd/src/approvals/`, and `docs/plans/M4b/files/crates/gatewayd/tests/approvals_judge.rs` to `crates/gatewayd/tests/`. Filled `add` (insert by `approval`), `remove` (remove by id), `holds` (contains_key), `waiting_sessions` (the `session` of every entry), and `judge` (the six spec-section-7 checks in order: `user` in `approvers` else `NOT_APPROVER`, the command to `(id, approved, reason)` — `Approve` true/None, `Deny` false/`reason.clone()`, `Malformed` `USAGE`, then `get(&id)` present else "not pending here", `answerable` else "bxctl approvals", `root` matches else "its own thread", `at <= shown_at` else "answered before shown in full", then `remove` and `send` the `ApprovalReply`, a gone connection → "not pending here"). Brought `Approve`/`Deny`/`Malformed` into scope with the `ApprovalCommand` type, and cloned the `answer` `Sender` out of the entry before the early-return refusals so the held lock's `remove` does not conflict. All six `approvals_judge` and six `approvals_text` tests pass; `make gate` prints `gate: ok` on the first run. | ? | | M4b/10-gatewayd-approval-text | 2026-09-24 | done | 1 | pass | none | Copied `docs/plans/M4b/files/crates/gatewayd/src/approvals/mod.rs-task10` to `crates/gatewayd/src/approvals/mod.rs` (the module comment and `pub mod text;`), the `text.rs` skeleton, and `docs/plans/M4b/files/crates/gatewayd/tests/approvals_text.rs` to `crates/gatewayd/tests/`, then added `pub mod approvals;` to `lib.rs` first, before `pub mod config;`. Filled the eight functions the skeleton left as `todo!()`: `code` (a backtick run one longer than `longest_run`, with a padding space on both sides when the text starts or ends with a backtick so CommonMark keeps it literal), `fence` (same, at least three backticks, wrapped `fence\n`), `longest_run` (a char scan keeping the longest run of consecutive backticks), `minute` (the rfc3339 date and `HH:MM` plus ` UTC`), `name` (the serde string form when there is one, else `{value:?}`), `reply_line` (the `!approve`/`!deny` line), `ended_line` (the six-arm match on `Ended`, the `Denied` reason and any `Refused` reason run through `name`/`code`), and `with_ending` (the last post, a blank line, then `ended_line`). `render` was already written in the skeleton. All six `approvals_text` tests pass; `make gate` prints `gate: ok` on the first run. | ? | | M4b/09-gatewayd-mm-edit | 2026-09-24 | done | 1 | pass | none | Copied `docs/plans/M4b/files/crates/gatewayd/tests/mm_edit.rs` into `crates/gatewayd/tests/`. In `rest.rs`: added the `User { username: String }` struct and `valid_username` (1..=64 bytes of a-z, 0-9, `.`, `-` and `_`) after `Channel`, then `patch_post` (a `post` that is not `valid_id` is `Json` and nothing is sent; otherwise PUT `/api/v4/posts/{post}/patch` with `{"message": message}` through `call`, the answer a `Post` `check`ed and dropped) and `username` (a `user` that is not `valid_id` is `Json`; GET `/api/v4/users/{user}`, a username that is not `valid_username` is `Json`, else the name) at the end of `impl Client`. `cargo fmt --all` then `cargo test -p gatewayd --test mm_edit --test mm_rest` green (4 and 10 passed); `make gate` prints `gate: ok` on the first run. | ? |