Skip to main content

roboticus_agent/
discovery.rs

1use std::collections::HashMap;
2
3use chrono::{DateTime, Utc};
4use serde::{Deserialize, Serialize};
5use tracing::info;
6
7use roboticus_core::{Result, RoboticusError};
8
9/// A discovered agent on the network.
10#[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/// How the agent was discovered.
23#[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
42/// Manages discovered agents and their verification state.
43pub 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    /// Register a newly discovered agent (unverified).
55    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    /// Mark a discovered agent as verified (after mutual auth).
66    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    /// Update the last-seen timestamp.
78    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    /// Remove a discovered agent.
85    pub fn remove(&mut self, agent_id: &str) -> Option<DiscoveredAgent> {
86        self.agents.remove(agent_id)
87    }
88
89    /// Get a discovered agent by ID.
90    pub fn get(&self, agent_id: &str) -> Option<&DiscoveredAgent> {
91        self.agents.get(agent_id)
92    }
93
94    /// List all verified agents.
95    pub fn verified_agents(&self) -> Vec<&DiscoveredAgent> {
96        self.agents.values().filter(|a| a.verified).collect()
97    }
98
99    /// List all agents.
100    pub fn all_agents(&self) -> Vec<&DiscoveredAgent> {
101        self.agents.values().collect()
102    }
103
104    /// Find agents by capability.
105    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    /// Remove agents not seen since the given threshold.
113    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/// DNS SRV record representation.
143#[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/// DNS TXT record for capability advertisement.
155#[derive(Debug, Clone, Serialize, Deserialize)]
156pub struct TxtRecord {
157    pub service: String,
158    pub entries: HashMap<String, String>,
159}
160
161/// Build SRV and TXT records for advertising this agent.
162pub 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        // Should not panic -- silently ignored
313        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")); // unverified
346
347        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}