Skip to main content

orch/engine/
events.rs

1//! Task event bus — broadcast channel for status transitions.
2
3use futures::{SinkExt, StreamExt};
4use serde::{Deserialize, Serialize};
5use tokio::net::TcpListener;
6use tokio::sync::broadcast;
7use tokio_tungstenite::tungstenite::handshake::server::Request;
8
9/// Parsed query-parameter filters for a websocket connection.
10#[derive(Clone, Debug, Default)]
11pub struct EventFilter {
12    pub task_id: Option<String>,
13    pub repo: Option<String>,
14}
15
16impl EventFilter {
17    /// Parse `?task_id=123&repo=owner/repo` from a request URI.
18    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    /// Returns `true` if the event matches this filter (or no filter is set).
43    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/// A control message sent by the server to the websocket client for out-of-band
59/// notifications such as lag warnings.
60///
61/// The client should treat these as advisory: print a warning to the operator
62/// and, when `kind == "lagged"`, resync task state from SQLite.
63#[derive(Clone, Debug, Serialize, Deserialize)]
64pub struct ControlMessage {
65    /// Discriminator — currently only `"lagged"`.
66    pub kind: String,
67    /// Human-readable description.
68    pub message: String,
69    /// Number of events skipped (for `"lagged"`).
70    pub missed: u64,
71}
72
73/// A task status transition event.
74#[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/// The event bus — wraps a tokio broadcast channel.
93#[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    /// Get a sender clone (for TaskManager, subscribers, etc.).
105    pub fn sender(&self) -> broadcast::Sender<TaskEvent> {
106        self.tx.clone()
107    }
108
109    /// Subscribe to events.
110    pub fn subscribe(&self) -> broadcast::Receiver<TaskEvent> {
111        self.tx.subscribe()
112    }
113
114    /// Publish an event. Returns number of receivers that got it.
115    #[cfg(test)]
116    pub fn publish(&self, event: TaskEvent) -> usize {
117        self.tx.send(event).unwrap_or(0)
118    }
119
120    /// Start the websocket server. Returns the bound port.
121    /// Spawns a background task — does not block.
122    pub async fn start_ws_server(&self) -> anyhow::Result<u16> {
123        // Select a deterministic port in the ephemeral range (49152-65535),
124        // starting from a hostname-based offset to be stable across restarts.
125        let (listener, port) = select_available_listener()?;
126        listener.set_nonblocking(true)?;
127        let listener = TcpListener::from_std(listener)?;
128
129        // Write port file without blocking the runtime thread.
130        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        // Write service version file so service status can detect CLI/service drift.
137        // Format: "{pid}\t{version}" so readers can verify the owning process is alive.
138        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                                        // Client disconnected or sent close
209                                        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
229/// Find an available port in the ephemeral range (49152-65535).
230/// Starts from a deterministic offset based on hostname hash, then increments.
231/// This gives stable first-attempt ports across restarts on the same machine.
232pub 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
252/// Remove the ws.port file on shutdown.
253pub async fn cleanup_port_file() {
254    if let Ok(path) = ws_port_path() {
255        let _ = tokio::fs::remove_file(path).await;
256    }
257}
258
259/// Remove the service.version file on shutdown — only if this process owns it.
260pub 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()); // legacy plain-version files: assume ownership
266        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        // Verify the port is reserved while the listener is alive.
363        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        // Connect a websocket client
442        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        // Give the server a moment to register the client
447        tokio::time::sleep(std::time::Duration::from_millis(50)).await;
448
449        // Publish an event
450        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        // Read from websocket
455        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
474        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        // Connect filtered client — only task "42"
483        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        // Publish event for task 42 (should arrive)
490        bus.publish(make_event("42", "owner/repo"));
491        // Publish event for task 99 (should be filtered out)
492        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        // No more messages should arrive — timeout confirms the filter works
511        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        // Connect filtered client — only repo containing "gabrielkoerich/orch"
527        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        // Matching event
537        bus.publish(make_event("1", "gabrielkoerich/orch"));
538        // Non-matching event
539        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        // No more messages — non-matching event was filtered server-side
559        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        // Unfiltered client
575        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    /// When the broadcast receiver lags (missed events), the server must send a
608    /// ControlMessage{kind:"lagged"} to the ws client instead of silently skipping.
609    #[tokio::test]
610    async fn ws_server_sends_lag_control_message() {
611        // Tiny capacity (1) so we can force lag by publishing faster than the
612        // client consumes.
613        let bus = EventBus::new(1);
614        let Ok(port) = bus.start_ws_server().await else {
615            // Port unavailable (race with parallel tests) — skip gracefully.
616            return;
617        };
618
619        // Connect client, but don't read yet — let the buffer overflow.
620        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        // Give the server a moment to register the client.
625        tokio::time::sleep(std::time::Duration::from_millis(50)).await;
626
627        // Flood the bus — many more events than the channel capacity (1).
628        // This guarantees the server-side rx will lag before the client reads.
629        for i in 0..20 {
630            bus.publish(make_event(&i.to_string(), "owner/repo"));
631        }
632
633        // Drain messages until we find a ControlMessage with kind=="lagged".
634        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}