Files
settled-reach/tooling/domains/jobs/router.py
T
jpmschweitzerandClaude Opus 5 6f08cc9156 feat(config): T-1278 — the jobs domain, and typer.Exit is not a SystemExit
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>
2026-08-31 17:02:36 +02:00

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)