1use std::collections::HashMap;
2
3use chrono::{DateTime, Utc};
4use serde::{Deserialize, Serialize};
5use tracing::info;
6
7use roboticus_core::{Result, RoboticusError};
8
9#[derive(Debug, Clone, Serialize, Deserialize)]
11pub struct DiscoveredAgent {
12 pub agent_id: String,
13 pub name: String,
14 pub url: String,
15 pub capabilities: Vec<String>,
16 pub verified: bool,
17 pub discovered_at: DateTime<Utc>,
18 pub last_seen: DateTime<Utc>,
19 pub discovery_method: DiscoveryMethod,
20}
21
22#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
24pub enum DiscoveryMethod {
25 DnsSd,
26 MDns,
27 Manual,
28 A2AHandshake,
29}
30
31impl std::fmt::Display for DiscoveryMethod {
32 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
33 match self {
34 DiscoveryMethod::DnsSd => write!(f, "DNS-SD"),
35 DiscoveryMethod::MDns => write!(f, "mDNS"),
36 DiscoveryMethod::Manual => write!(f, "manual"),
37 DiscoveryMethod::A2AHandshake => write!(f, "A2A"),
38 }
39 }
40}
41
42pub struct DiscoveryRegistry {
44 agents: HashMap<String, DiscoveredAgent>,
45}
46
47impl DiscoveryRegistry {
48 pub fn new() -> Self {
49 Self {
50 agents: HashMap::new(),
51 }
52 }
53
54 pub fn register(&mut self, agent: DiscoveredAgent) {
56 info!(
57 id = %agent.agent_id,
58 url = %agent.url,
59 method = %agent.discovery_method,
60 "discovered agent"
61 );
62 self.agents.insert(agent.agent_id.clone(), agent);
63 }
64
65 pub fn verify(&mut self, agent_id: &str) -> Result<()> {
67 let agent = self
68 .agents
69 .get_mut(agent_id)
70 .ok_or_else(|| RoboticusError::Config(format!("agent '{}' not found", agent_id)))?;
71 agent.verified = true;
72 agent.last_seen = Utc::now();
73 info!(id = agent_id, "agent verified");
74 Ok(())
75 }
76
77 pub fn touch(&mut self, agent_id: &str) {
79 if let Some(agent) = self.agents.get_mut(agent_id) {
80 agent.last_seen = Utc::now();
81 }
82 }
83
84 pub fn remove(&mut self, agent_id: &str) -> Option<DiscoveredAgent> {
86 self.agents.remove(agent_id)
87 }
88
89 pub fn get(&self, agent_id: &str) -> Option<&DiscoveredAgent> {
91 self.agents.get(agent_id)
92 }
93
94 pub fn verified_agents(&self) -> Vec<&DiscoveredAgent> {
96 self.agents.values().filter(|a| a.verified).collect()
97 }
98
99 pub fn all_agents(&self) -> Vec<&DiscoveredAgent> {
101 self.agents.values().collect()
102 }
103
104 pub fn find_by_capability(&self, capability: &str) -> Vec<&DiscoveredAgent> {
106 self.agents
107 .values()
108 .filter(|a| a.verified && a.capabilities.iter().any(|c| c == capability))
109 .collect()
110 }
111
112 pub fn prune_stale(&mut self, max_age: chrono::Duration) -> usize {
114 let cutoff = Utc::now() - max_age;
115 let stale_ids: Vec<String> = self
116 .agents
117 .values()
118 .filter(|a| a.last_seen < cutoff)
119 .map(|a| a.agent_id.clone())
120 .collect();
121 let count = stale_ids.len();
122 for id in stale_ids {
123 self.agents.remove(&id);
124 }
125 if count > 0 {
126 info!(pruned = count, "pruned stale discovered agents");
127 }
128 count
129 }
130
131 pub fn count(&self) -> usize {
132 self.agents.len()
133 }
134}
135
136impl Default for DiscoveryRegistry {
137 fn default() -> Self {
138 Self::new()
139 }
140}
141
142#[derive(Debug, Clone, Serialize, Deserialize)]
144pub struct SrvRecord {
145 pub service: String,
146 pub protocol: String,
147 pub domain: String,
148 pub port: u16,
149 pub priority: u16,
150 pub weight: u16,
151 pub target: String,
152}
153
154#[derive(Debug, Clone, Serialize, Deserialize)]
156pub struct TxtRecord {
157 pub service: String,
158 pub entries: HashMap<String, String>,
159}
160
161pub fn build_advertisement(
163 agent_id: &str,
164 domain: &str,
165 port: u16,
166 capabilities: &[String],
167) -> (SrvRecord, TxtRecord) {
168 let srv = SrvRecord {
169 service: "_roboticus".to_string(),
170 protocol: "_tcp".to_string(),
171 domain: domain.to_string(),
172 port,
173 priority: 10,
174 weight: 100,
175 target: domain.to_string(),
176 };
177
178 let mut entries = HashMap::new();
179 entries.insert("agent_id".to_string(), agent_id.to_string());
180 entries.insert("caps".to_string(), capabilities.join(","));
181 entries.insert("version".to_string(), "0.1".to_string());
182
183 let txt = TxtRecord {
184 service: "_roboticus._tcp".to_string(),
185 entries,
186 };
187
188 (srv, txt)
189}
190
191#[cfg(test)]
192mod tests {
193 use super::*;
194
195 fn test_agent(id: &str) -> DiscoveredAgent {
196 DiscoveredAgent {
197 agent_id: id.to_string(),
198 name: format!("Agent {id}"),
199 url: format!("http://{id}.local:3000"),
200 capabilities: vec!["research".to_string(), "coding".to_string()],
201 verified: false,
202 discovered_at: Utc::now(),
203 last_seen: Utc::now(),
204 discovery_method: DiscoveryMethod::MDns,
205 }
206 }
207
208 #[test]
209 fn register_and_get() {
210 let mut reg = DiscoveryRegistry::new();
211 reg.register(test_agent("agent-1"));
212 assert_eq!(reg.count(), 1);
213 assert!(reg.get("agent-1").is_some());
214 }
215
216 #[test]
217 fn verify_agent() {
218 let mut reg = DiscoveryRegistry::new();
219 reg.register(test_agent("agent-1"));
220 assert!(reg.verified_agents().is_empty());
221
222 reg.verify("agent-1").unwrap();
223 assert_eq!(reg.verified_agents().len(), 1);
224 }
225
226 #[test]
227 fn verify_nonexistent() {
228 let mut reg = DiscoveryRegistry::new();
229 assert!(reg.verify("nope").is_err());
230 }
231
232 #[test]
233 fn remove_agent() {
234 let mut reg = DiscoveryRegistry::new();
235 reg.register(test_agent("agent-1"));
236 let removed = reg.remove("agent-1");
237 assert!(removed.is_some());
238 assert_eq!(reg.count(), 0);
239 }
240
241 #[test]
242 fn find_by_capability() {
243 let mut reg = DiscoveryRegistry::new();
244 let mut a1 = test_agent("a1");
245 a1.verified = true;
246 reg.register(a1);
247
248 let mut a2 = test_agent("a2");
249 a2.capabilities = vec!["finance".to_string()];
250 a2.verified = true;
251 reg.register(a2);
252
253 assert_eq!(reg.find_by_capability("research").len(), 1);
254 assert_eq!(reg.find_by_capability("finance").len(), 1);
255 assert_eq!(reg.find_by_capability("unknown").len(), 0);
256 }
257
258 #[test]
259 fn unverified_excluded_from_capability_search() {
260 let mut reg = DiscoveryRegistry::new();
261 reg.register(test_agent("unverified"));
262 assert_eq!(reg.find_by_capability("research").len(), 0);
263 }
264
265 #[test]
266 fn prune_stale() {
267 let mut reg = DiscoveryRegistry::new();
268 let mut old = test_agent("old");
269 old.last_seen = Utc::now() - chrono::Duration::hours(48);
270 reg.register(old);
271 reg.register(test_agent("fresh"));
272
273 let pruned = reg.prune_stale(chrono::Duration::hours(24));
274 assert_eq!(pruned, 1);
275 assert_eq!(reg.count(), 1);
276 }
277
278 #[test]
279 fn build_advertisement_records() {
280 let caps = vec!["research".to_string(), "coding".to_string()];
281 let (srv, txt) = build_advertisement("agent-1", "myhost.local", 3000, &caps);
282 assert_eq!(srv.port, 3000);
283 assert_eq!(txt.entries["agent_id"], "agent-1");
284 assert!(txt.entries["caps"].contains("research"));
285 }
286
287 #[test]
288 fn discovery_method_display() {
289 assert_eq!(format!("{}", DiscoveryMethod::DnsSd), "DNS-SD");
290 assert_eq!(format!("{}", DiscoveryMethod::MDns), "mDNS");
291 assert_eq!(format!("{}", DiscoveryMethod::Manual), "manual");
292 assert_eq!(format!("{}", DiscoveryMethod::A2AHandshake), "A2A");
293 }
294
295 #[test]
296 fn discovery_method_serde() {
297 for method in [
298 DiscoveryMethod::DnsSd,
299 DiscoveryMethod::MDns,
300 DiscoveryMethod::Manual,
301 DiscoveryMethod::A2AHandshake,
302 ] {
303 let json = serde_json::to_string(&method).unwrap();
304 let back: DiscoveryMethod = serde_json::from_str(&json).unwrap();
305 assert_eq!(method, back);
306 }
307 }
308
309 #[test]
310 fn touch_nonexistent_agent_is_noop() {
311 let mut reg = DiscoveryRegistry::new();
312 reg.touch("nonexistent-agent");
314 assert_eq!(reg.count(), 0);
315 }
316
317 #[test]
318 fn touch_updates_last_seen() {
319 let mut reg = DiscoveryRegistry::new();
320 let mut agent = test_agent("a1");
321 agent.last_seen = Utc::now() - chrono::Duration::hours(10);
322 let old_last_seen = agent.last_seen;
323 reg.register(agent);
324
325 reg.touch("a1");
326 let updated = reg.get("a1").unwrap();
327 assert!(
328 updated.last_seen > old_last_seen,
329 "touch should update last_seen to a more recent time"
330 );
331 }
332
333 #[test]
334 fn remove_nonexistent_returns_none() {
335 let mut reg = DiscoveryRegistry::new();
336 assert!(reg.remove("ghost").is_none());
337 }
338
339 #[test]
340 fn all_agents_includes_verified_and_unverified() {
341 let mut reg = DiscoveryRegistry::new();
342 let mut a1 = test_agent("a1");
343 a1.verified = true;
344 reg.register(a1);
345 reg.register(test_agent("a2")); let all = reg.all_agents();
348 assert_eq!(all.len(), 2);
349 let verified = reg.verified_agents();
350 assert_eq!(verified.len(), 1);
351 }
352
353 #[test]
354 fn prune_stale_no_stale_agents() {
355 let mut reg = DiscoveryRegistry::new();
356 reg.register(test_agent("fresh"));
357 let pruned = reg.prune_stale(chrono::Duration::hours(24));
358 assert_eq!(pruned, 0);
359 assert_eq!(reg.count(), 1);
360 }
361
362 #[test]
363 fn default_impl() {
364 let reg = DiscoveryRegistry::default();
365 assert_eq!(reg.count(), 0);
366 }
367
368 #[test]
369 fn register_overwrites_existing() {
370 let mut reg = DiscoveryRegistry::new();
371 let a1 = test_agent("dup");
372 let mut a2 = test_agent("dup");
373 a2.name = "Updated Agent dup".to_string();
374 reg.register(a1);
375 reg.register(a2);
376 assert_eq!(reg.count(), 1);
377 assert_eq!(reg.get("dup").unwrap().name, "Updated Agent dup");
378 }
379
380 #[test]
381 fn build_advertisement_with_empty_capabilities() {
382 let (srv, txt) = build_advertisement("agent-x", "host.local", 8080, &[]);
383 assert_eq!(srv.port, 8080);
384 assert_eq!(txt.entries["caps"], "");
385 }
386
387 #[test]
388 fn discovered_agent_serde_roundtrip() {
389 let agent = test_agent("serde-test");
390 let json = serde_json::to_string(&agent).unwrap();
391 let back: DiscoveredAgent = serde_json::from_str(&json).unwrap();
392 assert_eq!(back.agent_id, "serde-test");
393 assert_eq!(back.capabilities, vec!["research", "coding"]);
394 }
395}