1use std::fmt;
25use std::path::Path;
26
27use borg::msg::Message;
28use borg::steer::{Admission, Dropped, Role};
29
30use crate::membership::Membership;
31
32#[derive(Debug, Clone, PartialEq, Eq)]
34pub enum Refusal {
35 Role(String),
37 Revoked,
38 Unknown,
39}
40
41impl fmt::Display for Refusal {
42 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
43 match self {
44 Refusal::Role(r) => write!(f, "role {r} may not steer"),
45 Refusal::Revoked => write!(f, "revoked node"),
46 Refusal::Unknown => write!(f, "unknown node"),
47 }
48 }
49}
50
51pub fn check(m: &Membership, node: &str) -> Result<Role, Refusal> {
56 match m.role_of(node) {
57 Some(r) => Role::of_member(r).ok_or_else(|| Refusal::Role(r.to_string())),
58 None if m.revoked.iter().any(|x| x.key == node) => Err(Refusal::Revoked),
59 None => Err(Refusal::Unknown),
60 }
61}
62
63#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
65pub struct Admitted {
66 pub new: usize,
67 pub dropped: usize,
68}
69
70pub fn admit(home: &Path, m: &Membership, me: &str, got: &[Message], now: i64) -> std::io::Result<Admitted> {
77 let mut ok = Vec::new();
78 let mut dropped = 0;
79 for msg in got.iter().filter(|x| x.kind.privileged() && x.node != me) {
80 match check(m, &msg.node) {
81 Ok(role) => ok.push(Admission::of(msg, role)),
82 Err(why) => {
83 dropped += 1;
84 let d = Dropped { ms: now, id: msg.id.clone(), node: msg.node.clone(), engineer: msg.name.clone(), kind: msg.kind, why: why.to_string() };
85 borg::steer::log_dropped(home, &d);
86 }
87 }
88 }
89 let new = if ok.is_empty() { 0 } else { borg::steer::admit(home, &ok, now)? };
90 Ok(Admitted { new, dropped })
91}
92
93pub fn enforce(home: &Path, m: &Membership, now: i64) -> usize {
97 if !borg::steer::active(home) {
98 return 0;
99 }
100 let gone = borg::steer::retain(home, now, |a| a.role == Role::Local || check(m, &a.node).is_ok()).unwrap_or(0);
101 borg::veto::apply_messages(home, now);
102 gone
103}
104
105#[cfg(test)]
106mod tests {
107 use super::*;
108 use crate::identity::Identity;
109 use borg::msg::{Kind, Sender};
110 use std::path::PathBuf;
111
112 fn tmp(tag: &str) -> PathBuf {
113 let d = std::env::temp_dir().join(format!("borg-mesh-steer-{tag}-{}", std::process::id()));
114 let _ = std::fs::remove_dir_all(&d);
115 std::fs::create_dir_all(&d).unwrap();
116 d
117 }
118
119 struct Team {
120 m: Membership,
121 admin: Identity,
122 steerer: Identity,
123 observer: Identity,
124 member: Identity,
125 home: PathBuf,
126 }
127
128 fn team(tag: &str) -> Team {
129 let home = tmp(tag);
130 let id = |n: &str| Identity::load_or_create(&home.join(n)).unwrap();
131 let (admin, steerer, observer, member) = (id("a"), id("s"), id("o"), id("m"));
132 let mut m = Membership::found("corp", &admin);
133 m.invite(&admin, &steerer.public(), "steer").unwrap();
134 m.invite(&admin, &observer.public(), "observe").unwrap();
135 m.invite(&admin, &member.public(), "member").unwrap();
136 Team { m, admin, steerer, observer, member, home: home.join("store") }
137 }
138
139 fn steer(by: &Identity, kind: Kind, to: &str, now: i64) -> Message {
140 let from = Sender { by: "engineer:x@y".into(), name: "x@y".into(), harness: "terminal".into(), node: by.public() };
141 let text = if kind == Kind::Veto { "no" } else { "" };
142 Message::new(kind, &from, to, None, now, text)
143 }
144
145 #[test]
146 fn only_admin_and_steer_nodes_are_admitted() {
147 let mut t = team("roles");
148 assert_eq!(check(&t.m, &t.admin.public()), Ok(Role::Admin));
149 assert_eq!(check(&t.m, &t.steerer.public()), Ok(Role::Steer));
150 assert_eq!(check(&t.m, &t.observer.public()), Err(Refusal::Role("observe".into())));
151 assert_eq!(check(&t.m, &t.member.public()), Err(Refusal::Role("member".into())));
152 assert_eq!(check(&t.m, &"0".repeat(64)), Err(Refusal::Unknown));
153 t.m.revoke(&t.admin, &t.steerer.public(), 900).unwrap();
154 assert_eq!(check(&t.m, &t.steerer.public()), Err(Refusal::Revoked));
155 }
156
157 #[test]
158 fn a_round_admits_steerers_logs_the_rest_and_skips_tells_and_own_lines() {
159 let t = team("admit");
160 let now = borg::learn::now_ms();
161 let me = t.member.public();
162 let tell = Message::new(
163 Kind::Tell,
164 &Sender { by: "s1".into(), name: "One".into(), harness: "claude".into(), node: t.observer.public() },
165 "all",
166 None,
167 now,
168 "hi",
169 );
170 let got = [steer(&t.steerer, Kind::Pause, "all", now), steer(&t.observer, Kind::Pause, "all", now), steer(&t.member, Kind::Pause, "all", now), tell];
171 assert_eq!(admit(&t.home, &t.m, &me, &got, now).unwrap(), Admitted { new: 1, dropped: 1 });
172 let held = borg::steer::read_admitted(&t.home);
173 assert_eq!((held.len(), held[0].role, held[0].node.as_str()), (1, Role::Steer, t.steerer.public().as_str()));
174 let log = std::fs::read_to_string(borg::steer::dropped_path(&t.home)).unwrap();
175 assert!(log.contains("role observe may not steer") && log.contains(&got[1].id), "{log}");
176 assert_eq!(admit(&t.home, &t.m, &me, &got[..1], now).unwrap(), Admitted { new: 0, dropped: 0 }, "admitted once");
177 }
178
179 #[test]
180 fn a_demoted_or_revoked_node_stops_steering_and_own_admissions_stay() {
181 let mut t = team("enforce");
182 let now = borg::learn::now_ms();
183 let me = t.member.public();
184 admit(&t.home, &t.m, &me, &[steer(&t.steerer, Kind::Pause, "all", now), steer(&t.admin, Kind::Pause, "all", now + 1)], now).unwrap();
185 let own = steer(&t.member, Kind::Pause, "all", now);
186 borg::steer::admit(&t.home, &[Admission::of(&own, Role::Local)], now).unwrap();
187 assert_eq!(enforce(&t.home, &t.m, now), 0);
188 t.m.revoke(&t.admin, &t.steerer.public(), now).unwrap();
189 assert_eq!(enforce(&t.home, &t.m, now), 1);
190 let left: Vec<Role> = borg::steer::read_admitted(&t.home).into_iter().map(|a| a.role).collect();
191 assert_eq!(left, [Role::Admin, Role::Local]);
192 let late = now + 1 + borg::steer::TTL_MS + borg::steer::SLACK_MS + 1;
193 assert_eq!(enforce(&t.home, &t.m, late), 2, "expired admissions go without a new steer");
194 assert!(!borg::steer::active(&t.home), "back to the hook's failed stat");
195 }
196
197 #[test]
198 fn a_veto_not_recorded_when_admitted_is_retried_at_the_next_enforce() {
199 let t = team("veto-retry");
200 let now = borg::learn::now_ms();
201 let me = borg::msg::node(&t.home);
202 let veto = steer(&t.steerer, Kind::Veto, &format!("s1@{me}"), now);
203 std::fs::create_dir_all(&t.home).unwrap();
204 std::fs::write(borg::msg::path(&t.home), format!("{}\n", serde_json::to_string(&veto).unwrap())).unwrap();
205 assert_eq!(admit(&t.home, &t.m, &me, std::slice::from_ref(&veto), now).unwrap().new, 1);
206 let episodes = t.home.join("episodes.jsonl");
207 assert!(!episodes.exists(), "s1 is not known here yet: nothing recorded");
208 let asked = serde_json::json!({"kind": "asked", "session": "s1", "lessons": ["L1"], "ms": now - 10, "repo": "r"});
209 std::fs::write(t.home.join("outcomes.jsonl"), format!("{asked}\n")).unwrap();
210 assert_eq!(enforce(&t.home, &t.m, now), 0);
211 let recorded = std::fs::read_to_string(&episodes).unwrap();
212 assert_eq!(recorded.lines().count(), 1, "{recorded}");
213 enforce(&t.home, &t.m, now);
214 assert_eq!(std::fs::read_to_string(&episodes).unwrap().lines().count(), 1, "once");
215 }
216}