1use anyhow::Context;
8use futures::StreamExt;
9use std::collections::HashMap;
10use std::net::TcpStream;
11use std::net::{Ipv4Addr, SocketAddrV4};
12use tokio_tungstenite::connect_async;
13
14fn 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
25fn 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
37pub async fn stream(repo: Option<&str>, task: Option<&str>) -> anyhow::Result<()> {
42 match read_ws_port() {
44 Ok(port) if probe_ws_port(port) => {
45 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 eprintln!("Event bus not available — falling back to store polling.");
55 stream_from_store(repo, task).await
56 }
57 }
58}
59
60async 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(¶ms.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 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 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 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
116fn 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
136async 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 && !t.repo.contains(rf)
158 {
159 continue;
160 }
161 let key = task_key(t);
162 if let Some(tf) = task_filter
163 && key != tf
164 && t.external_id.as_deref() != Some(tf)
165 {
166 continue;
167 }
168 let agent_str = t.agent.as_deref().unwrap_or("");
169 let pr_str = t
170 .pr_number
171 .map(|p| format!(" PR: #{p}"))
172 .unwrap_or_default();
173 println!(
174 "{now} [resync] {} {} {agent_str}{pr_str}",
175 key,
176 t.status.as_str(),
177 );
178 printed += 1;
179 }
180 if printed == 0 {
181 println!("{now} [resync] no active tasks");
182 }
183}
184
185async fn stream_from_store(
191 repo_filter: Option<&str>,
192 task_filter: Option<&str>,
193) -> anyhow::Result<()> {
194 let store = match crate::cli::init_store().await {
195 Ok(s) => s,
196 Err(e) => {
197 anyhow::bail!(
198 "Cannot stream events: store unavailable ({e:#}). Is the service running?"
199 );
200 }
201 };
202
203 println!("Watching store for status changes (polling every 3s)...\n");
204
205 let mut last_status: HashMap<String, (String, Option<String>, Option<i32>)> = HashMap::new();
207
208 let tasks = store.list_all_active_global().await.unwrap_or_default();
210 for t in &tasks {
211 let key = task_key(t);
212 last_status.insert(
213 key,
214 (t.status.as_str().to_string(), t.agent.clone(), t.pr_number),
215 );
216 }
217
218 let mut interval = tokio::time::interval(std::time::Duration::from_secs(3));
219 loop {
220 interval.tick().await;
221
222 let Ok(tasks) = store.list_all_active_global().await else {
223 continue;
224 };
225
226 for t in &tasks {
227 let key = task_key(t);
228
229 if let Some(ref rf) = repo_filter
231 && !t.repo.contains(rf)
232 {
233 continue;
234 }
235 if let Some(tf) = task_filter
236 && key != tf
237 && t.external_id.as_deref() != Some(tf)
238 {
239 continue;
240 }
241
242 let new_status = t.status.as_str().to_string();
243
244 match last_status.get(&key) {
245 Some((old_status, _, _)) if *old_status == new_status => {
246 }
248 Some((old_status, _, _)) => {
249 let now = chrono::Utc::now().format("%H:%M:%S");
251 let agent_str = t.agent.as_deref().unwrap_or("");
252 let pr_str = t
253 .pr_number
254 .map(|p| format!(" PR: #{p}"))
255 .unwrap_or_default();
256
257 println!("{now} {key} {old_status} \u{2192} {new_status} {agent_str}{pr_str}");
258 last_status.insert(key, (new_status, t.agent.clone(), t.pr_number));
259 }
260 None => {
261 let now = chrono::Utc::now().format("%H:%M:%S");
263 let agent_str = t.agent.as_deref().unwrap_or("");
264 println!("{now} {key} - \u{2192} {new_status} {agent_str}");
265 last_status.insert(key, (new_status, t.agent.clone(), t.pr_number));
266 }
267 }
268 }
269
270 let active_keys: std::collections::HashSet<String> = tasks.iter().map(task_key).collect();
272 let disappeared: Vec<String> = last_status
273 .keys()
274 .filter(|k| !active_keys.contains(*k))
275 .cloned()
276 .collect();
277 for key in disappeared {
278 if let Some((old_status, _, _)) = last_status.remove(&key)
279 && old_status != "done"
280 {
281 let now = chrono::Utc::now().format("%H:%M:%S");
282 println!("{now} {key} {old_status} \u{2192} done");
283 }
284 }
285 }
286}
287
288fn task_key(t: &crate::store::Task) -> String {
290 t.external_id
291 .clone()
292 .unwrap_or_else(|| format!("internal:{}", t.id))
293}
294
295#[cfg(test)]
296mod tests {
297 use super::*;
298
299 #[test]
300 fn probe_ws_port_unreachable() {
301 assert!(!probe_ws_port(1));
303 }
304
305 #[test]
306 fn task_key_prefers_external_id() {
307 let t = crate::store::Task {
308 id: 42,
309 external_id: Some("123".to_string()),
310 repo: "o/r".into(),
311 origin: "external".into(),
312 title: "t".into(),
313 body: String::new(),
314 status: crate::store::TaskStatus::New,
315 source: String::new(),
316 source_id: String::new(),
317 author: String::new(),
318 url: String::new(),
319 labels: Vec::new(),
320 agent: None,
321 model: None,
322 complexity: String::new(),
323 estimate: 0,
324 route_reason: String::new(),
325 agent_profile: String::new(),
326 selected_skills: String::new(),
327 route_attempts: 0,
328 attempts: 0,
329 branch: String::new(),
330 worktree: String::new(),
331 worktree_cleaned: false,
332 owner_node: None,
333 summary: String::new(),
334 last_error: String::new(),
335 parent_id: None,
336 block_reason: None,
337 pr_number: None,
338 pr_review_context: String::new(),
339 last_review_ts: String::new(),
340 review_ts_map: "{}".to_string(),
341 last_comment_review_ts: String::new(),
342 merge_conflict_retries: 0,
343 ci_merge_failures: 0,
344 pr_create_failures: 0,
345 push_failures: 0,
346 network_retries: 0,
347 review_agent_failures: 0,
348 review_resets: 0,
349 review_cycles: 0,
350 review_invocations: 0,
351 needs_review_refires: 0,
352 input_tokens: 0,
353 output_tokens: 0,
354 input_cost_usd: 0.0,
355 output_cost_usd: 0.0,
356 total_cost_usd: 0.0,
357 model_reroute_chain: String::new(),
358 limit_reroute_chain: String::new(),
359 memory: Vec::new(),
360 delegations: Vec::new(),
361 auto_unblock_count: 0,
362 auto_unblock_last_at: String::new(),
363 auto_unblock_last_reason: String::new(),
364 ci_recovery_count: 0,
365 no_code_reroutes: 0,
366 no_code_last_agent: String::new(),
367 created_at: String::new(),
368 updated_at: String::new(),
369 };
370 assert_eq!(task_key(&t), "123");
371 }
372
373 #[test]
374 fn task_key_falls_back_to_internal() {
375 let t = crate::store::Task {
376 id: 7,
377 external_id: None,
378 repo: "o/r".into(),
379 origin: "internal".into(),
380 title: "t".into(),
381 body: String::new(),
382 status: crate::store::TaskStatus::InProgress,
383 source: String::new(),
384 source_id: String::new(),
385 author: String::new(),
386 url: String::new(),
387 labels: Vec::new(),
388 agent: Some("claude".to_string()),
389 model: None,
390 complexity: String::new(),
391 estimate: 0,
392 route_reason: String::new(),
393 agent_profile: String::new(),
394 selected_skills: String::new(),
395 route_attempts: 0,
396 attempts: 1,
397 branch: String::new(),
398 worktree: String::new(),
399 worktree_cleaned: false,
400 owner_node: None,
401 summary: String::new(),
402 last_error: String::new(),
403 parent_id: None,
404 block_reason: None,
405 pr_number: None,
406 pr_review_context: String::new(),
407 last_review_ts: String::new(),
408 review_ts_map: "{}".to_string(),
409 last_comment_review_ts: String::new(),
410 merge_conflict_retries: 0,
411 ci_merge_failures: 0,
412 pr_create_failures: 0,
413 push_failures: 0,
414 network_retries: 0,
415 review_agent_failures: 0,
416 review_resets: 0,
417 review_cycles: 0,
418 review_invocations: 0,
419 needs_review_refires: 0,
420 input_tokens: 0,
421 output_tokens: 0,
422 input_cost_usd: 0.0,
423 output_cost_usd: 0.0,
424 total_cost_usd: 0.0,
425 model_reroute_chain: String::new(),
426 limit_reroute_chain: String::new(),
427 memory: Vec::new(),
428 delegations: Vec::new(),
429 auto_unblock_count: 0,
430 auto_unblock_last_at: String::new(),
431 auto_unblock_last_reason: String::new(),
432 ci_recovery_count: 0,
433 no_code_reroutes: 0,
434 no_code_last_agent: String::new(),
435 created_at: String::new(),
436 updated_at: String::new(),
437 };
438 assert_eq!(task_key(&t), "internal:7");
439 }
440}