Skip to main content

wcode_protocol/
registry.rs

1//! In-process address book + router — the A2A bus (§8 #3, §10.1).
2//!
3//! Two pieces: an **address book** (`SessionId` → `Backend`: an in-process
4//! handle or a remote `Client`) and an **ownership** map (the `report_back_to`
5//! **ownership** map (the `report_back_to` edge: worker → orchestrator). Routing
6//! a message checks the **permitted set** — an orchestrator reaches its workers,
7//! a worker its orchestrator, and nothing else (v1 star, §10.1) — then delivers
8//! through the target's handle carrying the sender, so the recipient can
9//! attribute it (`AgentEvent::MessageReceived { from }`).
10//!
11//! Addressing lives *here*, one layer above the kernel: a [`Request`] is
12//! target-side and names no peer; the router turns a `to` address into a mailbox
13//! given the sender's scope. The future **group** mode widens only the permitted
14//! set (§10.1) — nothing else here changes.
15
16use std::collections::HashMap;
17use std::sync::{Arc, Mutex};
18
19use tokio::sync::watch;
20use wcode_harness::actor::SessionHandle;
21use wcode_harness::protocol::{Request, SessionId};
22
23use crate::backend::Backend;
24#[cfg(unix)]
25use crate::client::Client;
26
27/// Why a message could not be routed (§10.1 enforcement).
28#[derive(Debug, Clone, PartialEq, Eq)]
29pub enum RouteError {
30    /// No peer is registered at that address.
31    Unknown(SessionId),
32    /// The sender may not address that peer — outside the permitted set.
33    NotPermitted { from: SessionId, to: SessionId },
34    /// The target's session has shut down (its actor ended).
35    Closed,
36}
37
38impl std::fmt::Display for RouteError {
39    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
40        match self {
41            RouteError::Unknown(id) => write!(f, "no peer at {id}"),
42            RouteError::NotPermitted { from, to } => {
43                write!(f, "{from} may not address {to}")
44            }
45            RouteError::Closed => f.write_str("the target session is closed"),
46        }
47    }
48}
49
50impl std::error::Error for RouteError {}
51
52/// An in-process address book and router over session mailboxes.
53///
54/// Cloneable and shared: every clone refers to the same registry. There is no
55/// automatic deregistration — a peer that shuts down stays listed, and a delivery
56/// to it fails with [`RouteError::Closed`] (a sweep can come later; §10.1 keeps
57/// this minimal).
58#[derive(Clone, Default)]
59pub struct Registry {
60    inner: Arc<Inner>,
61}
62
63struct Inner {
64    peers: Mutex<HashMap<SessionId, Backend>>,
65    owners: Mutex<HashMap<SessionId, SessionId>>,
66    /// Each session's effective model id (see [`Registry::set_model`]), so a
67    /// socket server can name a member's model in the roster it pushes.
68    models: Mutex<HashMap<SessionId, String>>,
69    /// The live **local** roster (see [`Registry::locals`]), refreshed on every
70    /// `register`/`register_remote` so a socket server can fan a session that
71    /// appears after it started. Read via [`Registry::subscribe`].
72    roster: watch::Sender<Vec<(SessionId, SessionHandle)>>,
73}
74
75impl Default for Inner {
76    fn default() -> Self {
77        Self {
78            peers: Mutex::new(HashMap::new()),
79            owners: Mutex::new(HashMap::new()),
80            models: Mutex::new(HashMap::new()),
81            roster: watch::channel(Vec::new()).0,
82        }
83    }
84}
85
86impl Registry {
87    pub fn new() -> Self {
88        Self::default()
89    }
90
91    /// Register a peer's mailbox at `id`; re-registering replaces it.
92    pub fn register(&self, id: SessionId, handle: SessionHandle) {
93        self.inner
94            .peers
95            .lock()
96            .unwrap()
97            .insert(id, Backend::Local(handle));
98        self.refresh_roster();
99    }
100
101    /// Register a peer reachable **across a socket** — a served session this
102    /// process can message, and which can reach back if it knows us (§8, S4-4).
103    #[cfg(unix)]
104    pub fn register_remote(&self, id: SessionId, client: Client) {
105        self.inner
106            .peers
107            .lock()
108            .unwrap()
109            .insert(id, Backend::Remote(client));
110        self.refresh_roster();
111    }
112
113    /// The **local** peers — the in-process handles this registry holds, sorted
114    /// by address for a deterministic roster. A socket server serves exactly
115    /// these ([`Self::register`]ed sessions: the root and its team); a peer
116    /// reached across a socket ([`Self::register_remote`]) is not a local handle
117    /// and is omitted, so it is never re-served.
118    pub fn locals(&self) -> Vec<(SessionId, SessionHandle)> {
119        let mut locals: Vec<(SessionId, SessionHandle)> = self
120            .inner
121            .peers
122            .lock()
123            .unwrap()
124            .iter()
125            .filter_map(|(id, backend)| match backend {
126                Backend::Local(handle) => Some((id.clone(), handle.clone())),
127                #[cfg(unix)]
128                Backend::Remote(_) => None,
129            })
130            .collect();
131        locals.sort_by(|a, b| a.0.as_str().cmp(b.0.as_str()));
132        locals
133    }
134
135    /// A live view of [`Self::locals`]: a receiver **seeded** with the current
136    /// local roster and updated whenever it changes (a session is
137    /// `register`ed). A socket server serves it, so a session that appears
138    /// *after* the server started is still reachable — see [`crate::serve`].
139    pub fn subscribe(&self) -> watch::Receiver<Vec<(SessionId, SessionHandle)>> {
140        self.inner.roster.subscribe()
141    }
142
143    /// Recompute the local roster and publish it to every [`Self::subscribe`]r.
144    /// `send_replace` stores the value even when nobody is watching yet, so a
145    /// session registered *before* the first subscriber — a `[team]` member that
146    /// starts ahead of the socket server — still seeds a later [`Self::subscribe`].
147    /// (`watch::Sender::send` would drop it: it is a no-op with no receivers.)
148    fn refresh_roster(&self) {
149        let _ = self.inner.roster.send_replace(self.locals());
150    }
151
152    /// Whether a peer is already registered at `id`. A plain existence probe —
153    /// unlike [`Self::resolve`], it needs no `from` and enforces no permitted set.
154    pub fn contains(&self, id: &SessionId) -> bool {
155        self.inner.peers.lock().unwrap().contains_key(id)
156    }
157
158    /// Record the `report_back_to` edge: `worker` reports to `owner` (its
159    /// orchestrator). This is the whole ownership tree — a star in v1.
160    pub fn set_owner(&self, worker: SessionId, owner: SessionId) {
161        self.inner.owners.lock().unwrap().insert(worker, owner);
162    }
163
164    /// Record a session's effective model id, so the roster this registry feeds
165    /// (a socket server's `Sessions` push) can name it. Mirrors [`Self::set_owner`]
166    /// — a plain metadata write, no effect on routing.
167    pub fn set_model(&self, id: SessionId, model: impl Into<String>) {
168        self.inner.models.lock().unwrap().insert(id, model.into());
169    }
170
171    /// The model recorded for `id`, if any ([`Self::set_model`]). `None` for a
172    /// peer registered without one (e.g. a remote reached across a socket).
173    pub fn model_of(&self, id: &SessionId) -> Option<String> {
174        self.inner.models.lock().unwrap().get(id).cloned()
175    }
176
177    /// Whether `from` may address `to` (§10.1): the pair must be joined by an
178    /// ownership edge in either direction — an orchestrator and one of its
179    /// workers. There are no lateral (worker → worker) edges.
180    pub fn permitted(&self, from: &SessionId, to: &SessionId) -> bool {
181        let owners = self.inner.owners.lock().unwrap();
182        owners.get(from) == Some(to) || owners.get(to) == Some(from)
183    }
184
185    /// Resolve `to` for `from`, enforcing the permitted set. Fails
186    /// [`RouteError::Unknown`] if no peer is registered, else
187    /// [`RouteError::NotPermitted`] if the sender is out of scope.
188    pub fn resolve(&self, from: &SessionId, to: &SessionId) -> Result<Backend, RouteError> {
189        let endpoint = self
190            .inner
191            .peers
192            .lock()
193            .unwrap()
194            .get(to)
195            .cloned()
196            .ok_or_else(|| RouteError::Unknown(to.clone()))?;
197        if !self.permitted(from, to) {
198            return Err(RouteError::NotPermitted {
199                from: from.clone(),
200                to: to.clone(),
201            });
202        }
203        Ok(endpoint)
204    }
205
206    /// Deliver a target-side `request` to `to`, attributed to `from`. The
207    /// recipient sees it as if `from` had sent it directly
208    /// ([`SessionHandle::send_from`]).
209    pub fn deliver(
210        &self,
211        from: &SessionId,
212        to: &SessionId,
213        request: Request,
214    ) -> Result<(), RouteError> {
215        let endpoint = self.resolve(from, to)?;
216        endpoint
217            .send_from(from.clone(), request)
218            .map_err(|_| RouteError::Closed)
219    }
220}
221
222#[cfg(test)]
223mod tests {
224    use super::*;
225    use std::time::Duration;
226
227    use wcode_harness::actor::SessionActor;
228    use wcode_harness::agent::{Agent, AgentConfig};
229    use wcode_harness::compaction::CompactionPolicy;
230    use wcode_harness::event::AgentEvent;
231    use wcode_harness::hooks::HooksSet;
232    use wcode_harness::loop_::DEFAULT_MAX_TURNS;
233    use wcode_harness::streamfn::{LlmOpts, LlmStream, StreamFn};
234
235    /// A live session whose model never streams — enough for routing tests (the
236    /// messages we send are `Notify`, which run no turn).
237    fn session() -> SessionHandle {
238        let stream_fn: StreamFn = Arc::new(|_ctx, _sys, _tools, _opts| {
239            Box::pin(futures::stream::empty()) as LlmStream
240        });
241        SessionActor::spawn(Agent::new(AgentConfig {
242            system: "sys".into(),
243            tools: Vec::new(),
244            llm: LlmOpts {
245                model: "m".into(),
246                ..LlmOpts::default()
247            },
248            stream_fn,
249            hooks: HooksSet::default(),
250            session: None,
251            context: Vec::new(),
252            working_dir: std::path::PathBuf::new(),
253            max_turns: DEFAULT_MAX_TURNS,
254            parallel_tools: true,
255            compaction: CompactionPolicy::default(),
256            plan_mode: wcode_harness::hooks::PlanModeHandle::new(),
257        }))
258    }
259
260    async fn next_message(
261        rx: &mut tokio::sync::broadcast::Receiver<AgentEvent>,
262    ) -> AgentEvent {
263        tokio::time::timeout(Duration::from_secs(2), rx.recv())
264            .await
265            .expect("an event")
266            .expect("open")
267    }
268
269    #[tokio::test]
270    async fn orchestrator_and_worker_reach_each_other() {
271        let reg = Registry::new();
272        let orch = SessionId::agent("orch");
273        let worker = SessionId::agent("w1");
274        let h_orch = session();
275        let h_worker = session();
276        reg.register(orch.clone(), h_orch.clone());
277        reg.register(worker.clone(), h_worker.clone());
278        reg.set_owner(worker.clone(), orch.clone());
279
280        // Orchestrator → worker: the worker receives it, attributed to the orch.
281        let mut rx = h_worker.subscribe();
282        reg.deliver(
283            &orch,
284            &worker,
285            Request::Notify {
286                content: "do the thing".into(),
287            },
288        )
289        .unwrap();
290        match next_message(&mut rx).await {
291            AgentEvent::MessageReceived { from, content } => {
292                assert_eq!(from, orch);
293                assert_eq!(content, "do the thing");
294            }
295            other => panic!("expected MessageReceived, got {other:?}"),
296        }
297
298        // Worker → orchestrator (report back): permitted.
299        let mut rx = h_orch.subscribe();
300        reg.deliver(
301            &worker,
302            &orch,
303            Request::Notify {
304                content: "done".into(),
305            },
306        )
307        .unwrap();
308        match next_message(&mut rx).await {
309            AgentEvent::MessageReceived { from, content } => {
310                assert_eq!(from, worker);
311                assert_eq!(content, "done");
312            }
313            other => panic!("expected MessageReceived, got {other:?}"),
314        }
315    }
316
317    #[tokio::test]
318    async fn lateral_message_between_workers_is_refused() {
319        let reg = Registry::new();
320        let orch = SessionId::agent("orch");
321        let w1 = SessionId::agent("w1");
322        let w2 = SessionId::agent("w2");
323        for id in [&orch, &w1, &w2] {
324            reg.register(id.clone(), session());
325        }
326        reg.set_owner(w1.clone(), orch.clone());
327        reg.set_owner(w2.clone(), orch.clone());
328
329        assert_eq!(
330            reg.deliver(
331                &w1,
332                &w2,
333                Request::Notify {
334                    content: "psst".into()
335                }
336            ),
337            Err(RouteError::NotPermitted {
338                from: w1.clone(),
339                to: w2.clone()
340            })
341        );
342        assert!(!reg.permitted(&w1, &w2));
343    }
344
345    #[tokio::test]
346    async fn unknown_target_is_reported() {
347        let reg = Registry::new();
348        let orch = SessionId::agent("orch");
349        reg.register(orch.clone(), session());
350
351        let ghost = SessionId::agent("ghost");
352        assert_eq!(
353            reg.deliver(&orch, &ghost, Request::Notify { content: "?".into() }),
354            Err(RouteError::Unknown(ghost))
355        );
356    }
357
358    #[tokio::test]
359    async fn locals_lists_only_local_peers_sorted_by_id() {
360        let reg = Registry::new();
361        let b = SessionId::agent("b");
362        let a = SessionId::agent("a");
363        reg.register(b.clone(), session());
364        reg.register(a.clone(), session());
365        #[cfg(unix)]
366        reg.register_remote(
367            SessionId::agent("remote"),
368            Client::lazy(std::path::Path::new("/nonexistent-t2.sock")),
369        );
370        let ids: Vec<SessionId> = reg.locals().into_iter().map(|(id, _)| id).collect();
371        assert_eq!(ids, vec![a, b]);
372    }
373
374    #[tokio::test]
375    async fn set_model_is_read_back_per_id() {
376        let reg = Registry::new();
377        let w1 = SessionId::agent("w1");
378        let ghost = SessionId::agent("ghost");
379        assert_eq!(reg.model_of(&w1), None, "unset id has no model");
380        reg.set_model(w1.clone(), "m1");
381        assert_eq!(reg.model_of(&w1).as_deref(), Some("m1"));
382        assert_eq!(reg.model_of(&ghost), None);
383    }
384
385    #[tokio::test]
386    async fn subscribe_yields_the_new_roster_after_register() {
387        let reg = Registry::new();
388        let mut rx = reg.subscribe();
389        let w1 = SessionId::agent("w1");
390        reg.register(w1.clone(), session());
391        rx.changed().await.unwrap();
392        let ids: Vec<SessionId> = rx.borrow().iter().map(|(id, _)| id.clone()).collect();
393        assert_eq!(ids, vec![w1]);
394    }
395
396    #[tokio::test]
397    async fn subscribe_seeds_a_session_registered_before_it() {
398        // A session registered with **no** subscriber in existence must still be
399        // visible to a `subscribe()` that starts later — the `[team]` startup
400        // members, registered before the socket server's first `subscribe()`.
401        // (`watch::Sender::send` drops a value with no receivers; `send_replace`
402        // keeps it, which is what `refresh_roster` relies on.)
403        let reg = Registry::new();
404        let w1 = SessionId::agent("w1");
405        reg.register(w1.clone(), session());
406        let rx = reg.subscribe();
407        let ids: Vec<SessionId> = rx.borrow().iter().map(|(id, _)| id.clone()).collect();
408        assert_eq!(ids, vec![w1]);
409    }
410
411    #[tokio::test]
412    async fn an_unowned_pair_is_not_permitted() {
413        let reg = Registry::new();
414        let a = SessionId::agent("a");
415        let b = SessionId::agent("b");
416        reg.register(a.clone(), session());
417        reg.register(b.clone(), session());
418        // No ownership edge recorded → they may not address each other.
419        assert_eq!(
420            reg.deliver(&a, &b, Request::Notify { content: "?".into() }),
421            Err(RouteError::NotPermitted {
422                from: a.clone(),
423                to: b.clone()
424            })
425        );
426    }
427}