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>
This commit is contained in:
@@ -150,6 +150,18 @@ def _render_as_text(stream: TextIO) -> str | bool:
|
||||
return False
|
||||
|
||||
|
||||
def render(payload: dict[str, Any]) -> str:
|
||||
"""Render one event as a human would see it live.
|
||||
|
||||
Public because `reach jobs log` replays a stored event stream through it.
|
||||
A second renderer for stored events would drift from this one, and the
|
||||
divergence would show up exactly when someone is reading a log to work out
|
||||
what went wrong — the worst moment to be looking at output that does not
|
||||
match what the live run printed.
|
||||
"""
|
||||
return _render(payload)
|
||||
|
||||
|
||||
def _render(payload: dict[str, Any]) -> str:
|
||||
message = payload.get("message", "")
|
||||
if payload.get("kind") == "verdict":
|
||||
|
||||
@@ -42,6 +42,27 @@ class ReachError(Exception):
|
||||
self.exit_code = exit_code if exit_code != 0 else 1
|
||||
|
||||
|
||||
class ReachExit(Exception): # noqa: N818 control flow, not an error
|
||||
"""Exit with a specific code, quietly. Not a failure.
|
||||
|
||||
For a command that must RELAY an exit code rather than report one — most
|
||||
obviously `reach jobs wait`, which exits with the code of the job it waited
|
||||
on. That is not `jobs wait` failing, so a `ReachError` would be wrong twice
|
||||
over: it would print a failure verdict for a command that worked, and
|
||||
demand a `fix=` for a situation with no remedy.
|
||||
|
||||
**Use this, never `typer.Exit`, inside a decorated command.** `typer.Exit`
|
||||
is a `RuntimeError`, not a `SystemExit`, so `handle_errors` catches it like
|
||||
any other unexpected exception — reporting "unexpected Exit: 3" and exiting
|
||||
**1**, silently discarding the code that was asked for. The conformance
|
||||
suite forbids it in routers so the trap cannot be re-entered.
|
||||
"""
|
||||
|
||||
def __init__(self, exit_code: int = 0) -> None:
|
||||
super().__init__(f"exit {exit_code}")
|
||||
self.exit_code = exit_code
|
||||
|
||||
|
||||
def unknown_choice(kind: str, given: str, accepted: Iterable[str]) -> ReachError:
|
||||
"""Reject a value from a known finite set, naming the whole set.
|
||||
|
||||
@@ -70,6 +91,9 @@ def handle_errors(func: F) -> F:
|
||||
def wrapper(*args: Any, **kwargs: Any) -> Any:
|
||||
try:
|
||||
return func(*args, **kwargs)
|
||||
except ReachExit as exc:
|
||||
# Control flow, not a failure — no verdict, just the code.
|
||||
raise SystemExit(exc.exit_code) from None
|
||||
except ReachError as exc:
|
||||
# The verdict prints ONCE, LAST, after whatever the command streamed.
|
||||
# A remedy emitted mid-stream at line 400 of 900 is technically
|
||||
|
||||
Reference in New Issue
Block a user