gatewayd: approvals, the registry and the judging of commands

Implemented-By: OpenCode session (model recorded in docs/implementer-log.md)
This commit is contained in:
2026-09-24 04:48:06 -07:00
parent d9f8fa9331
commit c5df86d13d
3 changed files with 300 additions and 0 deletions
+133
View File
@@ -2,3 +2,136 @@
//! approvals this `gatewayd` has posted and the commands that answer them.
pub mod text;
use std::collections::HashMap;
use std::sync::mpsc::Sender;
use std::sync::{Mutex, MutexGuard};
use proto::ApprovalReply;
use crate::sessions::ApprovalCommand::{self, Approve, Deny, Malformed};
pub const NOT_APPROVER: &str = "only an approver can answer approvals";
pub const USAGE: &str = "use !approve <id> or !deny <id> <reason>";
/// One approval posted here and not yet answered.
#[derive(Debug)]
pub struct Posted {
pub approval: u64,
/// The session whose turn waits for it.
pub session: String,
/// The thread it is answered in.
pub channel: String,
pub root: String,
/// The last post's `create_at`: only a command after it counts.
pub shown_at: i64,
/// False when the arguments were too long to show: only `bxctl` answers it.
pub answerable: bool,
/// Where an accepted answer goes: the thread of its `gateway.sock` connection.
pub answer: Sender<ApprovalReply>,
}
/// What a command came to.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Judged {
/// The answer went to `brokerd`'s connection; the approval post will show the outcome.
Sent,
/// Refused: this line goes in the thread.
Reply(String),
}
/// The approvals posted here, by id.
#[derive(Debug, Default)]
pub struct Registry {
posted: Mutex<HashMap<u64, Posted>>,
}
impl Registry {
pub fn new() -> Registry {
Registry::default()
}
fn lock(&self) -> MutexGuard<'_, HashMap<u64, Posted>> {
self.posted.lock().unwrap_or_else(|p| p.into_inner())
}
pub fn add(&self, posted: Posted) {
self.lock().insert(posted.approval, posted);
}
/// Take it out: it ended, or its connection is done with it.
pub fn remove(&self, id: u64) -> Option<Posted> {
self.lock().remove(&id)
}
pub fn holds(&self, id: u64) -> bool {
self.lock().contains_key(&id)
}
/// The sessions with an approval waiting here: their turns are waiting, not working.
pub fn waiting_sessions(&self) -> Vec<String> {
self.lock().values().map(|p| p.session.clone()).collect()
}
/// Judge a command from `user`, posted as `post` at `at` in the thread `root`, in the order of
/// the spec's section 7. An accepted answer is taken out and sent; nothing else changes.
pub fn judge(
&self,
command: &ApprovalCommand,
user: &str,
root: &str,
post: &str,
at: i64,
approvers: &[String],
) -> Judged {
// In this order; each refusal is `Judged::Reply(<text>)` and changes nothing:
// 1. `user` not in `approvers`: NOT_APPROVER.
// 2. The command: Approve(id) is (id, approved true, reason None); Deny(id, reason) is (id,
// false, reason.clone()); Malformed: USAGE.
// 3. `let mut posted = self.lock();` Not in `posted`: "approval {id} is not pending here".
// 4. `!entry.answerable`: "approval {id} can only be answered with bxctl approvals".
// 5. `entry.root != root`: "approval {id} is answered in its own thread".
// 6. `at <= entry.shown_at`: "approval {id} was answered before it was shown in full".
// 7. Take it out (`posted.remove(&id)`) and send `ApprovalReply { approval: id, approved,
// by: user, post, reason }` on its `answer`: Ok -> `Judged::Sent`; Err (the connection
// is gone) -> "approval {id} is not pending here".
if !approvers.iter().any(|a| a.as_str() == user) {
return Judged::Reply(NOT_APPROVER.to_string());
}
let (id, approved, reason) = match command {
Approve(id) => (*id, true, None),
Deny(id, reason) => (*id, false, reason.clone()),
Malformed => return Judged::Reply(USAGE.to_string()),
};
let mut posted = self.lock();
let entry = match posted.get(&id) {
Some(entry) => entry,
None => return Judged::Reply(format!("approval {id} is not pending here")),
};
let answer = entry.answer.clone();
if !entry.answerable {
return Judged::Reply(format!(
"approval {id} can only be answered with bxctl approvals"
));
}
if entry.root != root {
return Judged::Reply(format!("approval {id} is answered in its own thread"));
}
if at <= entry.shown_at {
return Judged::Reply(format!(
"approval {id} was answered before it was shown in full"
));
}
posted.remove(&id);
match answer.send(ApprovalReply {
approval: id,
approved,
by: user.to_string(),
post: post.to_string(),
reason,
}) {
Ok(()) => Judged::Sent,
Err(_) => Judged::Reply(format!("approval {id} is not pending here")),
}
}
}