Skip to main content

agentmux_srv/backend/lan_discovery/
agent_names.rs

1// Copyright 2026, AgentMux Corp.
2// SPDX-License-Identifier: Apache-2.0
3
4//! Reading an instance's `/agentmux/reactive/agent-names` answer: the names a
5//! LAN peer's poll (and the Swarm's ask of this machine's other channels) list,
6//! and each listed agent's state and summary line, for the Swarm's remote rows
7//! (SPEC_SWARM_OTHER_HOSTS_AND_CHANNELS_2026_10_02.md §3.4 Phase 4).
8
9use super::*;
10use std::collections::HashSet;
11
12/// What an instance's `/agentmux/reactive/agent-names` answer says about its
13/// agents.
14#[derive(Debug, Default, Clone, PartialEq, Eq)]
15pub(crate) struct PeerAgents {
16    pub names: Vec<String>,
17    /// Lower-cased name → state, for listed names only.
18    pub status: BTreeMap<String, AgentState>,
19    /// Lower-cased name → summary line (`backend::agent_line`), for listed
20    /// names only, each cleaned again here.
21    pub lines: BTreeMap<String, String>,
22}
23
24/// Reads an `agent-names` body: the names, capped as above, and each listed
25/// agent's state from `agent_status` (`{name: {state, since_ms}}`). A state for
26/// a name the body doesn't list, or one this build doesn't know, is dropped,
27/// so the map is never larger than the name list. The same goes for
28/// `agent_lines` (`{name: line}`). A body from a build before `agent_status`
29/// gives names only.
30pub(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
74/// Applies one poll of a peer's `agent-names` route to its entry, and says
75/// whether what the frontend shows changed. A failed poll keeps the last-known
76/// names, since a flapping list is worse than a slightly old one, but clears
77/// the states and lines: mDNS can keep the peer looking fresh long after it
78/// stopped answering, and its last "working" would otherwise show indefinitely
79/// (#4703).
80pub(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    /// The Swarm's status chip on remote rows
106    /// (SPEC_SWARM_OTHER_HOSTS_AND_CHANNELS_2026_10_02.md §3.4 Phase 4).
107    #[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        // Keyed by lower-cased name; an unknown state, a missing state and a
128        // name the body doesn't list are all dropped.
129        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    /// The `laninstances` event carries the state to the frontend, and a record
170    /// from before the field reads as none.
171    #[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    /// The same JSON goes to the frontend, `/api/lan-instances` and
184    /// `/agentmux/discovery` (which container agents may call); a peer's
185    /// `lan_key` is in none of them.
186    #[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        // The same answer again changes nothing the frontend shows.
206        assert!(!apply_agent_names_poll(&mut inst, Some(peer), 78));
207        // A new line alone is a change too.
208        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    /// A peer still advertising over mDNS but no longer answering polls must
218    /// not keep showing its last state (#4703).
219    #[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        // Failing again, with nothing left to clear, changes nothing.
235        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}