gatewayd: approvals::text, the posts that show an approval
Implemented-By: OpenCode session (model recorded in docs/implementer-log.md)
This commit is contained in:
@@ -0,0 +1,4 @@
|
|||||||
|
//! Approvals in Mattermost (M4b spec, sections 6 and 7): the text of the posts, and the
|
||||||
|
//! approvals this `gatewayd` has posted and the commands that answer them.
|
||||||
|
|
||||||
|
pub mod text;
|
||||||
@@ -0,0 +1,175 @@
|
|||||||
|
//! The text of an approval's posts, and of the line added when it ends (M4b spec, section 6).
|
||||||
|
//! Nothing here is shortened: arguments too long to show whole are not shown at all.
|
||||||
|
|
||||||
|
use proto::escape::escape_json_text;
|
||||||
|
use proto::{DenyReason, PendingApproval, Timestamp, sha256};
|
||||||
|
|
||||||
|
/// Arguments are split into parts of at most this many characters.
|
||||||
|
pub const PART_CHARS: usize = 15_000;
|
||||||
|
/// At most this many parts; more are answerable only in `bxctl`.
|
||||||
|
pub const MAX_PARTS: usize = 4;
|
||||||
|
/// The longest a part's post may be, leaving room for the line added when it ends.
|
||||||
|
pub const MAX_PART_POST: usize = 15_500;
|
||||||
|
|
||||||
|
/// An approval's posts, in order. Only an answer after the last counts; when `answerable` is
|
||||||
|
/// false, no command answers it.
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
pub struct Shown {
|
||||||
|
pub posts: Vec<String>,
|
||||||
|
pub answerable: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// How an approval ended, for the line added to its last post.
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
pub enum Ended {
|
||||||
|
Runs { by: String },
|
||||||
|
Denied { by: String, reason: DenyReason },
|
||||||
|
Refused { by: String, reason: Option<String> },
|
||||||
|
NoLonger,
|
||||||
|
Restarted,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `text` as inline code, with a backtick run longer than any inside it.
|
||||||
|
pub fn code(text: &str) -> String {
|
||||||
|
let ticks = "`".repeat(longest_run(text) + 1);
|
||||||
|
let pad = if text.starts_with('`') || text.ends_with('`') {
|
||||||
|
" "
|
||||||
|
} else {
|
||||||
|
""
|
||||||
|
};
|
||||||
|
format!("{ticks}{pad}{text}{pad}{ticks}")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `text` as a fenced block, with a fence longer than any backtick run inside it (at least 3).
|
||||||
|
pub fn fence(text: &str) -> String {
|
||||||
|
let ticks = "`".repeat((longest_run(text) + 1).max(3));
|
||||||
|
format!("{ticks}\n{text}\n{ticks}")
|
||||||
|
}
|
||||||
|
|
||||||
|
fn longest_run(text: &str) -> usize {
|
||||||
|
let mut longest = 0;
|
||||||
|
let mut current = 0;
|
||||||
|
for c in text.chars() {
|
||||||
|
current = if c == '`' { current + 1 } else { 0 };
|
||||||
|
longest = longest.max(current);
|
||||||
|
}
|
||||||
|
longest
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `2026-09-24 14:47 UTC`.
|
||||||
|
fn minute(at: Timestamp) -> String {
|
||||||
|
let rfc = at.to_rfc3339();
|
||||||
|
format!(
|
||||||
|
"{} {} UTC",
|
||||||
|
rfc.get(..10).unwrap_or_default(),
|
||||||
|
rfc.get(11..16).unwrap_or_default()
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A serde name (`private`, `no_grant`), or the debug form if there is none.
|
||||||
|
fn name<T: serde::Serialize + std::fmt::Debug>(value: &T) -> String {
|
||||||
|
match serde_json::to_value(value) {
|
||||||
|
Ok(serde_json::Value::String(s)) => s,
|
||||||
|
_ => format!("{value:?}"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn reply_line(id: u64) -> String {
|
||||||
|
format!("Reply in this thread with `!approve {id}` or `!deny {id} <reason>`.")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The posts that show `info`.
|
||||||
|
pub fn render(info: &PendingApproval) -> Shown {
|
||||||
|
let id = info.approval;
|
||||||
|
let args = escape_json_text(&info.arguments);
|
||||||
|
let header = format!(
|
||||||
|
"**Approval {id}** · tool {} · grant {} · taint {} · expires {}",
|
||||||
|
code(&escape_json_text(&info.tool)),
|
||||||
|
code(&escape_json_text(&info.grant)),
|
||||||
|
name(&info.taint),
|
||||||
|
minute(info.expires),
|
||||||
|
);
|
||||||
|
let chars: Vec<char> = args.chars().collect();
|
||||||
|
let parts: Vec<String> = if chars.is_empty() {
|
||||||
|
vec![String::new()]
|
||||||
|
} else {
|
||||||
|
chars
|
||||||
|
.chunks(PART_CHARS)
|
||||||
|
.map(|c| c.iter().collect())
|
||||||
|
.collect()
|
||||||
|
};
|
||||||
|
let count = parts.len();
|
||||||
|
let posts: Vec<String> = if count == 1 {
|
||||||
|
vec![format!("{header}\n{}\n{}", fence(&args), reply_line(id))]
|
||||||
|
} else {
|
||||||
|
let hash = sha256(args.as_bytes())
|
||||||
|
.map(|h| h.to_hex())
|
||||||
|
.unwrap_or_default();
|
||||||
|
parts
|
||||||
|
.iter()
|
||||||
|
.enumerate()
|
||||||
|
.map(|(i, part)| {
|
||||||
|
let n = i + 1;
|
||||||
|
let top = if n == 1 {
|
||||||
|
format!("{header}\nPart 1 of {count} of the arguments:")
|
||||||
|
} else {
|
||||||
|
format!("**Approval {id}**, part {n} of {count} of the arguments:")
|
||||||
|
};
|
||||||
|
let mut post = format!("{top}\n{}", fence(part));
|
||||||
|
if n == count {
|
||||||
|
post.push_str(&format!(
|
||||||
|
"\nApproval {id}: {count} parts, {} characters, sha256 {hash} (of the arguments as shown).\n{}",
|
||||||
|
chars.len(),
|
||||||
|
reply_line(id)
|
||||||
|
));
|
||||||
|
}
|
||||||
|
post
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
|
};
|
||||||
|
if count > MAX_PARTS || posts.iter().any(|p| p.chars().count() > MAX_PART_POST) {
|
||||||
|
let hash = sha256(args.as_bytes())
|
||||||
|
.map(|h| h.to_hex())
|
||||||
|
.unwrap_or_default();
|
||||||
|
let post = format!(
|
||||||
|
"{header}\nThe arguments are {} characters (sha256 {hash}), too long to show here; answer it with `bxctl approvals`.",
|
||||||
|
chars.len()
|
||||||
|
);
|
||||||
|
return Shown {
|
||||||
|
posts: vec![post],
|
||||||
|
answerable: false,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
Shown {
|
||||||
|
posts,
|
||||||
|
answerable: true,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The line added to the last post when the approval ends. `by` is `@username`, or the id.
|
||||||
|
pub fn ended_line(ended: &Ended) -> String {
|
||||||
|
match ended {
|
||||||
|
Ended::Runs { by } => format!("✅ Approved by {by}: the call runs."),
|
||||||
|
Ended::Denied { by, reason } => {
|
||||||
|
format!("Approved by {by}, but denied on the re-check: {}.", name(reason))
|
||||||
|
}
|
||||||
|
Ended::Refused { by, reason: Some(reason) } => {
|
||||||
|
format!("❌ Refused by {by}: {}.", code(&escape_json_text(reason)))
|
||||||
|
}
|
||||||
|
Ended::Refused { by, reason: None } => {
|
||||||
|
format!("❌ Refused by {by}: no reason given.")
|
||||||
|
}
|
||||||
|
Ended::NoLonger => {
|
||||||
|
"No longer pending (answered elsewhere, expired, or its turn ended).".to_string()
|
||||||
|
}
|
||||||
|
Ended::Restarted => {
|
||||||
|
"No longer answerable here: gatewayd restarted. If it is still pending it is posted again."
|
||||||
|
.to_string()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The last post's text with the ending line added.
|
||||||
|
pub fn with_ending(last: &str, ended: &Ended) -> String {
|
||||||
|
format!("{last}\n\n{}", ended_line(ended))
|
||||||
|
}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
//! `gatewayd`: the Mattermost channel. It carries the owner's messages to `loopd` as turns and posts
|
//! `gatewayd`: the Mattermost channel. It carries the owner's messages to `loopd` as turns and posts
|
||||||
//! the answers back, over outbound connections only. M4a spec: `docs/specs/2026-09-23-m4a-gateway.md`.
|
//! the answers back, over outbound connections only. M4a spec: `docs/specs/2026-09-23-m4a-gateway.md`.
|
||||||
|
|
||||||
|
pub mod approvals;
|
||||||
pub mod config;
|
pub mod config;
|
||||||
pub mod deliver;
|
pub mod deliver;
|
||||||
pub mod http;
|
pub mod http;
|
||||||
|
|||||||
@@ -0,0 +1,197 @@
|
|||||||
|
//! The text of an approval's posts and of its ending line (M4b spec, section 6). Do not edit.
|
||||||
|
|
||||||
|
use gatewayd::approvals::text::{
|
||||||
|
Ended, MAX_PART_POST, MAX_PARTS, PART_CHARS, code, ended_line, fence, render, with_ending,
|
||||||
|
};
|
||||||
|
use proto::{CallId, DataClass, DenyReason, PendingApproval, SessionId, Timestamp, sha256};
|
||||||
|
|
||||||
|
/// The escape for one code point, built here so this file holds no such character itself.
|
||||||
|
fn esc(code: u32) -> String {
|
||||||
|
format!("{}u{code:04x}", char::from(92))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn info(arguments: &str) -> PendingApproval {
|
||||||
|
PendingApproval {
|
||||||
|
approval: 42,
|
||||||
|
session: SessionId::new("mm-r0000000000000000000000001").unwrap(),
|
||||||
|
call: CallId(3),
|
||||||
|
tool: "shell".to_string(),
|
||||||
|
arguments: arguments.to_string(),
|
||||||
|
grant: "ops-shell".to_string(),
|
||||||
|
taint: DataClass::Private,
|
||||||
|
created: Timestamp::parse("2026-09-24T14:32:10.000Z").unwrap(),
|
||||||
|
expires: Timestamp::parse("2026-09-24T14:47:10.000Z").unwrap(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn one_part_exactly() {
|
||||||
|
let shown = render(&info(
|
||||||
|
r#"{"command":"ls -la /srv/backups","timeout_ms":10000}"#,
|
||||||
|
));
|
||||||
|
assert!(shown.answerable);
|
||||||
|
assert_eq!(
|
||||||
|
shown.posts,
|
||||||
|
[
|
||||||
|
"**Approval 42** · tool `shell` · grant `ops-shell` · taint private · expires 2026-09-24 14:47 UTC\n```\n{\"command\":\"ls -la /srv/backups\",\"timeout_ms\":10000}\n```\nReply in this thread with `!approve 42` or `!deny 42 <reason>`."
|
||||||
|
]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn fences_and_code_outrun_backticks() {
|
||||||
|
assert_eq!(fence("a"), "```\na\n```");
|
||||||
|
assert_eq!(fence("a ``` b ```` c"), "`````\na ``` b ```` c\n`````");
|
||||||
|
assert_eq!(code("ops"), "`ops`");
|
||||||
|
assert_eq!(code("a`b"), "``a`b``");
|
||||||
|
assert_eq!(code("`x"), "`` `x ``");
|
||||||
|
let shown = render(&info(r#"{"command":"echo ```` done"}"#));
|
||||||
|
assert!(
|
||||||
|
shown.posts[0].contains("\n`````\n{\"command\":\"echo ```` done\"}\n`````\n"),
|
||||||
|
"{}",
|
||||||
|
shown.posts[0]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn what_must_not_reach_the_reader_raw_is_escaped() {
|
||||||
|
let raw = "a\u{7}b\u{202e}c\u{200b}d\u{2028}e\u{feff}f\nnewline";
|
||||||
|
let shown = render(&info(&format!("{{\"command\":\"{raw}\"}}")));
|
||||||
|
let post = &shown.posts[0];
|
||||||
|
for bad in ['\u{7}', '\u{202e}', '\u{200b}', '\u{2028}', '\u{feff}'] {
|
||||||
|
assert!(!post.contains(bad), "{bad:?} reached the post");
|
||||||
|
}
|
||||||
|
let want = format!(
|
||||||
|
"a{}b{}c{}d{}e{}f{}newline",
|
||||||
|
esc(0x7),
|
||||||
|
esc(0x202e),
|
||||||
|
esc(0x200b),
|
||||||
|
esc(0x2028),
|
||||||
|
esc(0xfeff),
|
||||||
|
esc(0xa)
|
||||||
|
);
|
||||||
|
assert!(post.contains(&want), "{post}");
|
||||||
|
let mut odd = info("{}");
|
||||||
|
odd.grant = "g`rant\u{202e}".to_string();
|
||||||
|
assert!(
|
||||||
|
render(&odd).posts[0].contains(&format!("grant ``g`rant{}``", esc(0x202e))),
|
||||||
|
"{}",
|
||||||
|
render(&odd).posts[0]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn long_arguments_come_in_parts_with_a_hash() {
|
||||||
|
let args = format!("{{\"content\":\"{}\"}}", "é".repeat(PART_CHARS * 2));
|
||||||
|
let shown = render(&info(&args));
|
||||||
|
assert!(shown.answerable);
|
||||||
|
assert_eq!(shown.posts.len(), 3);
|
||||||
|
let total = args.chars().count();
|
||||||
|
assert!(shown.posts[0].starts_with("**Approval 42** · tool `shell` · grant `ops-shell` · taint private · expires 2026-09-24 14:47 UTC\nPart 1 of 3 of the arguments:\n```\n{\"content\":\""));
|
||||||
|
assert!(shown.posts[1].starts_with("**Approval 42**, part 2 of 3 of the arguments:\n```\n"));
|
||||||
|
let hash = sha256(args.as_bytes()).unwrap().to_hex();
|
||||||
|
let last = &shown.posts[2];
|
||||||
|
assert!(last.ends_with(&format!("\nApproval 42: 3 parts, {total} characters, sha256 {hash} (of the arguments as shown).\nReply in this thread with `!approve 42` or `!deny 42 <reason>`.")), "{last}");
|
||||||
|
assert!(
|
||||||
|
!shown.posts[0].contains("Reply in this thread")
|
||||||
|
&& !shown.posts[1].contains("Reply in this thread")
|
||||||
|
);
|
||||||
|
let shown_again: String = shown
|
||||||
|
.posts
|
||||||
|
.iter()
|
||||||
|
.map(|p| {
|
||||||
|
let body = p.split_once("\n```\n").unwrap().1;
|
||||||
|
body.split_once("\n```").unwrap().0.to_string()
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
assert_eq!(
|
||||||
|
shown_again, args,
|
||||||
|
"the parts, joined, are the arguments: nothing lost"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
shown
|
||||||
|
.posts
|
||||||
|
.iter()
|
||||||
|
.all(|p| p.chars().count() <= MAX_PART_POST)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn too_long_to_show_is_answerable_only_in_bxctl() {
|
||||||
|
let args = "x".repeat(PART_CHARS * MAX_PARTS + 1);
|
||||||
|
let shown = render(&info(&args));
|
||||||
|
assert!(!shown.answerable);
|
||||||
|
let hash = sha256(args.as_bytes()).unwrap().to_hex();
|
||||||
|
assert_eq!(
|
||||||
|
shown.posts,
|
||||||
|
[format!(
|
||||||
|
"**Approval 42** · tool `shell` · grant `ops-shell` · taint private · expires 2026-09-24 14:47 UTC\nThe arguments are {} characters (sha256 {hash}), too long to show here; answer it with `bxctl approvals`.",
|
||||||
|
args.len()
|
||||||
|
)]
|
||||||
|
);
|
||||||
|
let exactly = "x".repeat(PART_CHARS * MAX_PARTS);
|
||||||
|
assert!(
|
||||||
|
render(&info(&exactly)).answerable,
|
||||||
|
"four full parts still show"
|
||||||
|
);
|
||||||
|
let ticks = format!("{}{}", "`".repeat(PART_CHARS - 10), "x".repeat(20));
|
||||||
|
assert!(
|
||||||
|
!render(&info(&ticks)).answerable,
|
||||||
|
"a fence that would overflow the post is not shown"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn ending_lines() {
|
||||||
|
let kyle = "@kyle".to_string();
|
||||||
|
assert_eq!(
|
||||||
|
ended_line(&Ended::Runs { by: kyle.clone() }),
|
||||||
|
"✅ Approved by @kyle: the call runs."
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
ended_line(&Ended::Denied {
|
||||||
|
by: kyle.clone(),
|
||||||
|
reason: DenyReason::GrantExpired
|
||||||
|
}),
|
||||||
|
"Approved by @kyle, but denied on the re-check: grant_expired."
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
ended_line(&Ended::Refused {
|
||||||
|
by: kyle.clone(),
|
||||||
|
reason: None
|
||||||
|
}),
|
||||||
|
"❌ Refused by @kyle: no reason given."
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
ended_line(&Ended::Refused {
|
||||||
|
by: kyle.clone(),
|
||||||
|
reason: Some("not @channel `now`".to_string())
|
||||||
|
}),
|
||||||
|
"❌ Refused by @kyle: `` not @channel `now` ``."
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
ended_line(&Ended::NoLonger),
|
||||||
|
"No longer pending (answered elsewhere, expired, or its turn ended)."
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
ended_line(&Ended::Restarted),
|
||||||
|
"No longer answerable here: gatewayd restarted. If it is still pending it is posted again."
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
with_ending("post", &Ended::NoLonger),
|
||||||
|
"post\n\nNo longer pending (answered elsewhere, expired, or its turn ended)."
|
||||||
|
);
|
||||||
|
let longest = render(&info(&"y".repeat(PART_CHARS))).posts.remove(0);
|
||||||
|
let ended = with_ending(
|
||||||
|
&longest,
|
||||||
|
&Ended::Refused {
|
||||||
|
by: format!("@{}", "u".repeat(64)),
|
||||||
|
reason: Some("r".repeat(500)),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
ended.chars().count() <= 16_383,
|
||||||
|
"an ended post still fits: {}",
|
||||||
|
ended.chars().count()
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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/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. | ? |
|
| 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. | ? |
|
||||||
| 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/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. | ? |
|
||||||
|
|||||||
Reference in New Issue
Block a user