1use crate::cli::ndjson::{self, Formatter};
5use std::collections::HashMap;
6use std::fs::File;
7use std::io::{IsTerminal, Read, Seek, SeekFrom, Write};
8use std::path::{Path, PathBuf};
9use std::time::{Duration, SystemTime};
10
11const BACKLOG_BYTES: u64 = 64 * 1024;
13
14struct Tail {
15 path: PathBuf,
16 offset: u64,
17 partial: Vec<u8>,
18 skip_first: bool,
19 formatter: Formatter,
20}
21
22impl Tail {
23 fn open(path: PathBuf, color: bool) -> Self {
24 let len = std::fs::metadata(&path).map_or(0, |m| m.len());
25 let offset = len.saturating_sub(BACKLOG_BYTES);
26 Self {
27 path,
28 offset,
29 partial: Vec::new(),
30 skip_first: offset > 0,
31 formatter: Formatter::new(color),
32 }
33 }
34
35 fn read_lines(&mut self) -> Vec<String> {
37 let mut buf = Vec::new();
38 let read = File::open(&self.path).and_then(|mut f| {
39 f.seek(SeekFrom::Start(self.offset))?;
40 f.read_to_end(&mut buf)
41 });
42 let Ok(n) = read else {
43 return vec![];
44 };
45
46 self.offset += n as u64;
47 self.partial.extend_from_slice(&buf);
48 let Some(end) = self.partial.iter().rposition(|&b| b == b'\n') else {
49 return vec![];
50 };
51
52 let rest = self.partial.split_off(end + 1);
53 let complete = std::mem::replace(&mut self.partial, rest);
54 let mut lines: Vec<String> = String::from_utf8_lossy(&complete)
55 .lines()
56 .map(String::from)
57 .collect();
58 if std::mem::take(&mut self.skip_first) && !lines.is_empty() {
60 lines.remove(0);
61 }
62
63 lines
64 }
65}
66
67pub async fn run(task_id: Option<&str>, formatted: bool) -> anyhow::Result<()> {
69 let color = formatted && std::io::stdout().is_terminal();
70 let watched = if let Some(id) = task_id {
71 println!("Watching task {id}. Ctrl+C to stop");
72 Some(Watched::resolve(id).await?)
73 } else {
74 println!("Watching all running agents. Ctrl+C to stop");
75 None
76 };
77
78 let mut tails: HashMap<String, Tail> = HashMap::new();
79 let mut colors: HashMap<String, &str> = HashMap::new();
80 let mut tag_width = 0;
81 let mut said_waiting = false;
82 loop {
83 let found = match &watched {
84 Some(task) => task.sources(),
85 None => session_sources().await,
86 };
87 if let (Some(task), true, false) = (&watched, found.is_empty(), said_waiting) {
88 println!("{}", task.nothing_yet());
89 said_waiting = true;
90 }
91
92 let live: Vec<String> = found.iter().map(|(key, _)| key.clone()).collect();
93 for (key, path) in found {
94 let tail = tails
95 .entry(key)
96 .or_insert_with(|| Tail::open(path.clone(), color));
97 if tail.path != path {
98 *tail = Tail::open(path, color);
99 }
100 }
101
102 for tail in tails.values_mut() {
104 let mut entries = Vec::new();
105 for line in tail.read_lines() {
106 if formatted {
107 entries.extend(tail.formatter.format(&line));
108 } else {
109 entries.push(line);
110 }
111 }
112 if entries.is_empty() {
113 continue;
114 }
115
116 let tag = tag(&tail.path);
118 tag_width = tag_width.max(tag.chars().count());
119 let next = TAG_COLORS[colors.len() % TAG_COLORS.len()];
120 let code = *colors.entry(key_of(&tail.path)).or_insert(next);
121 let gutter = |show: bool| {
122 let text = if show { tag.as_str() } else { "" };
123 let text = format!("{text:<tag_width$} │");
124 if color {
125 format!("\x1b[{code}m{text}\x1b[0m")
126 } else {
127 text
128 }
129 };
130 for line in tag_lines(&entries, formatted, gutter) {
131 println!("{line}");
132 }
133 }
134 tails.retain(|key, _| live.contains(key));
135
136 std::io::stdout().flush()?;
137 tokio::time::sleep(Duration::from_secs(1)).await;
138 }
139}
140
141struct Watched {
143 id: String,
144 status: String,
145 dirs: Vec<PathBuf>,
147}
148
149impl Watched {
150 async fn resolve(id: &str) -> anyhow::Result<Self> {
151 let store = crate::cli::init_store().await?;
152 let id = id.strip_suffix("-review").unwrap_or(id);
154 let task = crate::cli::task_ref::resolve(&store, id).await?;
155 Ok(Self {
156 id: task.display_spec(),
157 status: task.status.clone(),
158 dirs: crate::cli::task_ref::state_dirs(&task)?,
159 })
160 }
161
162 fn sources(&self) -> Vec<(String, PathBuf)> {
163 newest_output(&self.dirs)
164 .map(|path| (self.id.clone(), path))
165 .into_iter()
166 .collect()
167 }
168
169 fn nothing_yet(&self) -> String {
171 if self.status != crate::store::TaskStatus::InProgress.as_str() {
172 return format!(
173 "No output for task {} yet, waiting for it to start",
174 self.id
175 );
176 }
177
178 let searched: Vec<String> = self
179 .dirs
180 .iter()
181 .map(|dir| dir.join("attempts").display().to_string())
182 .collect();
183 format!(
184 "Task {} is in_progress but no output file was found under {}",
185 self.id,
186 searched.join(" or ")
187 )
188 }
189}
190
191async fn session_sources() -> Vec<(String, PathBuf)> {
193 let tmux = crate::tmux::TmuxManager::new();
194 crate::channels::tmux::list_orch_sessions()
195 .await
196 .unwrap_or_default()
197 .into_iter()
198 .filter_map(|session| {
199 let (project, task) = tmux.parse_session_name(&session)?;
200 let path = session_output(&project, &task_dir_name(&task))?;
201 Some((session, path))
202 })
203 .collect()
204}
205
206fn task_dir_name(task: &str) -> String {
208 match task.strip_prefix("internal-") {
209 Some(rest) => format!("internal:{rest}"),
210 None => task.to_string(),
211 }
212}
213
214fn session_output(project: &str, name: &str) -> Option<PathBuf> {
216 let state = crate::home::state_dir().ok()?;
217 let dirs: Vec<PathBuf> = subdirs(&state)
218 .iter()
219 .flat_map(|owner| subdirs(owner))
220 .filter(|repo| repo.file_name() == Some(project.as_ref()))
221 .map(|repo| repo.join("tasks").join(name))
222 .collect();
223 newest_output(&dirs)
224}
225
226fn newest_output(dirs: &[PathBuf]) -> Option<PathBuf> {
228 let mut best: Option<(SystemTime, PathBuf)> = None;
229 for dir in dirs {
230 for attempt in subdirs(&dir.join("attempts")) {
231 let output = attempt.join("output.json");
232 let Ok(modified) = output.metadata().and_then(|m| m.modified()) else {
233 continue;
234 };
235 if best.as_ref().is_none_or(|(t, _)| modified > *t) {
236 best = Some((modified, output));
237 }
238 }
239 }
240
241 best.map(|(_, p)| p)
242}
243
244fn subdirs(dir: &Path) -> Vec<PathBuf> {
245 std::fs::read_dir(dir)
246 .into_iter()
247 .flatten()
248 .flatten()
249 .map(|e| e.path())
250 .filter(|p| p.is_dir())
251 .collect()
252}
253
254const TAG_COLORS: [&str; 6] = ["36", "35", "33", "34", "96", "95"];
255
256fn tag_lines(entries: &[String], formatted: bool, gutter: impl Fn(bool) -> String) -> Vec<String> {
258 let mut out = Vec::new();
259 for entry in entries {
260 let step = !formatted || !entry.starts_with(ndjson::RESULT_PREFIX);
261 if formatted && step {
262 out.push(gutter(false));
263 }
264
265 for (i, line) in entry.lines().enumerate() {
266 out.push(format!("{} {line}", gutter(step && i == 0)));
267 }
268 }
269 out
270}
271
272fn tag(path: &Path) -> String {
274 match path_parts(path).as_slice() {
275 [attempt, _, task, _, repo] => {
276 let task = task.strip_prefix("internal:").unwrap_or(task);
277 format!("{repo}#{task} a{attempt}")
278 }
279 _ => path.display().to_string(),
280 }
281}
282
283fn key_of(path: &Path) -> String {
285 path.ancestors()
286 .nth(3)
287 .map_or_else(String::new, |p| p.display().to_string())
288}
289
290fn path_parts(path: &Path) -> Vec<String> {
291 path.iter()
292 .rev()
293 .skip(1)
294 .take(5)
295 .map(|p| p.to_string_lossy().into_owned())
296 .collect()
297}
298
299#[cfg(test)]
300mod tests {
301 use super::*;
302
303 #[test]
304 fn tail_returns_only_complete_lines() {
305 let dir = tempfile::tempdir().unwrap();
306 let path = dir.path().join("output.json");
307 std::fs::write(&path, "one\ntw").unwrap();
308 let mut tail = Tail::open(path.clone(), false);
309 assert_eq!(tail.read_lines(), vec!["one"]);
310
311 let mut f = std::fs::OpenOptions::new()
312 .append(true)
313 .open(&path)
314 .unwrap();
315 f.write_all(b"o\nthree\n").unwrap();
316 assert_eq!(tail.read_lines(), vec!["two", "three"]);
317 assert_eq!(tail.read_lines(), [] as [String; 0]);
318 }
319
320 #[test]
321 fn tail_skips_cut_line_in_backlog() {
322 let dir = tempfile::tempdir().unwrap();
323 let path = dir.path().join("output.json");
324 let long = "x".repeat(usize::try_from(BACKLOG_BYTES).unwrap());
325
326 std::fs::write(&path, format!("{long}\nlast\n")).unwrap();
327 assert_eq!(Tail::open(path, false).read_lines(), vec!["last"]);
328 }
329
330 #[test]
331 fn task_dir_name_restores_internal_colon() {
332 assert_eq!(task_dir_name("internal-8-review"), "internal:8-review");
333 assert_eq!(task_dir_name("3601"), "3601");
334 }
335
336 #[test]
337 fn in_progress_without_output_names_the_path() {
338 let watched = Watched {
339 id: "owner/repo#5".to_string(),
340 status: crate::store::TaskStatus::InProgress.as_str().to_string(),
341 dirs: vec![
342 PathBuf::from("/state/owner/repo/tasks/5-review"),
343 PathBuf::from("/state/owner/repo/tasks/5"),
344 ],
345 };
346 let msg = watched.nothing_yet();
347 assert!(msg.contains("in_progress"), "{msg}");
348 assert!(msg.contains("/state/owner/repo/tasks/5/attempts"), "{msg}");
349 assert!(
350 msg.contains("/state/owner/repo/tasks/5-review/attempts"),
351 "{msg}"
352 );
353
354 let waiting = Watched {
355 status: crate::store::TaskStatus::Routed.as_str().to_string(),
356 ..watched
357 };
358 let msg = waiting.nothing_yet();
359 assert!(msg.contains("waiting for it to start"), "{msg}");
360 assert!(!msg.contains("no output file was found"), "{msg}");
361 }
362
363 #[test]
364 fn newest_output_picks_the_latest_attempt() {
365 let dir = tempfile::tempdir().unwrap();
366 let task = dir.path().join("tasks").join("5");
367 for attempt in ["1", "2"] {
368 let d = task.join("attempts").join(attempt);
369 std::fs::create_dir_all(&d).unwrap();
370 std::fs::write(d.join("output.json"), attempt).unwrap();
371 std::thread::sleep(Duration::from_millis(10));
373 }
374 let found = newest_output(&[task.join("5-review"), task.clone()]).unwrap();
375 assert_eq!(found, task.join("attempts").join("2").join("output.json"));
376 assert!(newest_output(&[dir.path().join("missing")]).is_none());
377 }
378
379 #[test]
380 fn tag_drops_internal_prefix() {
381 let path = Path::new("/s/owner/bean/tasks/internal:189488-review/attempts/1/output.json");
382 assert_eq!(tag(path), "bean#189488-review a1");
383 assert_eq!(key_of(path), "/s/owner/bean/tasks/internal:189488-review");
384 }
385
386 #[test]
387 fn tag_lines_label_each_step_only() {
388 let entries = vec![
389 "⏺ Read(a.rs)".to_string(),
390 " ⎿ Read 3 lines".to_string(),
391 "⏺ Fixing it.\n Then test.".to_string(),
392 ];
393 let gutter = |show: bool| if show { "t1 │" } else { " │" }.to_string();
394 assert_eq!(
395 tag_lines(&entries, true, gutter),
396 vec![
397 " │",
398 "t1 │ ⏺ Read(a.rs)",
399 " │ ⎿ Read 3 lines",
400 " │",
401 "t1 │ ⏺ Fixing it.",
402 " │ Then test.",
403 ]
404 );
405 }
406
407 #[test]
408 fn tag_lines_raw_tags_every_line() {
409 let entries = vec!["{\"a\":1}".to_string(), "{\"b\":2}".to_string()];
410 let gutter = |show: bool| if show { "t │" } else { " │" }.to_string();
411 assert_eq!(
412 tag_lines(&entries, false, gutter),
413 vec!["t │ {\"a\":1}", "t │ {\"b\":2}"]
414 );
415 }
416}