1use 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
27fn 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
38fn 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
46pub async fn stream(repo: Option<&str>, task: Option<&str>) -> anyhow::Result<()> {
51 match read_ws_port() {
53 Ok(port) if probe_ws_port(port) => {
54 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 eprintln!("Event bus not available — falling back to store polling.");
64 stream_from_store(repo, task).await
65 }
66 }
67}
68
69async 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(¶ms.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 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 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 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
125fn 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
145async 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
194async 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 let mut last_status: HashMap<String, (String, Option<String>, Option<i32>)> = HashMap::new();
216
217 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 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 }
257 Some((old_status, _, _)) => {
258 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 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 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
297fn 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 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 total_cost_usd: 0.0,
366 model_reroute_chain: String::new(),
367 limit_reroute_chain: String::new(),
368 memory: Vec::new(),
369 delegations: Vec::new(),
370 auto_unblock_count: 0,
371 auto_unblock_last_at: String::new(),
372 auto_unblock_last_reason: String::new(),
373 ci_recovery_count: 0,
374 no_code_reroutes: 0,
375 no_code_last_agent: String::new(),
376 created_at: String::new(),
377 updated_at: String::new(),
378 };
379 assert_eq!(task_key(&t), "123");
380 }
381
382 #[test]
383 fn task_key_falls_back_to_internal() {
384 let t = Task {
385 id: 7,
386 external_id: None,
387 repo: "o/r".into(),
388 origin: "internal".into(),
389 title: "t".into(),
390 body: String::new(),
391 status: TaskStatus::InProgress,
392 source: String::new(),
393 source_id: String::new(),
394 author: String::new(),
395 url: String::new(),
396 labels: Vec::new(),
397 agent: Some("claude".to_string()),
398 model: None,
399 complexity: String::new(),
400 estimate: 0,
401 route_reason: String::new(),
402 agent_profile: String::new(),
403 selected_skills: String::new(),
404 route_attempts: 0,
405 attempts: 1,
406 branch: String::new(),
407 worktree: String::new(),
408 worktree_cleaned: false,
409 owner_node: None,
410 summary: String::new(),
411 last_error: String::new(),
412 parent_id: None,
413 block_reason: None,
414 pr_number: None,
415 pr_review_context: String::new(),
416 last_review_ts: String::new(),
417 review_ts_map: "{}".to_string(),
418 last_comment_review_ts: String::new(),
419 merge_conflict_retries: 0,
420 ci_merge_failures: 0,
421 pr_create_failures: 0,
422 push_failures: 0,
423 network_retries: 0,
424 timeout_retries: 0,
425 review_agent_failures: 0,
426 review_resets: 0,
427 review_cycles: 0,
428 review_invocations: 0,
429 needs_review_refires: 0,
430 input_tokens: 0,
431 output_tokens: 0,
432 total_cost_usd: 0.0,
433 model_reroute_chain: String::new(),
434 limit_reroute_chain: String::new(),
435 memory: Vec::new(),
436 delegations: Vec::new(),
437 auto_unblock_count: 0,
438 auto_unblock_last_at: String::new(),
439 auto_unblock_last_reason: String::new(),
440 ci_recovery_count: 0,
441 no_code_reroutes: 0,
442 no_code_last_agent: String::new(),
443 created_at: String::new(),
444 updated_at: String::new(),
445 };
446 assert_eq!(task_key(&t), "internal:7");
447 }
448}