Files
settled-reach/tooling/core/console.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

180 lines
6.1 KiB
Python

"""The single output path (D-263). Nothing else in `reach` prints.
Two channels, and keeping them apart is the whole design:
stdout the command's ACTUAL OUTPUT — the data the caller asked for.
Nothing else ever goes here, so `reach ... | jq` keeps working.
stderr the EVENT STREAM — progress, and the final verdict — as JSONL,
one object per line.
**Rendering happens at the sink, not at the emit site.** When stderr is a
terminal the events are rendered for a human; otherwise they are written as raw
JSONL. A live terminal and a job log are then the same artefact in two
presentations, which is what lets `reach jobs log` render for a person while a
conformance test asserts on the same bytes.
**Emitting is optional.** A command that never calls `event()` works normally,
and the push-gate checks deliberately emit nothing. This is a channel, not an
obligation — the point is that a long command *has somewhere to speak*, not that
every command must.
**The stream never replaces the verdict.** A remedy emitted at line 400 of 900
is technically printed and practically invisible, so `verdict()` prints once,
last, and is what a caller reads when it reads only one thing.
Stdlib only, and cheap to import: this module is on the gate path.
"""
from __future__ import annotations
import json
import os
import sys
from datetime import datetime, timezone
from typing import Any, TextIO
from tooling.core import jobs
LEVELS: dict[str, int] = {"debug": 10, "info": 20, "warn": 30, "error": 40}
ENV_LEVEL = "SR_LOG_LEVEL"
ENV_FORMAT = "SR_OUTPUT_FORMAT" # "json" | "text" — overrides TTY detection
# Quiet by default so hooks are not spammed. T-1249's global --verbose lowers it.
_threshold = LEVELS.get(os.environ.get(ENV_LEVEL, "info").lower(), LEVELS["info"])
def set_level(name: str) -> None:
"""Set the minimum level that reaches the stream. Unknown names are a no-op."""
global _threshold
if name.lower() in LEVELS:
_threshold = LEVELS[name.lower()]
def is_verbose() -> bool:
"""True when debug-level events are being emitted.
Read by the error handler to decide whether an unexpected failure gets a
traceback or a one-liner. Verbosity is one setting, not two — a --verbose
that showed debug events but hid tracebacks would be a puzzle.
"""
return _threshold <= LEVELS["debug"]
def out(text: str = "") -> None:
"""Write to stdout — the command's actual output, never commentary."""
print(text, file=sys.stdout, flush=True)
def event(
message: str,
*,
level: str = "info",
phase: str | None = None,
progress: float | None = None,
**extra: Any,
) -> None:
"""Emit one progress event onto the stream.
Suppressed entirely if below the current level, so a debug-chatty service
costs nothing in a hook.
"""
if LEVELS.get(level, LEVELS["info"]) < _threshold:
return
_write(
{
"ts": _now(),
"level": level,
"phase": phase,
"message": message,
"progress": progress,
**extra,
}
)
def verdict(message: str, *, ok: bool = True, fix: str | None = None) -> None:
"""Print the final summary: once, last, and never suppressed by the level.
`fix` is the command that would resolve a failure — the contract at the top
of D-263. It is carried as a structured field so a caller can extract it
without parsing prose.
"""
_write(
{
"ts": _now(),
"level": "info" if ok else "error",
"kind": "verdict",
"ok": ok,
"message": message,
"fix": fix,
}
)
def _now() -> str:
return datetime.now(timezone.utc).isoformat(timespec="milliseconds")
def _write(payload: dict[str, Any]) -> None:
# Tagged here rather than at each call site, so nothing can emit an
# uncorrelated line. The two events that matter most for a detached run —
# the verdict and the invocation record — are written while unwinding, and
# would be the ones a call-site approach missed.
job_id = jobs.current()
if job_id:
payload = {**payload, "job": job_id}
stream: TextIO = sys.stderr
if _render_as_text(stream):
stream.write(_render(payload))
else:
# Drop None-valued optionals so a log line carries what happened, not a
# census of the fields that did not apply.
compact = {k: v for k, v in payload.items() if v is not None}
stream.write(json.dumps(compact, ensure_ascii=False) + "\n")
stream.flush()
def _render_as_text(stream: TextIO) -> str | bool:
override = os.environ.get(ENV_FORMAT, "").lower()
if override == "text":
return True
if override == "json":
return False
try:
return stream.isatty()
except (AttributeError, ValueError):
# A closed or substituted stream: prefer the machine format, which is
# the one that stays parseable when nobody is watching.
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":
if payload.get("ok"):
return f"{message}\n"
fix = payload.get("fix")
tail = f"\n\nFix: {fix}\n" if fix else "\n"
return f"{message}{tail}"
prefix = {"warn": "warning: ", "error": "error: "}.get(payload.get("level", ""), "")
phase = payload.get("phase")
scope = f"[{phase}] " if phase else ""
progress = payload.get("progress")
pct = f" ({progress * 100:.0f}%)" if isinstance(progress, (int, float)) else ""
return f"{scope}{prefix}{message}{pct}\n"