agentmux_srv\backend/
eventbus.rs

1// Copyright 2025-2026, AgentMux Corp.
2// SPDX-License-Identifier: Apache-2.0
3
4//! Event bus: WebSocket event dispatching to connected clients.
5//! Port of Go's pkg/eventbus/eventbus.go.
6
7
8use std::collections::HashMap;
9use std::sync::{Arc, Mutex};
10
11use serde::{Deserialize, Serialize};
12
13use super::wps::{WaveEvent, WpsClient, EVENT_SYS_INFO, EVENT_BLOCK_STATS, EVENT_BLOCK_FILE};
14
15// ---- Event type constants ----
16
17pub const WS_EVENT_RPC: &str = "rpc";
18
19/// Egress priority lane for a server→client event.
20///
21/// `Background` is reserved for droppable perf telemetry (sysinfo + per-block
22/// stats) that must never delay interactive terminal I/O; everything else is
23/// `Priority`. The WebSocket egress loop drains the priority lane before the
24/// background lane (see `server/websocket.rs` and
25/// `docs/specs/SPEC_TERMINAL_INPUT_PRIORITY_OVER_SYSINFO_2026_06_16.md`).
26#[derive(Clone, Copy, Debug, PartialEq, Eq)]
27pub enum Lane {
28    Priority,
29    Background,
30}
31
32/// The pair of receivers handed to a WebSocket connection on registration.
33/// Terminal echo + interactive events arrive on `priority`; perf telemetry on
34/// `background`.
35pub struct WsReceivers {
36    pub priority: tokio::sync::mpsc::UnboundedReceiver<serde_json::Value>,
37    pub background: tokio::sync::mpsc::UnboundedReceiver<serde_json::Value>,
38}
39
40// ---- Types ----
41
42#[derive(Debug, Clone, Serialize, Deserialize)]
43pub struct WSEventType {
44    pub eventtype: String,
45    #[serde(skip_serializing_if = "String::is_empty", default)]
46    pub oref: String,
47    #[serde(skip_serializing_if = "Option::is_none")]
48    pub data: Option<serde_json::Value>,
49}
50
51struct WindowWatchData {
52    /// Interactive lane: terminal echo, RPC-routed wave events, obj updates.
53    priority: tokio::sync::mpsc::UnboundedSender<serde_json::Value>,
54    /// Background lane: droppable perf telemetry (sysinfo, blockstats).
55    background: tokio::sync::mpsc::UnboundedSender<serde_json::Value>,
56    #[allow(dead_code)]
57    tab_id: String,
58}
59
60impl WindowWatchData {
61    fn sender(&self, lane: Lane) -> &tokio::sync::mpsc::UnboundedSender<serde_json::Value> {
62        match lane {
63            Lane::Priority => &self.priority,
64            Lane::Background => &self.background,
65        }
66    }
67}
68
69/// Global event bus for dispatching WebSocket events to connected clients.
70pub struct EventBus {
71    watches: Mutex<HashMap<String, WindowWatchData>>,
72}
73
74impl EventBus {
75    pub fn new() -> Self {
76        Self {
77            watches: Mutex::new(HashMap::new()),
78        }
79    }
80
81    /// Register a WebSocket connection for receiving events.
82    /// Returns the priority + background receiver pair for the connection.
83    pub fn register_ws(&self, conn_id: &str, tab_id: &str) -> WsReceivers {
84        let (priority_tx, priority_rx) = tokio::sync::mpsc::unbounded_channel();
85        let (background_tx, background_rx) = tokio::sync::mpsc::unbounded_channel();
86        let mut watches = self.watches.lock().unwrap();
87        watches.insert(
88            conn_id.to_string(),
89            WindowWatchData {
90                priority: priority_tx,
91                background: background_tx,
92                tab_id: tab_id.to_string(),
93            },
94        );
95        WsReceivers {
96            priority: priority_rx,
97            background: background_rx,
98        }
99    }
100
101    /// Unregister a WebSocket connection.
102    pub fn unregister_ws(&self, conn_id: &str) {
103        let mut watches = self.watches.lock().unwrap();
104        watches.remove(conn_id);
105    }
106
107    /// Check if any connections exist for a given window/tab ID.
108    #[allow(dead_code)]
109    pub fn has_connections_for(&self, tab_id: &str) -> bool {
110        let watches = self.watches.lock().unwrap();
111        watches.values().any(|w| w.tab_id == tab_id)
112    }
113
114    /// Wait for a connection to appear for the given tab_id (with timeout).
115    #[allow(dead_code)]
116    pub async fn wait_for_connection(
117        &self,
118        tab_id: &str,
119        timeout: std::time::Duration,
120    ) -> bool {
121        let deadline = tokio::time::Instant::now() + timeout;
122        loop {
123            if self.has_connections_for(tab_id) {
124                return true;
125            }
126            if tokio::time::Instant::now() >= deadline {
127                return false;
128            }
129            tokio::time::sleep(std::time::Duration::from_millis(20)).await;
130        }
131    }
132
133    /// Send an event to a single connection by conn_id, on the priority lane.
134    /// No-op if not found.
135    pub fn send_to_conn(&self, conn_id: &str, event: &WSEventType) {
136        self.send_to_conn_lane(conn_id, event, Lane::Priority);
137    }
138
139    /// Send an event to a single connection by conn_id on a specific lane.
140    /// No-op if not found.
141    pub fn send_to_conn_lane(&self, conn_id: &str, event: &WSEventType, lane: Lane) {
142        let data = match serde_json::to_value(event) {
143            Ok(v) => v,
144            Err(e) => {
145                tracing::error!("cannot marshal event: {}", e);
146                return;
147            }
148        };
149        let watches = self.watches.lock().unwrap();
150        if let Some(watch) = watches.get(conn_id) {
151            if watch.sender(lane).send(data).is_err() {
152                tracing::warn!("failed to send event to conn {}", conn_id);
153            }
154        }
155    }
156
157    /// Broadcast an event to all connected WebSocket clients, on the priority lane.
158    pub fn broadcast_event(&self, event: &WSEventType) {
159        self.broadcast_event_lane(event, Lane::Priority);
160    }
161
162    /// Broadcast an event to all connected WebSocket clients on a specific lane.
163    pub fn broadcast_event_lane(&self, event: &WSEventType, lane: Lane) {
164        let data = match serde_json::to_value(event) {
165            Ok(v) => v,
166            Err(e) => {
167                tracing::error!("cannot marshal event: {}", e);
168                return;
169            }
170        };
171        let watches = self.watches.lock().unwrap();
172        for (conn_id, watch) in watches.iter() {
173            if watch.sender(lane).send(data.clone()).is_err() {
174                tracing::warn!("failed to send event to conn {}", conn_id);
175            }
176        }
177    }
178
179    /// Send an event to connections matching a specific tab_id.
180    #[allow(dead_code)]
181    pub fn send_to_tab(&self, tab_id: &str, event: &WSEventType) {
182        let data = match serde_json::to_value(event) {
183            Ok(v) => v,
184            Err(e) => {
185                tracing::error!("cannot marshal event: {}", e);
186                return;
187            }
188        };
189        let watches = self.watches.lock().unwrap();
190        for watch in watches.values() {
191            if watch.tab_id == tab_id {
192                let _ = watch.sender(Lane::Priority).send(data.clone());
193            }
194        }
195    }
196
197    /// Get the number of active connections.
198    pub fn connection_count(&self) -> usize {
199        self.watches.lock().unwrap().len()
200    }
201}
202
203impl Default for EventBus {
204    fn default() -> Self {
205        Self::new()
206    }
207}
208
209/// Bridge from WPS Broker to EventBus.
210/// Wraps WaveEvents as RPC eventrecv messages and broadcasts them to all WS clients.
211pub struct EventBusBridge {
212    event_bus: Arc<EventBus>,
213}
214
215impl EventBusBridge {
216    pub fn new(event_bus: Arc<EventBus>) -> Self {
217        Self { event_bus }
218    }
219}
220
221impl WpsClient for EventBusBridge {
222    fn send_event(&self, route_id: &str, event: WaveEvent) {
223        // Perf telemetry is droppable and must never delay interactive terminal
224        // I/O, so route sysinfo + per-block stats to the background lane and
225        // everything else to the priority lane. This is the only place the raw
226        // WaveEvent type is visible before it's wrapped as an opaque RPC
227        // envelope. See SPEC_TERMINAL_INPUT_PRIORITY_OVER_SYSINFO_2026_06_16.
228        let lane = match event.event.as_str() {
229            EVENT_SYS_INFO | EVENT_BLOCK_STATS => Lane::Background,
230            _ => Lane::Priority,
231        };
232        // Wrap as RPC eventrecv message (format expected by frontend)
233        let ws_event = WSEventType {
234            eventtype: WS_EVENT_RPC.to_string(),
235            oref: String::new(),
236            data: Some(serde_json::json!({
237                "command": "eventrecv",
238                "data": event
239            })),
240        };
241        // Route to the specific connection that subscribed. Broadcast is used
242        // only for legacy callers that pass "ws-main" (none remain after the
243        // per-conn-id fix), so this always takes the targeted path in practice.
244        if route_id == "ws-main" {
245            self.event_bus.broadcast_event_lane(&ws_event, lane);
246        } else {
247            self.event_bus.send_to_conn_lane(route_id, &ws_event, lane);
248        }
249    }
250}
251
252// ====================================================================
253// Tests
254// ====================================================================
255
256#[cfg(test)]
257mod tests {
258    use super::*;
259
260    #[test]
261    fn test_register_unregister() {
262        let bus = EventBus::new();
263        let _rx = bus.register_ws("conn-1", "tab-1");
264        assert_eq!(bus.connection_count(), 1);
265        assert!(bus.has_connections_for("tab-1"));
266        assert!(!bus.has_connections_for("tab-2"));
267
268        bus.unregister_ws("conn-1");
269        assert_eq!(bus.connection_count(), 0);
270        assert!(!bus.has_connections_for("tab-1"));
271    }
272
273    #[test]
274    fn test_broadcast_event() {
275        let bus = EventBus::new();
276        let mut rx1 = bus.register_ws("conn-1", "tab-1");
277        let mut rx2 = bus.register_ws("conn-2", "tab-2");
278
279        let event = WSEventType {
280            eventtype: WS_EVENT_RPC.to_string(),
281            oref: String::new(),
282            data: Some(serde_json::json!({"test": true})),
283        };
284        bus.broadcast_event(&event);
285
286        // Default broadcast lands on the priority lane.
287        assert!(rx1.priority.try_recv().is_ok());
288        assert!(rx2.priority.try_recv().is_ok());
289        assert!(rx1.background.try_recv().is_err());
290    }
291
292    #[test]
293    fn test_send_to_tab() {
294        let bus = EventBus::new();
295        let mut rx1 = bus.register_ws("conn-1", "tab-1");
296        let mut rx2 = bus.register_ws("conn-2", "tab-2");
297
298        let event = WSEventType {
299            eventtype: WS_EVENT_RPC.to_string(),
300            oref: String::new(),
301            data: None,
302        };
303        bus.send_to_tab("tab-1", &event);
304
305        assert!(rx1.priority.try_recv().is_ok());
306        assert!(rx2.priority.try_recv().is_err()); // tab-2 should not receive
307    }
308
309    #[test]
310    fn test_lane_separation() {
311        // A background-lane send must not land on the priority lane, and vice
312        // versa — this is the core of "terminal typing has complete priority
313        // over perf monitoring".
314        let bus = EventBus::new();
315        let mut rx = bus.register_ws("conn-1", "tab-1");
316
317        let event = WSEventType {
318            eventtype: WS_EVENT_RPC.to_string(),
319            oref: String::new(),
320            data: None,
321        };
322        bus.send_to_conn_lane("conn-1", &event, Lane::Background);
323        assert!(rx.priority.try_recv().is_err()); // nothing on priority
324        assert!(rx.background.try_recv().is_ok()); // telemetry on background
325
326        bus.send_to_conn_lane("conn-1", &event, Lane::Priority);
327        assert!(rx.background.try_recv().is_err()); // nothing on background
328        assert!(rx.priority.try_recv().is_ok()); // interactive on priority
329    }
330
331    #[test]
332    fn test_bridge_routes_telemetry_to_background() {
333        // EventBusBridge must demote sysinfo + blockstats to the background lane
334        // and keep everything else (e.g. terminal blockfile output) on priority.
335        let bus = Arc::new(EventBus::new());
336        let mut rx = bus.register_ws("conn-1", "tab-1");
337        let bridge = EventBusBridge::new(bus.clone());
338
339        let telemetry = |event: &str| WaveEvent {
340            event: event.to_string(),
341            scopes: vec![],
342            sender: String::new(),
343            persist: 0,
344            data: None,
345        };
346
347        bridge.send_event("conn-1", telemetry(EVENT_SYS_INFO));
348        bridge.send_event("conn-1", telemetry(EVENT_BLOCK_STATS));
349        assert!(rx.priority.try_recv().is_err()); // no telemetry on priority
350        assert!(rx.background.try_recv().is_ok()); // sysinfo
351        assert!(rx.background.try_recv().is_ok()); // blockstats
352
353        // Terminal output (blockfile) stays on the interactive priority lane.
354        bridge.send_event("conn-1", telemetry(EVENT_BLOCK_FILE));
355        assert!(rx.priority.try_recv().is_ok());
356        assert!(rx.background.try_recv().is_err());
357    }
358
359    #[test]
360    fn test_ws_event_serialization() {
361        let event = WSEventType {
362            eventtype: "test".to_string(),
363            oref: String::new(),
364            data: Some(serde_json::json!(42)),
365        };
366        let json = serde_json::to_string(&event).unwrap();
367        assert!(json.contains("\"eventtype\":\"test\""));
368        // Empty oref should be omitted
369        assert!(!json.contains("\"oref\""));
370    }
371}