Skip to main content

orch/engine/
events.rs

1//! Task event bus — broadcast channel for status transitions.
2
3use 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/// Parsed query-parameter filters for a websocket connection.
22#[derive(Clone, Debug, Default)]
23pub struct EventFilter {
24    pub task_id: Option<String>,
25    pub repo: Option<String>,
26}
27
28impl EventFilter {
29    /// Parse `?task_id=123&repo=owner/repo` from a request URI.
30    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    /// Returns `true` if the event matches this filter (or no filter is set).
55    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/// A control message sent by the server to the websocket client for out-of-band
71/// notifications such as lag warnings.
72///
73/// The client should treat these as advisory: print a warning to the operator
74/// and, when `kind == "lagged"`, resync task state from SQLite.
75#[derive(Clone, Debug, Serialize, Deserialize)]
76pub struct ControlMessage {
77    /// Discriminator — currently only `"lagged"`.
78    pub kind: String,
79    /// Human-readable description.
80    pub message: String,
81    /// Number of events skipped (for `"lagged"`).
82    pub missed: u64,
83}
84
85/// A task status transition event.
86#[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/// The event bus — wraps a tokio broadcast channel.
105#[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    /// Get a sender clone (for TaskManager, subscribers, etc.).
117    pub fn sender(&self) -> broadcast::Sender<TaskEvent> {
118        self.tx.clone()
119    }
120
121    /// Subscribe to events.
122    pub fn subscribe(&self) -> broadcast::Receiver<TaskEvent> {
123        self.tx.subscribe()
124    }
125
126    /// Publish an event. Returns number of receivers that got it.
127    #[cfg(test)]
128    pub fn publish(&self, event: TaskEvent) -> usize {
129        self.tx.send(event).unwrap_or(0)
130    }
131
132    /// Start the websocket server. Returns the bound port.
133    /// Spawns a background task — does not block.
134    pub async fn start_ws_server(&self) -> anyhow::Result<u16> {
135        // Select a deterministic port in the ephemeral range (49152-65535),
136        // starting from a hostname-based offset to be stable across restarts.
137        let (listener, port) = select_available_listener()?;
138        listener.set_nonblocking(true)?;
139        let listener = TcpListener::from_std(listener)?;
140
141        // Write port file without blocking the runtime thread.
142        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        // Write service version file so service status can detect CLI/service drift.
149        // Format: "{pid}\t{version}" so readers can verify the owning process is alive.
150        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                                        // Client disconnected or sent close
221                                        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
241/// Find an available port in the ephemeral range (49152-65535).
242/// Starts from a deterministic offset based on hostname hash, then increments.
243/// This gives stable first-attempt ports across restarts on the same machine.
244pub 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
264/// Remove the ws.port file on shutdown.
265pub async fn cleanup_port_file() {
266    if let Ok(path) = ws_port_path() {
267        let _ = fs::remove_file(path).await;
268    }
269}
270
271/// Remove the service.version file on shutdown — only if this process owns it.
272pub 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()); // legacy plain-version files: assume ownership
278        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        // Verify the port is reserved while the listener is alive.
379        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        // Connect a websocket client
458        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        // Give the server a moment to register the client
463        time::sleep(Duration::from_millis(50)).await;
464
465        // Publish an event
466        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        // Read from websocket
471        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
487        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        // Connect filtered client — only task "42"
496        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        // Publish event for task 42 (should arrive)
503        bus.publish(make_event("42", "owner/repo"));
504        // Publish event for task 99 (should be filtered out)
505        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        // No more messages should arrive — timeout confirms the filter works
521        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        // Connect filtered client — only repo containing "gabrielkoerich/orch"
533        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        // Matching event
543        bus.publish(make_event("1", "gabrielkoerich/orch"));
544        // Non-matching event
545        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        // No more messages — non-matching event was filtered server-side
562        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        // Unfiltered client
574        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    /// When the broadcast receiver lags (missed events), the server must send a
604    /// ControlMessage{kind:"lagged"} to the ws client instead of silently skipping.
605    #[tokio::test]
606    async fn ws_server_sends_lag_control_message() {
607        // Tiny capacity (1) so we can force lag by publishing faster than the
608        // client consumes.
609        let bus = EventBus::new(1);
610        let Ok(port) = bus.start_ws_server().await else {
611            // Port unavailable (race with parallel tests) — skip gracefully.
612            return;
613        };
614
615        // Connect client, but don't read yet — let the buffer overflow.
616        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        // Give the server a moment to register the client.
621        time::sleep(Duration::from_millis(50)).await;
622
623        // Flood the bus — many more events than the channel capacity (1).
624        // This guarantees the server-side rx will lag before the client reads.
625        for i in 0..20 {
626            bus.publish(make_event(&i.to_string(), "owner/repo"));
627        }
628
629        // Drain messages until we find a ControlMessage with kind=="lagged".
630        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}