Skip to main content

orch/cli/
events.rs

1//! CLI handler for `orch task events` and `orch task watch` — connects to the
2//! event bus websocket server and prints task status transitions in real-time.
3//!
4//! If the websocket is unreachable (stale port file after service restart/crash),
5//! falls back to polling the SQLite store for status changes.
6
7use anyhow::Context;
8use futures::StreamExt;
9use std::collections::HashMap;
10use std::net::TcpStream;
11use std::net::{Ipv4Addr, SocketAddrV4};
12use tokio_tungstenite::connect_async;
13
14/// Read the websocket port from `~/.orch/state/ws.port`.
15fn read_ws_port() -> anyhow::Result<u16> {
16    let state_dir = crate::home::state_dir()?;
17    let port_str = std::fs::read_to_string(state_dir.join("ws.port"))
18        .context("event bus not running — is the service started? (ws.port not found)")?;
19    port_str
20        .trim()
21        .parse()
22        .context("invalid port number in ws.port")
23}
24
25/// Probe whether a TCP socket is reachable on the given port.
26///
27/// Uses a short timeout (500ms) so the CLI doesn't hang on stale ports.
28fn probe_ws_port(port: u16) -> bool {
29    let addr = SocketAddrV4::new(Ipv4Addr::LOCALHOST, port);
30    TcpStream::connect_timeout(
31        &std::net::SocketAddr::V4(addr),
32        std::time::Duration::from_millis(500),
33    )
34    .is_ok()
35}
36
37/// Stream events from the event bus websocket. Falls back to store polling
38/// when the websocket port is stale or unreachable.
39///
40/// Output format: `HH:MM:SS task_id old_status → new_status agent PR: #N`
41pub async fn stream(repo: Option<&str>, task: Option<&str>) -> anyhow::Result<()> {
42    // Try to read and probe the websocket port.
43    match read_ws_port() {
44        Ok(port) if probe_ws_port(port) => {
45            // Port is alive — connect via websocket.
46            stream_from_ws(port, repo, task).await
47        }
48        Ok(port) => {
49            eprintln!("Event bus port {port} is not reachable — falling back to store polling.");
50            stream_from_store(repo, task).await
51        }
52        Err(_) => {
53            // No port file at all — service probably isn't running.
54            eprintln!("Event bus not available — falling back to store polling.");
55            stream_from_store(repo, task).await
56        }
57    }
58}
59
60/// Connect to the event bus websocket and stream events.
61async fn stream_from_ws(port: u16, repo: Option<&str>, task: Option<&str>) -> anyhow::Result<()> {
62    let mut url = format!("ws://127.0.0.1:{port}/events");
63    let mut params = Vec::new();
64    if let Some(r) = repo {
65        params.push(format!("repo={}", urlencoding::encode(r)));
66    }
67    if let Some(t) = task {
68        params.push(format!("task_id={}", urlencoding::encode(t)));
69    }
70    if !params.is_empty() {
71        url.push('?');
72        url.push_str(&params.join("&"));
73    }
74
75    let (ws, _) = connect_async(&url)
76        .await
77        .context("failed to connect to event bus — is the service running?")?;
78
79    let (_write, mut read) = ws.split();
80
81    println!("Connected to event bus (port {port}). Streaming events...\n");
82
83    // Open the store for lag resyncs (best-effort — None if unavailable).
84    let store = crate::cli::init_store().await.ok();
85
86    while let Some(msg) = read.next().await {
87        let msg = msg?;
88        if let tokio_tungstenite::tungstenite::Message::Text(text) = msg {
89            // First try to parse as a control message (lag notification).
90            if let Ok(ctrl) = serde_json::from_str::<crate::engine::events::ControlMessage>(&text) {
91                if ctrl.kind == "lagged" {
92                    eprintln!(
93                        "WARNING: event bus lagged — {} task transition(s) were dropped. \
94                         Resyncing current task state from store...",
95                        ctrl.missed
96                    );
97                    if let Some(ref s) = store {
98                        resync_from_store(s, repo, task).await;
99                    }
100                }
101                continue;
102            }
103
104            // Normal task event.
105            let Ok(event) = serde_json::from_str::<crate::engine::events::TaskEvent>(&text) else {
106                continue;
107            };
108
109            print_event(&event);
110        }
111    }
112
113    Ok(())
114}
115
116/// Format and print a single task event.
117fn print_event(event: &crate::engine::events::TaskEvent) {
118    let time = if event.timestamp.len() >= 19 {
119        &event.timestamp[11..19]
120    } else {
121        &event.timestamp
122    };
123    let agent_str = event.agent.as_deref().unwrap_or("");
124    let pr_str = event
125        .pr_number
126        .as_ref()
127        .map(|p| format!(" PR: #{p}"))
128        .unwrap_or_default();
129
130    println!(
131        "{time} {} {} \u{2192} {} {agent_str}{pr_str}",
132        event.task_id, event.old_status, event.new_status,
133    );
134}
135
136/// Resync: print current status of all active tasks from the store after a lag event.
137///
138/// This gives the operator a point-in-time snapshot so they know where things
139/// stand after missed transitions.
140async fn resync_from_store(
141    store: &crate::store::TaskStore,
142    repo_filter: Option<&str>,
143    task_filter: Option<&str>,
144) {
145    let tasks = match store.list_all_active_global().await {
146        Ok(t) => t,
147        Err(e) => {
148            eprintln!("  resync failed: {e:#}");
149            return;
150        }
151    };
152
153    let now = chrono::Utc::now().format("%H:%M:%S");
154    let mut printed = 0usize;
155    for t in &tasks {
156        if let Some(rf) = repo_filter {
157            if !t.repo.contains(rf) {
158                continue;
159            }
160        }
161        let key = task_key(t);
162        if let Some(tf) = task_filter {
163            if key != tf && t.external_id.as_deref() != Some(tf) {
164                continue;
165            }
166        }
167        let agent_str = t.agent.as_deref().unwrap_or("");
168        let pr_str = t
169            .pr_number
170            .map(|p| format!(" PR: #{p}"))
171            .unwrap_or_default();
172        println!(
173            "{now} [resync] {} {} {agent_str}{pr_str}",
174            key,
175            t.status.as_str(),
176        );
177        printed += 1;
178    }
179    if printed == 0 {
180        println!("{now} [resync] no active tasks");
181    }
182}
183
184/// Fallback: poll the SQLite store for task status changes.
185///
186/// When watching a specific task (`task` is Some), polls that task's status
187/// every 3 seconds and prints transitions. When watching all tasks, polls
188/// all active tasks and prints any status changes.
189async fn stream_from_store(
190    repo_filter: Option<&str>,
191    task_filter: Option<&str>,
192) -> anyhow::Result<()> {
193    let store = match crate::cli::init_store().await {
194        Ok(s) => s,
195        Err(e) => {
196            anyhow::bail!(
197                "Cannot stream events: store unavailable ({e:#}). Is the service running?"
198            );
199        }
200    };
201
202    println!("Watching store for status changes (polling every 3s)...\n");
203
204    // Snapshot of last known statuses: task_key -> (status, agent, pr_number)
205    let mut last_status: HashMap<String, (String, Option<String>, Option<i32>)> = HashMap::new();
206
207    // Seed initial state
208    let tasks = store.list_all_active_global().await.unwrap_or_default();
209    for t in &tasks {
210        let key = task_key(t);
211        last_status.insert(
212            key,
213            (t.status.as_str().to_string(), t.agent.clone(), t.pr_number),
214        );
215    }
216
217    let mut interval = tokio::time::interval(std::time::Duration::from_secs(3));
218    loop {
219        interval.tick().await;
220
221        let tasks = match store.list_all_active_global().await {
222            Ok(t) => t,
223            Err(_) => continue,
224        };
225
226        for t in &tasks {
227            let key = task_key(t);
228
229            // Apply filters
230            if let Some(ref rf) = repo_filter {
231                if !t.repo.contains(rf) {
232                    continue;
233                }
234            }
235            if let Some(tf) = task_filter {
236                if key != tf && t.external_id.as_deref() != Some(tf) {
237                    continue;
238                }
239            }
240
241            let new_status = t.status.as_str().to_string();
242
243            match last_status.get(&key) {
244                Some((old_status, _, _)) if *old_status == new_status => {
245                    // No change
246                }
247                Some((old_status, _, _)) => {
248                    // Status changed — print transition
249                    let now = chrono::Utc::now().format("%H:%M:%S");
250                    let agent_str = t.agent.as_deref().unwrap_or("");
251                    let pr_str = t
252                        .pr_number
253                        .map(|p| format!(" PR: #{p}"))
254                        .unwrap_or_default();
255
256                    println!("{now} {key} {old_status} \u{2192} {new_status} {agent_str}{pr_str}",);
257                    last_status.insert(key, (new_status, t.agent.clone(), t.pr_number));
258                }
259                None => {
260                    // New task appeared
261                    let now = chrono::Utc::now().format("%H:%M:%S");
262                    let agent_str = t.agent.as_deref().unwrap_or("");
263                    println!("{now} {key} - \u{2192} {new_status} {agent_str}");
264                    last_status.insert(key, (new_status, t.agent.clone(), t.pr_number));
265                }
266            }
267        }
268
269        // Check for tasks that were removed (completed + cleaned up)
270        let active_keys: std::collections::HashSet<String> = tasks.iter().map(task_key).collect();
271        let disappeared: Vec<String> = last_status
272            .keys()
273            .filter(|k| !active_keys.contains(*k))
274            .cloned()
275            .collect();
276        for key in disappeared {
277            if let Some((old_status, _, _)) = last_status.remove(&key) {
278                if old_status != "done" {
279                    let now = chrono::Utc::now().format("%H:%M:%S");
280                    println!("{now} {key} {old_status} \u{2192} done");
281                }
282            }
283        }
284    }
285}
286
287/// Derive a display key for a task: prefer external_id, fall back to internal id.
288fn task_key(t: &crate::store::Task) -> String {
289    t.external_id
290        .clone()
291        .unwrap_or_else(|| format!("internal:{}", t.id))
292}
293
294#[cfg(test)]
295mod tests {
296    use super::*;
297
298    #[test]
299    fn probe_ws_port_unreachable() {
300        // Port 1 should be unreachable (reserved).
301        assert!(!probe_ws_port(1));
302    }
303
304    #[test]
305    fn task_key_prefers_external_id() {
306        let t = crate::store::Task {
307            id: 42,
308            external_id: Some("123".to_string()),
309            repo: "o/r".into(),
310            origin: "external".into(),
311            title: "t".into(),
312            body: String::new(),
313            status: crate::store::TaskStatus::New,
314            source: String::new(),
315            source_id: String::new(),
316            author: String::new(),
317            url: String::new(),
318            labels: Vec::new(),
319            agent: None,
320            model: None,
321            complexity: String::new(),
322            estimate: 0,
323            route_reason: String::new(),
324            agent_profile: String::new(),
325            selected_skills: String::new(),
326            route_attempts: 0,
327            attempts: 0,
328            branch: String::new(),
329            worktree: String::new(),
330            worktree_cleaned: false,
331            summary: String::new(),
332            last_error: String::new(),
333            parent_id: None,
334            block_reason: None,
335            pr_number: None,
336            pr_review_context: String::new(),
337            last_review_ts: String::new(),
338            review_ts_map: "{}".to_string(),
339            last_comment_review_ts: String::new(),
340            merge_conflict_retries: 0,
341            ci_merge_failures: 0,
342            pr_create_failures: 0,
343            push_failures: 0,
344            network_retries: 0,
345            review_agent_failures: 0,
346            review_cycles: 0,
347            review_invocations: 0,
348            review_session_expected: false,
349            needs_review_refires: 0,
350            input_tokens: 0,
351            output_tokens: 0,
352            input_cost_usd: 0.0,
353            output_cost_usd: 0.0,
354            total_cost_usd: 0.0,
355            model_reroute_chain: String::new(),
356            limit_reroute_chain: String::new(),
357            memory: Vec::new(),
358            delegations: Vec::new(),
359            auto_unblock_count: 0,
360            auto_unblock_last_at: String::new(),
361            auto_unblock_last_reason: String::new(),
362            ci_recovery_count: 0,
363            no_code_reroutes: 0,
364            no_code_last_agent: String::new(),
365            created_at: String::new(),
366            updated_at: String::new(),
367        };
368        assert_eq!(task_key(&t), "123");
369    }
370
371    #[test]
372    fn task_key_falls_back_to_internal() {
373        let t = crate::store::Task {
374            id: 7,
375            external_id: None,
376            repo: "o/r".into(),
377            origin: "internal".into(),
378            title: "t".into(),
379            body: String::new(),
380            status: crate::store::TaskStatus::InProgress,
381            source: String::new(),
382            source_id: String::new(),
383            author: String::new(),
384            url: String::new(),
385            labels: Vec::new(),
386            agent: Some("claude".to_string()),
387            model: None,
388            complexity: String::new(),
389            estimate: 0,
390            route_reason: String::new(),
391            agent_profile: String::new(),
392            selected_skills: String::new(),
393            route_attempts: 0,
394            attempts: 1,
395            branch: String::new(),
396            worktree: String::new(),
397            worktree_cleaned: false,
398            summary: String::new(),
399            last_error: String::new(),
400            parent_id: None,
401            block_reason: None,
402            pr_number: None,
403            pr_review_context: String::new(),
404            last_review_ts: String::new(),
405            review_ts_map: "{}".to_string(),
406            last_comment_review_ts: String::new(),
407            merge_conflict_retries: 0,
408            ci_merge_failures: 0,
409            pr_create_failures: 0,
410            push_failures: 0,
411            network_retries: 0,
412            review_agent_failures: 0,
413            review_cycles: 0,
414            review_invocations: 0,
415            review_session_expected: false,
416            needs_review_refires: 0,
417            input_tokens: 0,
418            output_tokens: 0,
419            input_cost_usd: 0.0,
420            output_cost_usd: 0.0,
421            total_cost_usd: 0.0,
422            model_reroute_chain: String::new(),
423            limit_reroute_chain: String::new(),
424            memory: Vec::new(),
425            delegations: Vec::new(),
426            auto_unblock_count: 0,
427            auto_unblock_last_at: String::new(),
428            auto_unblock_last_reason: String::new(),
429            ci_recovery_count: 0,
430            no_code_reroutes: 0,
431            no_code_last_agent: String::new(),
432            created_at: String::new(),
433            updated_at: String::new(),
434        };
435        assert_eq!(task_key(&t), "internal:7");
436    }
437}