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>
This commit is contained in:
@@ -0,0 +1,93 @@
|
||||
"""
|
||||
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 []
|
||||
Reference in New Issue
Block a user