1use serde::Serialize;
28
29use std::collections::{BTreeMap, HashMap, HashSet};
30use std::net::IpAddr;
31
32use crate::backend::agent_state::AgentState;
33use crate::backend::lan_discovery::LanInstance;
34use crate::backend::reactive::registry::AgentEntry;
35
36pub const STALE_AFTER_MS: u64 = 60_000;
40
41pub const HIDE_AFTER_MS: u64 = 10 * 60_000;
45
46#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
47pub struct RemoteAgent {
48 pub name: String,
49 pub block_id: String,
52 #[serde(skip_serializing_if = "Option::is_none")]
55 pub status: Option<AgentState>,
56 #[serde(skip_serializing_if = "Option::is_none")]
59 pub line: Option<String>,
60}
61
62#[derive(Debug, Clone, Default, PartialEq, Eq)]
64pub struct ChannelReport {
65 pub status: BTreeMap<String, AgentState>,
66 pub lines: BTreeMap<String, String>,
67}
68
69pub type ChannelStatuses = HashMap<String, ChannelReport>;
71
72#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
73pub struct RemoteChannel {
74 pub channel: String,
75 pub seen_at_ms: u64,
77 pub stale: bool,
79 pub os: String,
83 pub agents: Vec<RemoteAgent>,
85}
86
87#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
88pub struct RemoteHost {
89 pub host_id: String,
91 pub display_name: String,
92 pub tier: &'static str,
94 pub channels: Vec<RemoteChannel>,
96}
97
98#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
100pub struct SwarmOtherInstancesResult {
101 pub hostname: String,
104 pub channel: String,
105 pub hosts: Vec<RemoteHost>,
106}
107
108pub fn other_channels(
115 entries: &[AgentEntry],
116 statuses: &ChannelStatuses,
117 own_channel: &str,
118 own_url: &str,
119 now_ms: u64,
120) -> Vec<RemoteChannel> {
121 use std::collections::BTreeMap;
122 let mut by_channel: BTreeMap<String, (u64, BTreeMap<String, RemoteAgent>)> = BTreeMap::new();
123 for e in entries {
124 if e.channel.is_empty()
125 || e.channel == own_channel
126 || (!own_url.is_empty() && e.local_url == own_url)
127 {
128 continue;
129 }
130 if now_ms.saturating_sub(e.updated_at) > HIDE_AFTER_MS {
131 continue;
132 }
133 let slot = by_channel.entry(e.channel.clone()).or_default();
134 slot.0 = slot.0.max(e.updated_at);
135 slot.1
136 .entry(e.agent_id.to_lowercase())
137 .or_insert_with(|| {
138 let report = statuses.get(&e.channel);
139 let key = e.agent_id.to_lowercase();
140 RemoteAgent {
141 name: e.agent_id.clone(),
142 block_id: e.block_id.clone(),
143 status: report.and_then(|r| r.status.get(&key)).copied(),
144 line: report.and_then(|r| r.lines.get(&key)).cloned(),
145 }
146 });
147 }
148 by_channel
149 .into_iter()
150 .map(|(channel, (seen_at_ms, agents))| RemoteChannel {
151 channel,
152 seen_at_ms,
153 stale: now_ms.saturating_sub(seen_at_ms) > STALE_AFTER_MS,
154 os: crate::backend::host_os::local_os(),
156 agents: agents.into_values().collect(),
157 })
158 .collect()
159}
160
161fn fnv1a32(text: &str) -> u32 {
162 text.bytes().fold(0x811c_9dc5u32, |h, b| {
163 (h ^ u32::from(b)).wrapping_mul(0x0100_0193)
164 })
165}
166
167fn label_repeated_channels(by_instance: BTreeMap<String, RemoteChannel>) -> Vec<RemoteChannel> {
170 let mut count: BTreeMap<String, usize> = BTreeMap::new();
171 for c in by_instance.values() {
172 *count.entry(c.channel.clone()).or_default() += 1;
173 }
174 by_instance
175 .into_iter()
176 .map(|(key, mut c)| {
177 if count[&c.channel] > 1 {
178 if let Some((_, port)) = key.rsplit_once('@') {
179 c.channel = format!("{} :{port}", c.channel);
180 }
181 }
182 c
183 })
184 .collect()
185}
186
187pub fn lan_hosts(
193 peers: &[LanInstance],
194 own_addrs: &HashSet<IpAddr>,
195 now_ms: u64,
196) -> Vec<RemoteHost> {
197 let mut hosts: BTreeMap<String, (String, BTreeMap<String, RemoteChannel>)> = BTreeMap::new();
198 for p in peers {
199 if p.address
200 .parse::<IpAddr>()
201 .is_ok_and(|a| own_addrs.contains(&a))
202 {
203 continue;
204 }
205 let display = if p.hostname.trim().is_empty() {
208 "unnamed host".to_string()
209 } else {
210 p.hostname.trim().to_string()
211 };
212 let channel = if p.channel.is_empty() {
213 format!(":{}", p.port)
214 } else {
215 p.channel.clone()
216 };
217 let seen_at_ms = p.last_seen.max(p.last_polled_ok).saturating_mul(1000);
219 let mut agents: Vec<RemoteAgent> = p
220 .agents
221 .iter()
222 .map(|name| RemoteAgent {
223 name: name.clone(),
224 block_id: String::new(),
225 status: p.agent_status.get(&name.to_lowercase()).copied(),
226 line: p.agent_lines.get(&name.to_lowercase()).cloned(),
227 })
228 .collect();
229 agents.sort_by_key(|a| a.name.to_lowercase());
230 agents.dedup_by(|a, b| a.name.eq_ignore_ascii_case(&b.name));
231 let host = hosts
236 .entry(format!("{}@{}", display.to_lowercase(), p.address))
237 .or_insert_with(|| (display.clone(), BTreeMap::new()));
238 let instance_key = format!("{channel}@{}", p.port);
243 let keep = host
244 .1
245 .get(&instance_key)
246 .is_none_or(|c| c.seen_at_ms < seen_at_ms);
247 if keep {
248 host.1.insert(
249 instance_key,
250 RemoteChannel {
251 channel,
252 seen_at_ms,
253 stale: now_ms.saturating_sub(seen_at_ms) > STALE_AFTER_MS,
254 os: p.os.clone(),
255 agents,
256 },
257 );
258 }
259 }
260 let mut name_count: BTreeMap<String, usize> = BTreeMap::new();
264 for (display, _) in hosts.values() {
265 *name_count.entry(display.to_lowercase()).or_default() += 1;
266 }
267 let mut name_seen: BTreeMap<String, usize> = BTreeMap::new();
268 hosts
269 .into_iter()
270 .map(|(key, (display, channels))| {
271 let host_id = format!("lan:{}#{:08x}", display.to_lowercase(), fnv1a32(&key));
274 let lower = display.to_lowercase();
275 let display_name = if name_count[&lower] > 1 {
276 let n = name_seen.entry(lower).or_default();
277 *n += 1;
278 format!("{display} ({n})")
279 } else {
280 display
281 };
282 RemoteHost {
283 host_id,
284 display_name,
285 tier: "lan",
286 channels: label_repeated_channels(channels),
287 }
288 })
289 .collect()
290}
291
292const CHANNEL_STATUS_TIMEOUT: std::time::Duration = std::time::Duration::from_millis(1500);
296
297#[derive(Debug, Clone, PartialEq, Eq)]
299pub struct ChannelStatusTarget {
300 pub channel: String,
301 pub local_url: String,
302 pub auth_key: String,
303}
304
305fn is_loopback_url(url: &str) -> bool {
306 ["http://127.0.0.1:", "http://localhost:", "http://[::1]:"]
307 .iter()
308 .any(|prefix| url.starts_with(prefix))
309}
310
311pub fn channel_status_targets(
320 entries: &[AgentEntry],
321 own_channel: &str,
322 own_url: &str,
323 now_ms: u64,
324) -> Vec<ChannelStatusTarget> {
325 let mut by_url: BTreeMap<&str, &AgentEntry> = BTreeMap::new();
326 for e in entries {
327 if e.channel.is_empty()
328 || e.channel == own_channel
329 || e.local_url == own_url
330 || !is_loopback_url(&e.local_url)
331 || now_ms.saturating_sub(e.updated_at) > STALE_AFTER_MS
332 {
333 continue;
334 }
335 let slot = by_url.entry(&e.local_url).or_insert(e);
336 if e.updated_at > slot.updated_at {
337 *slot = e;
338 }
339 }
340 by_url
341 .into_values()
342 .map(|e| ChannelStatusTarget {
343 channel: e.channel.clone(),
344 local_url: e.local_url.clone(),
345 auth_key: e.auth_key.clone(),
346 })
347 .collect()
348}
349
350pub async fn channel_statuses(
354 http: &reqwest::Client,
355 targets: &[ChannelStatusTarget],
356) -> ChannelStatuses {
357 let answers = futures_util::future::join_all(targets.iter().map(|t| async move {
358 let resp = http
359 .get(format!("{}/agentmux/reactive/agent-names", t.local_url))
360 .header(agentmux_common::AUTH_KEY_HEADER, &t.auth_key)
361 .timeout(CHANNEL_STATUS_TIMEOUT)
362 .send()
363 .await
364 .ok()
365 .filter(|r| r.status().is_success())?;
366 let body = crate::backend::lan_discovery::LanDiscovery::read_body_capped(
367 resp,
368 crate::backend::lan_discovery::LAN_AGENT_NAMES_MAX_RESPONSE_BYTES,
369 )
370 .await
371 .ok()?;
372 let value: serde_json::Value = serde_json::from_slice(&body).ok()?;
373 Some((
374 t.channel.clone(),
375 crate::backend::lan_discovery::parse_agent_names_body(&value),
376 ))
377 }))
378 .await;
379 let mut out = ChannelStatuses::new();
380 for (channel, peer) in answers.into_iter().flatten() {
381 let report = out.entry(channel).or_default();
382 report.status.extend(peer.status);
383 report.lines.extend(peer.lines);
384 }
385 out
386}
387
388pub fn snapshot(
391 entries: &[AgentEntry],
392 statuses: &ChannelStatuses,
393 lan: &[LanInstance],
394 own_addrs: &HashSet<IpAddr>,
395 hostname: &str,
396 own_channel: &str,
397 own_url: &str,
398 now_ms: u64,
399) -> SwarmOtherInstancesResult {
400 let channels = other_channels(entries, statuses, own_channel, own_url, now_ms);
401 let mut hosts = Vec::new();
402 if !channels.is_empty() {
403 hosts.push(RemoteHost {
404 host_id: format!("host:{hostname}"),
405 display_name: hostname.to_string(),
406 tier: "host",
407 channels,
408 });
409 }
410 hosts.extend(lan_hosts(lan, own_addrs, now_ms));
411 SwarmOtherInstancesResult {
412 hostname: hostname.to_string(),
413 channel: own_channel.to_string(),
414 hosts,
415 }
416}
417
418#[cfg(test)]
419mod tests {
420 use super::*;
421
422 const NOW: u64 = 1_000_000_000;
423
424 fn entry(name: &str, channel: &str, age_ms: u64) -> AgentEntry {
425 AgentEntry {
426 agent_id: name.to_string(),
427 local_url: format!("http://127.0.0.1:{}", 29700 + channel.len()),
428 block_id: format!("blk-{name}"),
429 pid: 1,
430 updated_at: NOW - age_ms,
431 auth_key: "secret".to_string(),
432 channel: channel.to_string(),
433 registration_nonce: 0,
434 jekt_public_key: String::new(),
435 uid: String::new(),
436 uid_public_key: String::new(),
437 }
438 }
439
440 #[test]
441 fn groups_other_channels_and_leaves_this_one_out() {
442 let entries = [
443 entry("Korp", "dev-fix-lan", 5_000),
444 entry("Loap", "dev-fix-lan", 1_000),
445 entry("AgentX", "stable", 0),
446 ];
447 let chans = other_channels(&entries, &ChannelStatuses::new(), "stable", "", NOW);
448 assert_eq!(chans.len(), 1);
449 assert_eq!(chans[0].channel, "dev-fix-lan");
450 assert_eq!(
451 chans[0]
452 .agents
453 .iter()
454 .map(|a| a.name.as_str())
455 .collect::<Vec<_>>(),
456 ["Korp", "Loap"]
457 );
458 assert_eq!(chans[0].seen_at_ms, NOW - 1_000, "newest entry");
459 assert!(!chans[0].stale);
460 }
461
462 #[test]
463 fn a_stale_self_entry_by_url_is_left_out() {
464 let mut me = entry("AgentX", "old-name", 0);
465 me.local_url = "http://127.0.0.1:29706".to_string();
466 assert!(other_channels(
467 &[me],
468 &ChannelStatuses::new(),
469 "stable",
470 "http://127.0.0.1:29706",
471 NOW
472 )
473 .is_empty());
474 }
475
476 #[test]
477 fn freshness_marks_stale_then_hides() {
478 let stale = other_channels(
479 &[entry("Korp", "dev", STALE_AFTER_MS + 1)],
480 &ChannelStatuses::new(),
481 "stable",
482 "",
483 NOW,
484 );
485 assert!(stale[0].stale);
486 assert!(other_channels(
487 &[entry("Korp", "dev", HIDE_AFTER_MS + 1)],
488 &ChannelStatuses::new(),
489 "stable",
490 "",
491 NOW
492 )
493 .is_empty());
494 }
495
496 #[test]
497 fn an_agent_listed_twice_in_a_channel_appears_once() {
498 let chans = other_channels(
499 &[entry("Korp", "dev", 0), entry("korp", "dev", 10)],
500 &ChannelStatuses::new(),
501 "stable",
502 "",
503 NOW,
504 );
505 assert_eq!(chans[0].agents.len(), 1);
506 }
507
508 #[test]
509 fn entries_without_a_channel_are_not_another_channel() {
510 assert!(other_channels(
511 &[entry("Korp", "", 0)],
512 &ChannelStatuses::new(),
513 "stable",
514 "",
515 NOW
516 )
517 .is_empty());
518 }
519
520 #[test]
521 fn the_snapshot_names_this_machine_and_carries_no_credentials() {
522 let snap = snapshot(
523 &[entry("Korp", "dev", 0)],
524 &ChannelStatuses::new(),
525 &[],
526 &HashSet::new(),
527 "narko",
528 "stable",
529 "",
530 NOW,
531 );
532 assert_eq!(
533 (snap.hostname.as_str(), snap.channel.as_str()),
534 ("narko", "stable")
535 );
536 assert_eq!(snap.hosts.len(), 1);
537 assert_eq!(
538 (snap.hosts[0].display_name.as_str(), snap.hosts[0].tier),
539 ("narko", "host")
540 );
541 let json = serde_json::to_string(&snap).unwrap();
542 assert!(
543 !json.contains("secret") && !json.contains("local_url") && !json.contains("127.0.0.1"),
544 "{json}"
545 );
546 assert!(snapshot(
548 &[],
549 &ChannelStatuses::new(),
550 &[],
551 &HashSet::new(),
552 "narko",
553 "stable",
554 "",
555 NOW
556 )
557 .hosts
558 .is_empty());
559 }
560
561 fn peer(
562 host: &str,
563 channel: &str,
564 addr: &str,
565 port: u16,
566 agents: &[&str],
567 age_secs: u64,
568 ) -> LanInstance {
569 LanInstance {
570 instance_id: format!("{host}-{port}"),
571 hostname: host.to_string(),
572 version: "0.59.5".to_string(),
573 channel: channel.to_string(),
574 os: String::new(),
575 address: addr.to_string(),
576 port,
577 auth_key: "lan-secret".to_string(),
578 agents: agents.iter().map(|a| a.to_string()).collect(),
579 agent_status: Default::default(),
580 agent_lines: Default::default(),
581 first_seen: 0,
582 last_seen: NOW / 1000 - age_secs,
583 last_polled_ok: 0,
584 other_ttl_secs: 4500,
585 }
586 }
587
588 #[test]
589 fn lan_peers_group_into_hosts_with_a_channel_each() {
590 let peers = [
591 peer("Area54", "stable", "10.0.0.26", 29700, &["Manoz"], 5),
592 peer(
593 "starpower",
594 "stable",
595 "10.0.0.195",
596 29700,
597 &["Opaz", "korp"],
598 5,
599 ),
600 peer("starpower", "dev", "10.0.0.195", 29702, &["Loap"], 5),
601 ];
602 let hosts = lan_hosts(&peers, &HashSet::new(), NOW);
603 let shape: Vec<(&str, &str, usize)> = hosts
604 .iter()
605 .map(|h| (h.display_name.as_str(), h.tier, h.channels.len()))
606 .collect();
607 assert_eq!(shape, [("Area54", "lan", 1), ("starpower", "lan", 2)]);
608 let chans: Vec<&str> = hosts[1]
609 .channels
610 .iter()
611 .map(|c| c.channel.as_str())
612 .collect();
613 assert_eq!(chans, ["dev", "stable"]);
614 let names: Vec<&str> = hosts[1].channels[1]
615 .agents
616 .iter()
617 .map(|a| a.name.as_str())
618 .collect();
619 assert_eq!(names, ["korp", "Opaz"]);
620 }
621
622 #[test]
624 fn two_machines_with_the_same_hostname_stay_apart() {
625 let peers = [
626 peer("ubuntu", "stable", "10.0.0.40", 29700, &["A"], 5),
627 peer("ubuntu", "stable", "10.0.0.41", 29700, &["B"], 5),
628 ];
629 let hosts = lan_hosts(&peers, &HashSet::new(), NOW);
630 assert_eq!(hosts.len(), 2, "neither hides the other");
631 let names: Vec<&str> = hosts.iter().map(|h| h.display_name.as_str()).collect();
632 assert_eq!(names, ["ubuntu (1)", "ubuntu (2)"]);
633 let json = serde_json::to_string(&hosts).unwrap();
635 assert!(!json.contains("10.0.0.4"), "{json}");
636 assert_ne!(hosts[0].host_id, hosts[1].host_id);
637 let agents: Vec<&str> = hosts
638 .iter()
639 .map(|h| h.channels[0].agents[0].name.as_str())
640 .collect();
641 assert_eq!(agents, ["A", "B"]);
642 }
643
644 #[test]
645 fn a_lan_peer_at_one_of_our_own_addresses_is_left_to_the_registry() {
646 let own: HashSet<IpAddr> = ["10.0.0.230".parse().unwrap()].into_iter().collect();
647 let peers = [peer("narko", "dev", "10.0.0.230", 29702, &["Loap"], 5)];
648 assert!(lan_hosts(&peers, &own, NOW).is_empty());
649 }
650
651 #[test]
652 fn a_peer_without_a_channel_is_labelled_by_its_port() {
653 let peers = [
654 peer("old", "", "10.0.0.9", 29700, &["A"], 5),
655 peer("old", "", "10.0.0.9", 29701, &["B"], 5),
656 ];
657 let hosts = lan_hosts(&peers, &HashSet::new(), NOW);
658 let chans: Vec<&str> = hosts[0]
659 .channels
660 .iter()
661 .map(|c| c.channel.as_str())
662 .collect();
663 assert_eq!(chans, [":29700", ":29701"]);
664 }
665
666 #[test]
667 fn a_quiet_lan_peer_is_stale_and_no_lan_key_or_address_leaves() {
668 let quiet = [peer(
669 "Area54",
670 "stable",
671 "10.0.0.26",
672 29700,
673 &["Manoz"],
674 90,
675 )];
676 assert!(lan_hosts(&quiet, &HashSet::new(), NOW)[0].channels[0].stale);
677 let fresh = [peer(
678 "Area54",
679 "stable",
680 "10.0.0.26",
681 29700,
682 &["Manoz"],
683 5,
684 )];
685 let snap = snapshot(
686 &[],
687 &ChannelStatuses::new(),
688 &fresh,
689 &HashSet::new(),
690 "narko",
691 "stable",
692 "",
693 NOW,
694 );
695 let json = serde_json::to_string(&snap).unwrap();
696 assert!(
697 !json.contains("lan-secret") && !json.contains("10.0.0.26"),
698 "{json}"
699 );
700 }
701
702 #[test]
705 fn a_peer_that_answers_polls_is_not_stale() {
706 let mut p = peer("Area54", "stable", "10.0.0.26", 29700, &["Manoz"], 1800);
707 assert!(lan_hosts(std::slice::from_ref(&p), &HashSet::new(), NOW)[0].channels[0].stale);
708 p.last_polled_ok = NOW / 1000 - 10;
709 assert!(!lan_hosts(&[p], &HashSet::new(), NOW)[0].channels[0].stale);
710 }
711
712 #[test]
713 fn a_host_with_no_name_is_not_labelled_by_its_address() {
714 let hosts = lan_hosts(
715 &[peer("", "stable", "10.0.0.77", 29700, &["A"], 5)],
716 &HashSet::new(),
717 NOW,
718 );
719 assert_eq!(hosts[0].display_name, "unnamed host");
720 }
721
722 #[test]
725 fn two_instances_on_one_channel_both_stay() {
726 let peers = [
727 peer("narko2", "stable", "10.0.0.50", 29700, &["A"], 5),
728 peer("narko2", "stable", "10.0.0.50", 29702, &["B"], 5),
729 ];
730 let hosts = lan_hosts(&peers, &HashSet::new(), NOW);
731 assert_eq!(hosts.len(), 1);
732 let chans: Vec<&str> = hosts[0]
733 .channels
734 .iter()
735 .map(|c| c.channel.as_str())
736 .collect();
737 assert_eq!(chans, ["stable :29700", "stable :29702"]);
738 }
739
740 #[test]
741 fn the_same_instance_seen_twice_is_listed_once() {
742 let older = peer("Area54", "stable", "10.0.0.26", 29700, &["Old"], 50);
743 let newer = peer("Area54", "stable", "10.0.0.26", 29700, &["New"], 5);
744 let hosts = lan_hosts(&[older, newer], &HashSet::new(), NOW);
745 assert_eq!(hosts[0].channels.len(), 1);
746 assert_eq!(hosts[0].channels[0].channel, "stable");
747 assert_eq!(hosts[0].channels[0].agents[0].name, "New");
748 }
749
750 #[test]
752 fn this_machines_other_channels_carry_this_machines_platform() {
753 let chans = other_channels(
754 &[entry("Loap", "dev-fix", 0)],
755 &ChannelStatuses::new(),
756 "stable",
757 "",
758 NOW,
759 );
760 assert_eq!(chans[0].os, crate::backend::host_os::local_os());
761 }
762
763 #[test]
764 fn a_lan_peer_carries_the_platform_it_advertised_and_none_when_it_did_not() {
765 let mut mac = peer("Area54", "stable", "10.0.0.26", 29700, &["Manoz"], 5);
766 mac.os = "macos".to_string();
767 let old = peer("starpower", "stable", "10.0.0.27", 29700, &["Opaz"], 5);
768 let hosts = lan_hosts(&[mac, old], &HashSet::new(), NOW);
769 let os_of = |name: &str| {
770 hosts
771 .iter()
772 .find(|h| h.display_name == name)
773 .unwrap()
774 .channels[0]
775 .os
776 .clone()
777 };
778 assert_eq!(os_of("Area54"), "macos");
779 assert_eq!(os_of("starpower"), "");
780 }
781
782 #[test]
783 fn two_instances_on_one_host_keep_their_own_platform() {
784 let mut win = peer("narko2", "stable", "10.0.0.30", 29700, &["A"], 5);
786 win.os = "windows".to_string();
787 let mut wsl = peer("narko2", "stable", "10.0.0.30", 29702, &["B"], 5);
788 wsl.os = "linux".to_string();
789 let hosts = lan_hosts(&[win, wsl], &HashSet::new(), NOW);
790 let mut oses: Vec<String> = hosts[0].channels.iter().map(|c| c.os.clone()).collect();
791 oses.sort();
792 assert_eq!(oses, vec!["linux".to_string(), "windows".to_string()]);
793 }
794
795 #[test]
797 fn another_channels_agents_carry_the_state_that_channel_reported() {
798 let report = ChannelReport {
799 status: [("korp".to_string(), AgentState::Waiting)].into_iter().collect(),
800 lines: [("korp".to_string(), "Fixing the login race".to_string())].into_iter().collect(),
801 };
802 let statuses: ChannelStatuses = [("dev".to_string(), report)].into_iter().collect();
803 let chans = other_channels(
804 &[entry("Korp", "dev", 0), entry("Loap", "dev", 0)],
805 &statuses,
806 "stable",
807 "",
808 NOW,
809 );
810 let got: Vec<(&str, Option<AgentState>)> = chans[0]
811 .agents
812 .iter()
813 .map(|a| (a.name.as_str(), a.status))
814 .collect();
815 assert_eq!(got, [("Korp", Some(AgentState::Waiting)), ("Loap", None)]);
816 let json = serde_json::to_value(&chans[0].agents).unwrap();
818 assert_eq!(json[0]["status"], "waiting");
819 assert!(json[1].get("status").is_none(), "{json}");
820 assert_eq!(json[0]["line"], "Fixing the login race");
822 assert!(json[1].get("line").is_none(), "{json}");
823 }
824
825 #[test]
826 fn a_lan_peers_agents_carry_the_state_it_reported() {
827 let mut p = peer(
828 "Area54",
829 "stable",
830 "10.0.0.26",
831 29700,
832 &["Manoz", "Opaz"],
833 5,
834 );
835 p.agent_status.insert("manoz".into(), AgentState::Working);
836 p.agent_lines.insert("manoz".into(), "Fixing the login race".into());
837 let hosts = lan_hosts(&[p], &HashSet::new(), NOW);
838 let got: Vec<(Option<AgentState>, Option<&str>)> = hosts[0].channels[0]
839 .agents
840 .iter()
841 .map(|a| (a.status, a.line.as_deref()))
842 .collect();
843 assert_eq!(
844 got,
845 [(Some(AgentState::Working), Some("Fixing the login race")), (None, None)]
846 );
847 }
848
849 #[test]
850 fn status_targets_are_live_loopback_instances_other_than_this_one() {
851 let at = |name: &str, channel: &str, url: &str, age_ms: u64| {
852 let mut e = entry(name, channel, age_ms);
853 e.local_url = url.to_string();
854 e
855 };
856 let entries = [
857 at("Korp", "dev", "http://127.0.0.1:29702", 0),
858 at("Loap", "dev", "http://127.0.0.1:29702", 0),
859 at("Old", "gone", "http://127.0.0.1:29703", STALE_AFTER_MS + 1),
860 at("Far", "lan", "http://10.0.0.9:29700", 0),
861 at("Me", "renamed", "http://127.0.0.1:29700", 0),
862 at("Self", "stable", "http://127.0.0.1:29701", 0),
863 at("Nochan", "", "http://127.0.0.1:29704", 0),
864 at("Six", "v6", "http://[::1]:29705", 0),
865 ];
866 let targets = channel_status_targets(&entries, "stable", "http://127.0.0.1:29700", NOW);
867 let urls: Vec<(&str, &str)> = targets
868 .iter()
869 .map(|t| (t.channel.as_str(), t.local_url.as_str()))
870 .collect();
871 assert_eq!(
872 urls,
873 [
874 ("dev", "http://127.0.0.1:29702"),
875 ("v6", "http://[::1]:29705")
876 ],
877 "one per instance; not stale, not off-machine, not this instance"
878 );
879 assert_eq!(targets[0].auth_key, "secret");
880 }
881
882 #[test]
886 fn a_restarted_instance_is_asked_with_its_newest_key() {
887 let at = |name: &str, age_ms: u64, key: &str| {
888 let mut e = entry(name, "dev", age_ms);
889 e.local_url = "http://127.0.0.1:29702".to_string();
890 e.auth_key = key.to_string();
891 e
892 };
893 let entries = [
894 at("Korp", 30_000, "old-key"),
895 at("Loap", 1_000, "new-key"),
896 at("Opaz", 20_000, "old-key"),
897 ];
898 for order in [entries.to_vec(), entries.iter().rev().cloned().collect()] {
899 let targets = channel_status_targets(&order, "stable", "", NOW);
900 assert_eq!(targets.len(), 1);
901 assert_eq!(targets[0].auth_key, "new-key");
902 }
903 }
904
905 async fn serve_once(body: &'static str) -> (String, tokio::sync::oneshot::Receiver<String>) {
908 use tokio::io::{AsyncReadExt, AsyncWriteExt};
909 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
910 let url = format!("http://{}", listener.local_addr().unwrap());
911 let (tx, rx) = tokio::sync::oneshot::channel();
912 tokio::spawn(async move {
913 let Ok((mut stream, _)) = listener.accept().await else {
914 return;
915 };
916 let mut buf = [0u8; 4096];
917 let n = stream.read(&mut buf).await.unwrap_or(0);
918 let _ = tx.send(String::from_utf8_lossy(&buf[..n]).into_owned());
919 let response = format!(
920 "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}",
921 body.len()
922 );
923 let _ = stream.write_all(response.as_bytes()).await;
924 });
925 (url, rx)
926 }
927
928 #[tokio::test]
929 async fn channel_statuses_asks_each_instance_and_skips_one_that_fails() {
930 let (url, request) = serve_once(
931 r#"{"agents":["Korp"],"now_ms":1,"agent_status":{"Korp":{"state":"working","since_ms":1}},"agent_lines":{"Korp":"Building"}}"#,
932 )
933 .await;
934 let targets = [
935 ChannelStatusTarget {
936 channel: "dev".into(),
937 local_url: url,
938 auth_key: "chan-key".into(),
939 },
940 ChannelStatusTarget {
942 channel: "dead".into(),
943 local_url: "http://127.0.0.1:9".into(),
944 auth_key: "k".into(),
945 },
946 ];
947 let statuses = channel_statuses(&reqwest::Client::new(), &targets).await;
948 assert_eq!(statuses.len(), 1, "{statuses:?}");
949 assert_eq!(statuses["dev"].status.get("korp"), Some(&AgentState::Working));
950 assert_eq!(statuses["dev"].lines.get("korp").map(String::as_str), Some("Building"));
951 let request = request.await.unwrap().to_lowercase();
952 assert!(
953 request.starts_with("get /agentmux/reactive/agent-names "),
954 "{request}"
955 );
956 assert!(
957 request.contains(&format!(
958 "{}: chan-key",
959 agentmux_common::AUTH_KEY_HEADER.to_lowercase()
960 )),
961 "{request}"
962 );
963 }
964}