1use 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#[derive(Debug, Clone, PartialEq, Eq)]
29pub enum RouteError {
30 Unknown(SessionId),
32 NotPermitted { from: SessionId, to: SessionId },
34 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#[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 models: Mutex<HashMap<SessionId, String>>,
69 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 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 #[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 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 pub fn subscribe(&self) -> watch::Receiver<Vec<(SessionId, SessionHandle)>> {
140 self.inner.roster.subscribe()
141 }
142
143 fn refresh_roster(&self) {
149 let _ = self.inner.roster.send_replace(self.locals());
150 }
151
152 pub fn contains(&self, id: &SessionId) -> bool {
155 self.inner.peers.lock().unwrap().contains_key(id)
156 }
157
158 pub fn set_owner(&self, worker: SessionId, owner: SessionId) {
161 self.inner.owners.lock().unwrap().insert(worker, owner);
162 }
163
164 pub fn set_model(&self, id: SessionId, model: impl Into<String>) {
168 self.inner.models.lock().unwrap().insert(id, model.into());
169 }
170
171 pub fn model_of(&self, id: &SessionId) -> Option<String> {
174 self.inner.models.lock().unwrap().get(id).cloned()
175 }
176
177 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 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 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 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 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 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 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 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}