Files
woogiandCursor a6b0c8fdfa
Docker Release / build-and-push (push) Successful in 1m27s
Docker Release / release (push) Skipped
Add per-job run logs and separate vision/text model selection.
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>
2026-07-31 14:56:48 -05:00

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 []