reach jobs list / status / log --follow / wait. A domain rather than core/, because these verbs carry logic and state: they reconcile recorded status against process liveness, tail a file from an offset, and relay an exit code. Found a latent bug in already-committed code before building on it. typer.Exit is a RuntimeError, not a SystemExit, so @handle_errors caught it like any other unexpected exception: `raise typer.Exit(3)` inside a decorated command printed "unexpected Exit: 3" and exited 1, silently discarding the requested code. Nothing hit it because the check router had been converted to ReachError — but jobs wait needs exactly this and it is what anyone would naturally write. Added core/errors.ReachExit as the sanctioned control-flow exit, passed straight through with no verdict. ReachError would have been wrong twice: a failure verdict for a command that worked, and a demand for a fix= where there is no remedy. Reconciliation proved out on a real corpse rather than a simulated one — the job stranded by the T-1277 bug, status "running" with its process long gone, now reports as died. DIED is derived, never recorded, because a process killed outright cannot write its own ending. It relays 137, never 0: a died job has no exit code of its own and borrowing success points the exit-0 trap straight at whatever gated on the run. Second UTC bug of the same family as T-1276's: jobs list reported a job started minutes earlier as running for 133m, because _parse used mktime on a UTC stamp and silently added the offset to every duration. console.render() is public now, so jobs log replays stored events through the same path a live run prints them — a second renderer would drift, and the divergence would surface exactly when someone is reading a log to find out what went wrong. test_jobs.py closes the gap T-1257 named: D-263 claims services are callable without a CLI round trip, and nothing had ever demonstrated it, which left the layering as unverified decoration. Every test here calls the service directly. Not yet exercised, and said plainly: log --follow against a genuinely long-running job. Nothing in reach runs long enough to tail yet. The offset mechanics underneath are tested; the live loop waits for a slow domain. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
117 lines
4.3 KiB
Python
117 lines
4.3 KiB
Python
"""Transport for the `jobs` domain — args in, delegate, format out.
|
|
|
|
Zero logic. Reconciliation, offsets and polling all live in `service.py`; what
|
|
happens here is turning a `Job` into lines and an exit code.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import time
|
|
|
|
import typer
|
|
|
|
from tooling.core import cli, console
|
|
from tooling.core.command import command
|
|
from tooling.core.errors import ReachExit
|
|
from tooling.domains.jobs import service
|
|
from tooling.domains.jobs.schemas import Status
|
|
|
|
app = cli.domain("jobs", "Detached runs — what is running, what it printed, how it ended.")
|
|
|
|
|
|
@app.callback()
|
|
def _domain() -> None:
|
|
"""Keeps `jobs` a group (Typer collapses a single-command app)."""
|
|
|
|
|
|
@app.command("list")
|
|
@command
|
|
def list_jobs(
|
|
limit: int = typer.Option(20, "--limit", "-n", help="How many recent jobs to show."),
|
|
) -> None:
|
|
"""Recent detached runs, newest first."""
|
|
jobs = service.list_jobs(limit)
|
|
if not jobs:
|
|
console.out("no jobs recorded — start one with: reach --detach <command>")
|
|
return
|
|
for job in jobs:
|
|
console.out(
|
|
f"{job.job} {job.status.value:<8} {service.duration(job):>7} {job.command}"
|
|
)
|
|
|
|
|
|
@app.command("status")
|
|
@command
|
|
def status(job_id: str = typer.Argument(..., help="Job id, as printed by --detach.")) -> None:
|
|
"""One job's outcome, reconciled against whether its process is alive."""
|
|
job = service.get(job_id)
|
|
console.out(f"job {job.job}")
|
|
console.out(f"command {job.command}")
|
|
console.out(f"status {job.status.value}")
|
|
console.out(f"started {job.started_at}")
|
|
console.out(f"elapsed {service.duration(job)}")
|
|
if job.exit_code is not None:
|
|
console.out(f"exit {job.exit_code}")
|
|
if job.status is Status.DIED:
|
|
# Said in words, because "died" alone reads like a synonym for "failed"
|
|
# and the distinction matters: nothing recorded an outcome here.
|
|
console.out("")
|
|
console.out("This job's process is gone but it never recorded an ending —")
|
|
console.out("killed outright (SIGKILL, OOM, or a crash). Its log holds")
|
|
console.out("whatever it managed to emit before that.")
|
|
|
|
|
|
@app.command("log")
|
|
@command
|
|
def log(
|
|
job_id: str = typer.Argument(..., help="Job id, as printed by --detach."),
|
|
follow: bool = typer.Option(False, "--follow", "-f", help="Keep printing as it runs."),
|
|
) -> None:
|
|
"""Replay a job's event stream, rendered as it appeared live."""
|
|
job = service.get(job_id)
|
|
events, offset = service.read_events(job_id)
|
|
for event in events:
|
|
# Rendered through console, not a local formatter, so a stored log and a
|
|
# live run are one artefact in two presentations rather than two
|
|
# renderers that drift apart precisely when someone is debugging.
|
|
console.out(console.render(event).rstrip("\n"))
|
|
|
|
if not follow:
|
|
return
|
|
|
|
while not job.status.finished:
|
|
time.sleep(service.POLL_SECONDS)
|
|
events, offset = service.read_events(job_id, offset)
|
|
for event in events:
|
|
console.out(console.render(event).rstrip("\n"))
|
|
job = service.get(job_id)
|
|
|
|
# One last read: the job may have written its final events between the last
|
|
# poll and its exit, and stopping at the status flip would drop them.
|
|
events, offset = service.read_events(job_id, offset)
|
|
for event in events:
|
|
console.out(console.render(event).rstrip("\n"))
|
|
|
|
|
|
@app.command("wait")
|
|
@command
|
|
def wait(
|
|
job_id: str = typer.Argument(..., help="Job id, as printed by --detach."),
|
|
timeout: float = typer.Option(None, "--timeout", help="Give up after N seconds."),
|
|
) -> None:
|
|
"""Block until a job finishes, then exit with ITS exit code.
|
|
|
|
That relay is the point: a Makefile or a hook can gate on a detached run
|
|
exactly as it would on a foreground one. `ReachExit` rather than
|
|
`ReachError` because waiting successfully for a job that failed is not a
|
|
failure of `wait`.
|
|
"""
|
|
job = service.wait(job_id, timeout)
|
|
console.verdict(
|
|
f"job {job.job} {job.status.value} after {service.duration(job)}"
|
|
+ (f" (exit {job.exit_code})" if job.exit_code is not None else ""),
|
|
ok=job.status is Status.DONE,
|
|
fix=None if job.status is Status.DONE else f"reach jobs log {job.job}",
|
|
)
|
|
raise ReachExit(job.effective_exit_code)
|