Capture pipeline stdout into job.log + API/UI so failed runs can be reviewed, and let users pick OpenRouter vision vs text models independently. Co-authored-by: Cursor <cursoragent@cursor.com>
94 lines
2.7 KiB
Python
94 lines
2.7 KiB
Python
"""
|
|
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_id>/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 []
|