use std::fmt;
use std::path::Path;
use borg::msg::Message;
use borg::steer::{Admission, Dropped, Role};
use crate::membership::Membership;
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Refusal {
Role(String),
Revoked,
Unknown,
}
impl fmt::Display for Refusal {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Refusal::Role(r) => write!(f, "role {r} may not steer"),
Refusal::Revoked => write!(f, "revoked node"),
Refusal::Unknown => write!(f, "unknown node"),
}
}
}
pub fn check(m: &Membership, node: &str) -> Result<Role, Refusal> {
match m.role_of(node) {
Some(r) => Role::of_member(r).ok_or_else(|| Refusal::Role(r.to_string())),
None if m.revoked.iter().any(|x| x.key == node) => Err(Refusal::Revoked),
None => Err(Refusal::Unknown),
}
}
#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
pub struct Admitted {
pub new: usize,
pub dropped: usize,
}
pub fn admit(home: &Path, m: &Membership, me: &str, got: &[Message], now: i64) -> std::io::Result<Admitted> {
let mut ok = Vec::new();
let mut dropped = 0;
for msg in got.iter().filter(|x| x.kind.privileged() && x.node != me) {
match check(m, &msg.node) {
Ok(role) => ok.push(Admission::of(msg, role)),
Err(why) => {
dropped += 1;
let d = Dropped { ms: now, id: msg.id.clone(), node: msg.node.clone(), engineer: msg.name.clone(), kind: msg.kind, why: why.to_string() };
borg::steer::log_dropped(home, &d);
}
}
}
let new = if ok.is_empty() { 0 } else { borg::steer::admit(home, &ok, now)? };
Ok(Admitted { new, dropped })
}
pub fn enforce(home: &Path, m: &Membership, now: i64) -> usize {
if !borg::steer::active(home) {
return 0;
}
let gone = borg::steer::retain(home, now, |a| a.role == Role::Local || check(m, &a.node).is_ok()).unwrap_or(0);
borg::veto::apply_messages(home, now);
gone
}
#[cfg(test)]
mod tests {
use super::*;
use crate::identity::Identity;
use borg::msg::{Kind, Sender};
use std::path::PathBuf;
fn tmp(tag: &str) -> PathBuf {
let d = std::env::temp_dir().join(format!("borg-mesh-steer-{tag}-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&d);
std::fs::create_dir_all(&d).unwrap();
d
}
struct Team {
m: Membership,
admin: Identity,
steerer: Identity,
observer: Identity,
member: Identity,
home: PathBuf,
}
fn team(tag: &str) -> Team {
let home = tmp(tag);
let id = |n: &str| Identity::load_or_create(&home.join(n)).unwrap();
let (admin, steerer, observer, member) = (id("a"), id("s"), id("o"), id("m"));
let mut m = Membership::found("corp", &admin);
m.invite(&admin, &steerer.public(), "steer").unwrap();
m.invite(&admin, &observer.public(), "observe").unwrap();
m.invite(&admin, &member.public(), "member").unwrap();
Team { m, admin, steerer, observer, member, home: home.join("store") }
}
fn steer(by: &Identity, kind: Kind, to: &str, now: i64) -> Message {
let from = Sender { by: "engineer:x@y".into(), name: "x@y".into(), harness: "terminal".into(), node: by.public() };
let text = if kind == Kind::Veto { "no" } else { "" };
Message::new(kind, &from, to, None, now, text)
}
#[test]
fn only_admin_and_steer_nodes_are_admitted() {
let mut t = team("roles");
assert_eq!(check(&t.m, &t.admin.public()), Ok(Role::Admin));
assert_eq!(check(&t.m, &t.steerer.public()), Ok(Role::Steer));
assert_eq!(check(&t.m, &t.observer.public()), Err(Refusal::Role("observe".into())));
assert_eq!(check(&t.m, &t.member.public()), Err(Refusal::Role("member".into())));
assert_eq!(check(&t.m, &"0".repeat(64)), Err(Refusal::Unknown));
t.m.revoke(&t.admin, &t.steerer.public(), 900).unwrap();
assert_eq!(check(&t.m, &t.steerer.public()), Err(Refusal::Revoked));
}
#[test]
fn a_round_admits_steerers_logs_the_rest_and_skips_tells_and_own_lines() {
let t = team("admit");
let now = borg::learn::now_ms();
let me = t.member.public();
let tell = Message::new(
Kind::Tell,
&Sender { by: "s1".into(), name: "One".into(), harness: "claude".into(), node: t.observer.public() },
"all",
None,
now,
"hi",
);
let got = [steer(&t.steerer, Kind::Pause, "all", now), steer(&t.observer, Kind::Pause, "all", now), steer(&t.member, Kind::Pause, "all", now), tell];
assert_eq!(admit(&t.home, &t.m, &me, &got, now).unwrap(), Admitted { new: 1, dropped: 1 });
let held = borg::steer::read_admitted(&t.home);
assert_eq!((held.len(), held[0].role, held[0].node.as_str()), (1, Role::Steer, t.steerer.public().as_str()));
let log = std::fs::read_to_string(borg::steer::dropped_path(&t.home)).unwrap();
assert!(log.contains("role observe may not steer") && log.contains(&got[1].id), "{log}");
assert_eq!(admit(&t.home, &t.m, &me, &got[..1], now).unwrap(), Admitted { new: 0, dropped: 0 }, "admitted once");
}
#[test]
fn a_demoted_or_revoked_node_stops_steering_and_own_admissions_stay() {
let mut t = team("enforce");
let now = borg::learn::now_ms();
let me = t.member.public();
admit(&t.home, &t.m, &me, &[steer(&t.steerer, Kind::Pause, "all", now), steer(&t.admin, Kind::Pause, "all", now + 1)], now).unwrap();
let own = steer(&t.member, Kind::Pause, "all", now);
borg::steer::admit(&t.home, &[Admission::of(&own, Role::Local)], now).unwrap();
assert_eq!(enforce(&t.home, &t.m, now), 0);
t.m.revoke(&t.admin, &t.steerer.public(), now).unwrap();
assert_eq!(enforce(&t.home, &t.m, now), 1);
let left: Vec<Role> = borg::steer::read_admitted(&t.home).into_iter().map(|a| a.role).collect();
assert_eq!(left, [Role::Admin, Role::Local]);
let late = now + 1 + borg::steer::TTL_MS + borg::steer::SLACK_MS + 1;
assert_eq!(enforce(&t.home, &t.m, late), 2, "expired admissions go without a new steer");
assert!(!borg::steer::active(&t.home), "back to the hook's failed stat");
}
#[test]
fn a_veto_not_recorded_when_admitted_is_retried_at_the_next_enforce() {
let t = team("veto-retry");
let now = borg::learn::now_ms();
let me = borg::msg::node(&t.home);
let veto = steer(&t.steerer, Kind::Veto, &format!("s1@{me}"), now);
std::fs::create_dir_all(&t.home).unwrap();
std::fs::write(borg::msg::path(&t.home), format!("{}\n", serde_json::to_string(&veto).unwrap())).unwrap();
assert_eq!(admit(&t.home, &t.m, &me, std::slice::from_ref(&veto), now).unwrap().new, 1);
let episodes = t.home.join("episodes.jsonl");
assert!(!episodes.exists(), "s1 is not known here yet: nothing recorded");
let asked = serde_json::json!({"kind": "asked", "session": "s1", "lessons": ["L1"], "ms": now - 10, "repo": "r"});
std::fs::write(t.home.join("outcomes.jsonl"), format!("{asked}\n")).unwrap();
assert_eq!(enforce(&t.home, &t.m, now), 0);
let recorded = std::fs::read_to_string(&episodes).unwrap();
assert_eq!(recorded.lines().count(), 1, "{recorded}");
enforce(&t.home, &t.m, now);
assert_eq!(std::fs::read_to_string(&episodes).unwrap().lines().count(), 1, "once");
}
}