agentmux_srv/backend/lan_discovery/
agent_names.rs1use super::*;
10use std::collections::HashSet;
11
12#[derive(Debug, Default, Clone, PartialEq, Eq)]
15pub(crate) struct PeerAgents {
16 pub names: Vec<String>,
17 pub status: BTreeMap<String, AgentState>,
19 pub lines: BTreeMap<String, String>,
22}
23
24pub(crate) fn parse_agent_names_body(value: &serde_json::Value) -> PeerAgents {
31 let names: Vec<String> = value
32 .get("agents")
33 .and_then(|a| a.as_array())
34 .map(|a| {
35 a.iter()
36 .filter_map(|n| n.as_str())
37 .filter(|n| n.len() <= LAN_AGENT_NAMES_MAX_NAME_LEN)
38 .take(LAN_AGENT_NAMES_MAX_COUNT)
39 .map(str::to_string)
40 .collect()
41 })
42 .unwrap_or_default();
43 let listed: HashSet<String> = names.iter().map(|n| n.to_lowercase()).collect();
44 let status = value
45 .get("agent_status")
46 .and_then(|s| s.as_object())
47 .map(|map| {
48 map.iter()
49 .filter(|(name, _)| listed.contains(&name.to_lowercase()))
50 .filter_map(|(name, s)| {
51 let state =
52 serde_json::from_value::<AgentState>(s.get("state")?.clone()).ok()?;
53 Some((name.to_lowercase(), state))
54 })
55 .collect()
56 })
57 .unwrap_or_default();
58 let lines = value
59 .get("agent_lines")
60 .and_then(|s| s.as_object())
61 .map(|map| {
62 map.iter()
63 .filter(|(name, _)| listed.contains(&name.to_lowercase()))
64 .filter_map(|(name, line)| {
65 let line = crate::backend::agent_line::clean_line(line.as_str()?)?;
66 Some((name.to_lowercase(), line))
67 })
68 .collect()
69 })
70 .unwrap_or_default();
71 PeerAgents { names, status, lines }
72}
73
74pub(super) fn apply_agent_names_poll(
81 entry: &mut LanInstance,
82 peer: Option<PeerAgents>,
83 now_secs: u64,
84) -> bool {
85 let Some(peer) = peer else {
86 let changed = !entry.agent_status.is_empty() || !entry.agent_lines.is_empty();
87 entry.agent_status.clear();
88 entry.agent_lines.clear();
89 return changed;
90 };
91 entry.last_polled_ok = now_secs;
92 let changed = entry.agents != peer.names
93 || entry.agent_status != peer.status
94 || entry.agent_lines != peer.lines;
95 entry.agents = peer.names;
96 entry.agent_status = peer.status;
97 entry.agent_lines = peer.lines;
98 changed
99}
100
101#[cfg(test)]
102mod tests {
103 use super::*;
104
105 #[test]
108 fn agent_status_is_kept_for_listed_agents_with_a_known_state() {
109 let body = serde_json::json!({
110 "agents": ["Manoz", "Opaz", "Loap", "Korp"],
111 "now_ms": 5_000,
112 "agent_status": {
113 "Manoz": {"state": "working", "since_ms": 1_000},
114 "opaz": {"state": "waiting", "since_ms": 2_000},
115 "Loap": {"state": "dreaming", "since_ms": 2_000},
116 "Korp": {"since_ms": 2_000},
117 "Ghost": {"state": "idle", "since_ms": 2_000},
118 },
119 });
120 let peer = parse_agent_names_body(&body);
121 assert_eq!(peer.names, ["Manoz", "Opaz", "Loap", "Korp"]);
122 let status: Vec<(&str, AgentState)> = peer
123 .status
124 .iter()
125 .map(|(k, v): (&String, &AgentState)| (k.as_str(), *v))
126 .collect();
127 assert_eq!(
130 status,
131 [
132 ("manoz", AgentState::Working),
133 ("opaz", AgentState::Waiting)
134 ]
135 );
136 }
137
138 #[test]
139 fn agent_lines_are_kept_for_listed_agents_as_one_bounded_line() {
140 let body = serde_json::json!({
141 "agents": ["Manoz", "Opaz", "Loap"],
142 "agent_lines": {
143 "Manoz": "Fixing the\nlogin race",
144 "opaz": "x".repeat(500),
145 "Loap": 7,
146 "Ghost": "Not listed",
147 },
148 });
149 let peer = parse_agent_names_body(&body);
150 assert_eq!(peer.lines.get("manoz").map(String::as_str), Some("Fixing the login race"));
151 assert_eq!(
152 peer.lines.get("opaz").map(|l| l.chars().count()),
153 Some(crate::backend::agent_line::MAX_LINE_CHARS)
154 );
155 assert_eq!(peer.lines.len(), 2, "{:?}", peer.lines);
156 }
157
158 #[test]
159 fn a_body_from_before_agent_status_gives_names_only() {
160 let peer = parse_agent_names_body(&serde_json::json!({"agents": ["Manoz"]}));
161 assert_eq!(peer.names, ["Manoz"]);
162 assert!(peer.status.is_empty());
163 let peer = parse_agent_names_body(
164 &serde_json::json!({"agents": ["Manoz"], "agent_status": "bogus"}),
165 );
166 assert!(peer.status.is_empty());
167 }
168
169 #[test]
172 fn lan_instance_agent_status_round_trips_and_defaults_to_empty() {
173 let mut inst = instance();
174 inst.agent_status.insert("manoz".into(), AgentState::Idle);
175 let json = serde_json::to_value(&inst).unwrap();
176 assert_eq!(json["agent_status"], serde_json::json!({"manoz": "idle"}));
177 let mut old = json.clone();
178 old.as_object_mut().unwrap().remove("agent_status");
179 let back: LanInstance = serde_json::from_value(old).unwrap();
180 assert!(back.agent_status.is_empty());
181 }
182
183 #[test]
187 fn lan_instance_json_never_carries_the_peer_key() {
188 let json = serde_json::to_value(instance()).unwrap();
189 assert!(json.get("auth_key").is_none(), "{json}");
190 let back: LanInstance = serde_json::from_value(json).unwrap();
191 assert!(back.auth_key.is_empty());
192 }
193
194 #[test]
195 fn a_poll_that_answers_replaces_names_and_states() {
196 let mut inst = instance();
197 let peer = parse_agent_names_body(&serde_json::json!({
198 "agents": ["Manoz", "Opaz"],
199 "agent_status": {"Manoz": {"state": "working", "since_ms": 1}},
200 }));
201 assert!(apply_agent_names_poll(&mut inst, Some(peer.clone()), 77));
202 assert_eq!(inst.agents, ["Manoz", "Opaz"]);
203 assert_eq!(inst.agent_status.get("manoz"), Some(&AgentState::Working));
204 assert_eq!(inst.last_polled_ok, 77);
205 assert!(!apply_agent_names_poll(&mut inst, Some(peer), 78));
207 let peer = parse_agent_names_body(&serde_json::json!({
209 "agents": ["Manoz", "Opaz"],
210 "agent_status": {"Manoz": {"state": "working", "since_ms": 1}},
211 "agent_lines": {"Manoz": "Fixing the login race"},
212 }));
213 assert!(apply_agent_names_poll(&mut inst, Some(peer), 79));
214 assert_eq!(inst.agent_lines.get("manoz").map(String::as_str), Some("Fixing the login race"));
215 }
216
217 #[test]
220 fn a_failed_poll_keeps_the_names_and_clears_the_states() {
221 let mut inst = instance();
222 inst.agent_status
223 .insert("manoz".into(), AgentState::Working);
224 inst.agent_lines.insert("manoz".into(), "Fixing the login race".into());
225 inst.last_polled_ok = 5;
226 assert!(apply_agent_names_poll(&mut inst, None, 99));
227 assert_eq!(inst.agents, ["Manoz"]);
228 assert!(inst.agent_status.is_empty());
229 assert!(inst.agent_lines.is_empty());
230 assert_eq!(
231 inst.last_polled_ok, 5,
232 "a failed poll is not a successful one"
233 );
234 assert!(!apply_agent_names_poll(&mut inst, None, 100));
236 }
237
238 fn instance() -> LanInstance {
239 LanInstance {
240 instance_id: "area54-29700".into(),
241 hostname: "Area54".into(),
242 version: "0".into(),
243 channel: "stable".into(),
244 os: String::new(),
245 address: "10.0.0.26".into(),
246 port: 29700,
247 auth_key: "k".into(),
248 agents: vec!["Manoz".into()],
249 agent_status: Default::default(),
250 agent_lines: Default::default(),
251 first_seen: 0,
252 last_seen: 0,
253 last_polled_ok: 0,
254 other_ttl_secs: 4500,
255 }
256 }
257}