Skip to main content

orch/cli/
stream.rs

1//! `orch stream` and `orch task watch`: tail each running agent's `output.json` through the
2//! NDJSON formatter. The runner tees agent stdout there, so lines arrive whole
3
4use 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
11// Replayed when a file is first opened, so a new viewer sees recent context
12const 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    /// Complete lines appended since the last call
36    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        // The backlog starts mid-line
59        if std::mem::take(&mut self.skip_first) && !lines.is_empty() {
60            lines.remove(0);
61        }
62
63        lines
64    }
65}
66
67/// Follow one task (and its review) when `task_id` is set, else every running agent session
68pub 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        // A session that just ended gets this last read, its final message lands after the work
103        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            // Output scrolls fast, so every entry carries its task and attempt
117            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
141/// The task `orch task watch <id>` follows, resolved once through the shared CLI helper
142struct Watched {
143    id: String,
144    status: String,
145    /// The task's review dir and its own
146    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        // The review dir is watched anyway, so `<id>-review` watches the same pair
153        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    /// A running task with no output file is a problem, so name the path instead of waiting quietly
170    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
191/// Output files of every running agent session, keyed by session name
192async 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
206/// Session names replace `:` with `-`, task dirs keep the `internal:` id
207fn 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
214/// A tmux session names the repo without its owner, so look under every owner
215fn 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
226/// Newest `output.json` under `<dir>/attempts/*` for any of `dirs`
227fn 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
256/// Entry lines with `gutter(true)` on each step's first line and `gutter(false)` on the rest
257fn 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
272/// `<repo>#<task> a<n>` from `.../<repo>/tasks/<task>/attempts/<n>/output.json`
273fn 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
283/// Task dir of an output file, so a task keeps its colour across attempts
284fn 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            // mtime resolution is coarse on some filesystems
372            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}