1use futures::{SinkExt, StreamExt};
4use serde::{Deserialize, Serialize};
5use tokio::net::TcpListener;
6use tokio::sync::broadcast;
7use tokio_tungstenite::tungstenite::handshake::server::Request;
8
9#[derive(Clone, Debug, Default)]
11pub struct EventFilter {
12 pub task_id: Option<String>,
13 pub repo: Option<String>,
14}
15
16impl EventFilter {
17 fn from_request(request: &Request) -> Self {
19 let uri = request.uri();
20 let Some(query) = uri.query() else {
21 return Self::default();
22 };
23
24 let mut task_id = None;
25 let mut repo = None;
26
27 for pair in query.split('&') {
28 let Some((key, value)) = pair.split_once('=') else {
29 continue;
30 };
31 let decoded = urlencoding::decode(value).unwrap_or(std::borrow::Cow::Borrowed(value));
32 match key {
33 "task_id" => task_id = Some(decoded.into_owned()),
34 "repo" => repo = Some(decoded.into_owned()),
35 _ => {}
36 }
37 }
38
39 Self { task_id, repo }
40 }
41
42 fn matches(&self, event: &TaskEvent) -> bool {
44 if let Some(ref task_id) = self.task_id
45 && event.task_id != *task_id
46 {
47 return false;
48 }
49 if let Some(ref repo) = self.repo
50 && event.repo != *repo
51 {
52 return false;
53 }
54 true
55 }
56}
57
58#[derive(Clone, Debug, Serialize, Deserialize)]
64pub struct ControlMessage {
65 pub kind: String,
67 pub message: String,
69 pub missed: u64,
71}
72
73#[derive(Clone, Debug, Serialize, Deserialize)]
75pub struct TaskEvent {
76 pub task_id: String,
77 pub repo: String,
78 pub old_status: String,
79 pub new_status: String,
80 pub agent: Option<String>,
81 pub model: Option<String>,
82 pub pr_number: Option<String>,
83 pub branch: Option<String>,
84 pub review_context: Option<String>,
85 pub error: Option<String>,
86 pub timestamp: String,
87 pub title: Option<String>,
88 pub summary: Option<String>,
89 pub duration_seconds: Option<f64>,
90}
91
92#[derive(Debug)]
94pub struct EventBus {
95 tx: broadcast::Sender<TaskEvent>,
96}
97
98impl EventBus {
99 pub fn new(capacity: usize) -> Self {
100 let (tx, _) = broadcast::channel(capacity);
101 Self { tx }
102 }
103
104 pub fn sender(&self) -> broadcast::Sender<TaskEvent> {
106 self.tx.clone()
107 }
108
109 pub fn subscribe(&self) -> broadcast::Receiver<TaskEvent> {
111 self.tx.subscribe()
112 }
113
114 #[cfg(test)]
116 pub fn publish(&self, event: TaskEvent) -> usize {
117 self.tx.send(event).unwrap_or(0)
118 }
119
120 pub async fn start_ws_server(&self) -> anyhow::Result<u16> {
123 let (listener, port) = select_available_listener()?;
126 listener.set_nonblocking(true)?;
127 let listener = TcpListener::from_std(listener)?;
128
129 let port_path = ws_port_path()?;
131 if let Some(parent) = port_path.parent() {
132 tokio::fs::create_dir_all(parent).await?;
133 }
134 tokio::fs::write(&port_path, port.to_string()).await?;
135
136 if let Ok(path) = crate::home::service_version_path() {
139 if let Some(parent) = path.parent() {
140 let _ = tokio::fs::create_dir_all(parent).await;
141 }
142 let pid = std::process::id();
143 let content = format!("{}\t{}", pid, env!("ORCH_VERSION"));
144 let _ = tokio::fs::write(path, content).await;
145 }
146
147 let tx = self.tx.clone();
148 tokio::spawn(async move {
149 loop {
150 match listener.accept().await {
151 Ok((stream, _addr)) => {
152 let mut rx = tx.subscribe();
153 tokio::spawn(async move {
154 let mut filter = EventFilter::default();
155 #[expect(
156 clippy::result_large_err,
157 reason = "the tungstenite callback type fixes the error type"
158 )]
159 let callback = |req: &Request, resp| {
160 filter = EventFilter::from_request(req);
161 tracing::debug!(?filter, "ws client connected with filter");
162 Ok(resp)
163 };
164 let Ok(ws) =
165 tokio_tungstenite::accept_hdr_async(stream, callback).await
166 else {
167 return;
168 };
169 let (mut write, mut read) = ws.split();
170 loop {
171 tokio::select! {
172 event = rx.recv() => {
173 match event {
174 Ok(e) => {
175 if !filter.matches(&e) {
176 continue;
177 }
178 let json = serde_json::to_string(&e)
179 .unwrap_or_default();
180 let msg = tokio_tungstenite::tungstenite::Message::Text(json);
181 if write.send(msg).await.is_err() {
182 break;
183 }
184 }
185 Err(broadcast::error::RecvError::Lagged(n)) => {
186 tracing::warn!(
187 missed = n,
188 "event bus ws client lagged — {n} events dropped"
189 );
190 let ctrl = ControlMessage {
191 kind: "lagged".to_string(),
192 message: format!(
193 "event bus lagged: {n} events were dropped — resync from store"
194 ),
195 missed: n,
196 };
197 if let Ok(json) = serde_json::to_string(&ctrl) {
198 let msg = tokio_tungstenite::tungstenite::Message::Text(json);
199 if write.send(msg).await.is_err() {
200 break;
201 }
202 }
203 }
204 Err(_) => break,
205 }
206 }
207 msg = read.next() => {
208 if msg.is_none() {
210 break;
211 }
212 }
213 }
214 }
215 });
216 }
217 Err(e) => {
218 tracing::warn!(?e, "ws accept failed");
219 }
220 }
221 }
222 });
223
224 tracing::info!(port, "event bus websocket server started on 127.0.0.1");
225 Ok(port)
226 }
227}
228
229pub fn select_available_listener() -> anyhow::Result<(std::net::TcpListener, u16)> {
233 let hostname = std::env::var("HOSTNAME").unwrap_or_else(|_| "orch".to_string());
234 let hash = hostname
235 .bytes()
236 .fold(0u32, |acc, b| acc.wrapping_add(u32::from(b)));
237 let range = 65535u32 - 49152;
238 let start = 49152u16 + u16::try_from(hash % range).unwrap_or(0);
239
240 for offset in 0..1000u16 {
241 let port = start.wrapping_add(offset);
242 if port < 49152 {
243 continue;
244 }
245 if let Ok(listener) = std::net::TcpListener::bind(("127.0.0.1", port)) {
246 return Ok((listener, port));
247 }
248 }
249 anyhow::bail!("no available port found in range 49152-65535")
250}
251
252pub async fn cleanup_port_file() {
254 if let Ok(path) = ws_port_path() {
255 let _ = tokio::fs::remove_file(path).await;
256 }
257}
258
259pub fn cleanup_version_file() {
261 if let Ok(path) = crate::home::service_version_path() {
262 let owned = std::fs::read_to_string(&path)
263 .ok()
264 .and_then(|s| s.split('\t').next()?.parse::<u32>().ok())
265 .is_none_or(|pid| pid == std::process::id()); if owned {
267 let _ = std::fs::remove_file(path);
268 }
269 }
270}
271
272fn ws_port_path() -> anyhow::Result<std::path::PathBuf> {
273 if cfg!(test) {
274 Ok(std::env::temp_dir().join("orch-test-state").join("ws.port"))
275 } else {
276 Ok(crate::home::state_dir()?.join("ws.port"))
277 }
278}
279
280#[cfg(test)]
281mod tests {
282 use super::*;
283
284 #[test]
285 fn control_message_serializes_to_json() {
286 let ctrl = ControlMessage {
287 kind: "lagged".to_string(),
288 message: "event bus lagged: 5 events were dropped".to_string(),
289 missed: 5,
290 };
291 let json = serde_json::to_string(&ctrl).unwrap();
292 assert!(json.contains("\"kind\":\"lagged\""));
293 assert!(json.contains("\"missed\":5"));
294 }
295
296 #[test]
297 fn control_message_round_trips() {
298 let ctrl = ControlMessage {
299 kind: "lagged".to_string(),
300 message: "test".to_string(),
301 missed: 42,
302 };
303 let json = serde_json::to_string(&ctrl).unwrap();
304 let decoded: ControlMessage = serde_json::from_str(&json).unwrap();
305 assert_eq!(decoded.kind, "lagged");
306 assert_eq!(decoded.missed, 42);
307 }
308
309 #[test]
310 fn task_event_serializes_to_json() {
311 let event = TaskEvent {
312 task_id: "123".to_string(),
313 repo: "owner/repo".to_string(),
314 old_status: "new".to_string(),
315 new_status: "routed".to_string(),
316 agent: Some("claude".to_string()),
317 model: None,
318 pr_number: None,
319 branch: None,
320 review_context: None,
321 error: None,
322 timestamp: "2026-03-23T12:00:00Z".to_string(),
323 title: None,
324 summary: None,
325 duration_seconds: None,
326 };
327 let json = serde_json::to_string(&event).unwrap();
328 assert!(json.contains("\"task_id\":\"123\""));
329 assert!(json.contains("\"new_status\":\"routed\""));
330 }
331
332 #[test]
333 fn event_bus_send_receive() {
334 let bus = EventBus::new(256);
335 let mut rx = bus.subscribe();
336 let event = TaskEvent {
337 task_id: "1".to_string(),
338 repo: "r".to_string(),
339 old_status: "new".to_string(),
340 new_status: "routed".to_string(),
341 agent: None,
342 model: None,
343 pr_number: None,
344 branch: None,
345 review_context: None,
346 error: None,
347 timestamp: "2026-03-23T12:00:00Z".to_string(),
348 title: None,
349 summary: None,
350 duration_seconds: None,
351 };
352 bus.publish(event);
353 let received = rx.try_recv().unwrap();
354 assert_eq!(received.task_id, "1");
355 assert_eq!(received.new_status, "routed");
356 }
357
358 #[test]
359 fn select_port_finds_available_port() {
360 let (listener, port) = select_available_listener().unwrap();
361 assert!(port >= 49152);
362 let second_bind = std::net::TcpListener::bind(("127.0.0.1", port));
364 assert!(second_bind.is_err());
365 drop(listener);
366 }
367
368 fn make_event(task_id: &str, repo: &str) -> TaskEvent {
369 TaskEvent {
370 task_id: task_id.to_string(),
371 repo: repo.to_string(),
372 old_status: "new".to_string(),
373 new_status: "routed".to_string(),
374 agent: None,
375 model: None,
376 pr_number: None,
377 branch: None,
378 review_context: None,
379 error: None,
380 timestamp: "2026-03-23T12:00:00Z".to_string(),
381 title: None,
382 summary: None,
383 duration_seconds: None,
384 }
385 }
386
387 #[test]
388 fn event_filter_empty_matches_all() {
389 let filter = EventFilter::default();
390 assert!(filter.matches(&make_event("1", "a/b")));
391 assert!(filter.matches(&make_event("999", "x/y")));
392 }
393
394 #[test]
395 fn event_filter_task_id_filters() {
396 let filter = EventFilter {
397 task_id: Some("42".to_string()),
398 repo: None,
399 };
400 assert!(filter.matches(&make_event("42", "a/b")));
401 assert!(!filter.matches(&make_event("43", "a/b")));
402 }
403
404 #[test]
405 fn event_filter_repo_filters() {
406 let filter = EventFilter {
407 task_id: None,
408 repo: Some("owner/repo".to_string()),
409 };
410 assert!(filter.matches(&make_event("1", "owner/repo")));
411 assert!(!filter.matches(&make_event("1", "other/repo")));
412 }
413
414 #[test]
415 fn event_filter_repo_exact_match() {
416 let filter = EventFilter {
417 task_id: None,
418 repo: Some("owner".to_string()),
419 };
420 assert!(filter.matches(&make_event("1", "owner")));
421 assert!(!filter.matches(&make_event("1", "owner/repo")));
422 assert!(!filter.matches(&make_event("1", "someone/else")));
423 }
424
425 #[test]
426 fn event_filter_combined() {
427 let filter = EventFilter {
428 task_id: Some("5".to_string()),
429 repo: Some("a/b".to_string()),
430 };
431 assert!(filter.matches(&make_event("5", "a/b")));
432 assert!(!filter.matches(&make_event("6", "a/b")));
433 assert!(!filter.matches(&make_event("5", "c/d")));
434 }
435
436 #[tokio::test]
437 async fn ws_server_broadcasts_events() {
438 let bus = EventBus::new(256);
439 let port = bus.start_ws_server().await.unwrap();
440
441 let url = format!("ws://127.0.0.1:{port}");
443 let (ws, _) = tokio_tungstenite::connect_async(&url).await.unwrap();
444 let (_write, mut read) = ws.split();
445
446 tokio::time::sleep(std::time::Duration::from_millis(50)).await;
448
449 let mut event_with_agent = make_event("test-1", "owner/repo");
451 event_with_agent.agent = Some("claude".to_string());
452 bus.publish(event_with_agent);
453
454 let msg = tokio::time::timeout(
456 std::time::Duration::from_secs(2),
457 StreamExt::next(&mut read),
458 )
459 .await
460 .unwrap()
461 .unwrap()
462 .unwrap();
463
464 if let tokio_tungstenite::tungstenite::Message::Text(text) = msg {
465 let received: TaskEvent = serde_json::from_str(&text).unwrap();
466 assert_eq!(received.task_id, "test-1");
467 assert_eq!(received.new_status, "routed");
468 assert_eq!(received.agent, Some("claude".to_string()));
469 } else {
470 panic!("expected text message");
471 }
472
473 cleanup_port_file().await;
475 }
476
477 #[tokio::test]
478 async fn ws_filter_by_task_id() {
479 let bus = EventBus::new(256);
480 let port = bus.start_ws_server().await.unwrap();
481
482 let url = format!("ws://127.0.0.1:{port}/events?task_id=42");
484 let (ws, _) = tokio_tungstenite::connect_async(&url).await.unwrap();
485 let (_write, mut read) = ws.split();
486
487 tokio::time::sleep(std::time::Duration::from_millis(50)).await;
488
489 bus.publish(make_event("42", "owner/repo"));
491 bus.publish(make_event("99", "owner/repo"));
493
494 let msg = tokio::time::timeout(
495 std::time::Duration::from_secs(2),
496 StreamExt::next(&mut read),
497 )
498 .await
499 .unwrap()
500 .unwrap()
501 .unwrap();
502
503 if let tokio_tungstenite::tungstenite::Message::Text(text) = msg {
504 let received: TaskEvent = serde_json::from_str(&text).unwrap();
505 assert_eq!(received.task_id, "42");
506 } else {
507 panic!("expected text message");
508 }
509
510 let result = tokio::time::timeout(
512 std::time::Duration::from_millis(300),
513 StreamExt::next(&mut read),
514 )
515 .await;
516 assert!(result.is_err(), "expected no more messages (filtered)");
517
518 cleanup_port_file().await;
519 }
520
521 #[tokio::test]
522 async fn ws_filter_by_repo() {
523 let bus = EventBus::new(256);
524 let port = bus.start_ws_server().await.unwrap();
525
526 let url = format!(
528 "ws://127.0.0.1:{port}/events?repo={}",
529 urlencoding::encode("gabrielkoerich/orch")
530 );
531 let (ws, _) = tokio_tungstenite::connect_async(&url).await.unwrap();
532 let (_write, mut read) = ws.split();
533
534 tokio::time::sleep(std::time::Duration::from_millis(50)).await;
535
536 bus.publish(make_event("1", "gabrielkoerich/orch"));
538 bus.publish(make_event("2", "other/repo"));
540
541 let msg = tokio::time::timeout(
542 std::time::Duration::from_secs(2),
543 StreamExt::next(&mut read),
544 )
545 .await
546 .unwrap()
547 .unwrap()
548 .unwrap();
549
550 if let tokio_tungstenite::tungstenite::Message::Text(text) = msg {
551 let received: TaskEvent = serde_json::from_str(&text).unwrap();
552 assert_eq!(received.task_id, "1");
553 assert_eq!(received.repo, "gabrielkoerich/orch");
554 } else {
555 panic!("expected text message");
556 }
557
558 let result = tokio::time::timeout(
560 std::time::Duration::from_millis(300),
561 StreamExt::next(&mut read),
562 )
563 .await;
564 assert!(result.is_err(), "expected no more messages (filtered)");
565
566 cleanup_port_file().await;
567 }
568
569 #[tokio::test]
570 async fn ws_unfiltered_client_receives_all() {
571 let bus = EventBus::new(256);
572 let port = bus.start_ws_server().await.unwrap();
573
574 let url = format!("ws://127.0.0.1:{port}/events");
576 let (ws, _) = tokio_tungstenite::connect_async(&url).await.unwrap();
577 let (_write, mut read) = ws.split();
578
579 tokio::time::sleep(std::time::Duration::from_millis(50)).await;
580
581 bus.publish(make_event("1", "a/b"));
582 bus.publish(make_event("2", "c/d"));
583 bus.publish(make_event("3", "e/f"));
584
585 let mut received_ids = Vec::new();
586 for _ in 0..3 {
587 let msg = tokio::time::timeout(
588 std::time::Duration::from_secs(2),
589 StreamExt::next(&mut read),
590 )
591 .await
592 .unwrap()
593 .unwrap()
594 .unwrap();
595
596 if let tokio_tungstenite::tungstenite::Message::Text(text) = msg {
597 let event: TaskEvent = serde_json::from_str(&text).unwrap();
598 received_ids.push(event.task_id);
599 }
600 }
601
602 assert_eq!(received_ids, vec!["1", "2", "3"]);
603
604 cleanup_port_file().await;
605 }
606
607 #[tokio::test]
610 async fn ws_server_sends_lag_control_message() {
611 let bus = EventBus::new(1);
614 let Ok(port) = bus.start_ws_server().await else {
615 return;
617 };
618
619 let url = format!("ws://127.0.0.1:{port}/events");
621 let (ws, _) = tokio_tungstenite::connect_async(&url).await.unwrap();
622 let (_write, mut read) = ws.split();
623
624 tokio::time::sleep(std::time::Duration::from_millis(50)).await;
626
627 for i in 0..20 {
630 bus.publish(make_event(&i.to_string(), "owner/repo"));
631 }
632
633 let mut got_lag = false;
635 for _ in 0..30 {
636 let result = tokio::time::timeout(
637 std::time::Duration::from_secs(2),
638 StreamExt::next(&mut read),
639 )
640 .await;
641
642 let Ok(Some(Ok(msg))) = result else { break };
643
644 if let tokio_tungstenite::tungstenite::Message::Text(text) = msg
645 && let Ok(ctrl) = serde_json::from_str::<ControlMessage>(&text)
646 && ctrl.kind == "lagged"
647 {
648 got_lag = true;
649 assert!(ctrl.missed > 0, "missed count must be > 0");
650 break;
651 }
652 }
653
654 assert!(
655 got_lag,
656 "expected a lagged ControlMessage when bus capacity is exceeded"
657 );
658
659 cleanup_port_file().await;
660 }
661}