Skip to main content

orch/cli/
events.rs

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