""" job_log.py - Capture pipeline stdout/stderr into a per-job log. The pipeline already prints stage progress via print(). For a reviewable post-run log we tee those lines into memory + outputs//job.log without rewriting every call site. """ import sys import time from contextlib import contextmanager from typing import Callable, Iterator, List, Optional, TextIO class _LineSplitter: """Accumulate write() chunks and emit complete lines.""" def __init__(self, on_line: Callable[[str], None]): self._buf = "" self._on_line = on_line def write(self, s: str) -> None: if not s: return self._buf += s while "\n" in self._buf: line, self._buf = self._buf.split("\n", 1) # Strip trailing CR from Windows-ish streams; keep content intact. self._on_line(line.rstrip("\r")) def flush_remainder(self) -> None: if self._buf: self._on_line(self._buf.rstrip("\r")) self._buf = "" class _Tee: """Mirror writes to the original stream and a line callback.""" def __init__(self, stream: TextIO, on_line: Callable[[str], None]): self._stream = stream self._lines = _LineSplitter(on_line) def write(self, s: str) -> int: n = self._stream.write(s) self._stream.flush() self._lines.write(s) return n def flush(self) -> None: self._stream.flush() def flush_remainder(self) -> None: self._lines.flush_remainder() def __getattr__(self, name: str): return getattr(self._stream, name) def stamp_line(line: str, t: Optional[float] = None) -> str: """Prefix a log line with HH:MM:SS.""" ts = time.strftime("%H:%M:%S", time.localtime(t if t is not None else time.time())) return f"[{ts}] {line}" @contextmanager def capture_stdio(on_line: Callable[[str], None]) -> Iterator[None]: """ Tee sys.stdout and sys.stderr into on_line(raw_line) for the duration. Safe for the single-job-at-a-time usage of this app; overlapping jobs would interleave (same limitation as the LLM cost counters). """ old_out, old_err = sys.stdout, sys.stderr tee_out = _Tee(old_out, on_line) tee_err = _Tee(old_err, on_line) sys.stdout = tee_out # type: ignore[assignment] sys.stderr = tee_err # type: ignore[assignment] try: yield finally: tee_out.flush_remainder() tee_err.flush_remainder() sys.stdout = old_out sys.stderr = old_err def read_log_file(path: str) -> List[str]: try: with open(path, encoding="utf-8") as f: return [ln.rstrip("\n") for ln in f] except OSError: return []