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 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
184async 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 let mut last_status: HashMap<String, (String, Option<String>, Option<i32>)> = HashMap::new();
206
207 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 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 }
247 Some((old_status, _, _)) => {
248 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 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 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
287fn 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 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}