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