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