793a5239cd5e1f6251d7ea1ef1bc084bba8881b7
9
Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
a3cbc478a0 |
feat(config): T-1281 — canvas-version, and typer's other rich path
All five gates now live in the check domain. canvas-version produces byte-identical output to the original on the live tree. It is the first real consumer of core/process.run. The git calls pass check=False deliberately: a git failure here is not an error to report but a signal that there is nothing to compare, since a fresh clone with no remote is a legitimate state rather than a broken one. The argv-list and missing-binary guards still apply. Its two skips are kept distinct from its pass. NO_BASE and DIFF_FAILED exit 0, as does CLEAN — but only CLEAN means the gate actually looked at something. Collapsing them would hide a gate that had silently stopped running, which for this check in particular is the exact failure it exists to prevent. Found a second rich path while a NameError was rendering as a full-width box-drawn traceback: typer's pretty-exception handler is a different mechanism from rich_markup_mode, and setting one does nothing about the other. Same log pollution T-1259 thought it had closed, arriving through another door and landing in the worst place — a hook log at the moment something has already gone wrong. pretty_exceptions_enable=False now on the root and on every domain built by cli.domain(). test_canvas_version_check.py moves with the code it guards. It had been loading the extensionless script through a SourceFileLoader and reaching canvas_sources by sys.path insert, both only because tooling/ was not importable. Second instance of that debt evaporating on contact. What it asserts is unchanged, which is the point: diff_has_version_bump was kept pure in the port so its six properties still hold without constructing git history. Also restores an import the check router dropped in T-1267 when it moved to cli.domain() — caught by running the command rather than by reading it. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
afe2328182 |
feat(config): core/process.run — one guarded exec, and the logic in Python
Clarifies the rewrite decision to what it actually meant: rewriting the bash in Python does not mean reimplementing the operating system. A guarded exec is the right answer for rustup, curl, unzip, git, godot, blender. What must become Python is the LOGIC — which version is wanted, whether it is already present, what the output means, what to do when it fails. The test of a correct port is not whether it calls anything external, but whether the decisions can be exercised without performing them. Delivered ahead of the remaining ports because every one of them needs it. core/process.run is the single sanctioned exec, and each of its guards exists because a per-domain subprocess call is precisely where that guard goes missing: - An argv list, never a shell string. A string is rejected outright rather than helpfully split, since the helpful split is the vulnerability. - shell=False always. - A non-zero exit becomes a ReachError naming the command, carrying its output, and preserving its exit code — not a CalledProcessError traceback at someone who wanted to know the next step. - A missing binary reports what to install. FileNotFoundError names the path that was not found, which is the less useful half of the answer. All four verified against real commands, including a genuine git failure relaying exit 128. A conformance invariant keeps the door single: nothing outside core/process.py may import subprocess or call os.system/popen/exec*. Proven to fail by importing subprocess into a domain service. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
de69bd70b4 |
feat(config): T-1280 — job log retention, and the corpse that would never die
Pruning happens at spawn time rather than on a schedule: a retention pass that depends on someone remembering to run it is one that silently never happens. reach jobs prune is the explicit escape hatch for reclaiming space now. The cap was measured rather than guessed, which is why this ticket ran last. A chatty short job writes ~1.8 KB across its three files, so 100 jobs is single-digit megabytes even if a generator emits per-body progress — inside .cache/, where being wrong costs disk and never data. SR_JOB_KEEP overrides it. The interesting part is what "a running job is never pruned" has to mean. Not "the file says running" — a process killed outright never updates its own status, so that reading would make every crashed job immortal. Those are exactly the ones that accumulate, so the naive rule produces the opposite of retention: the only logs that never go away are the ones nobody wants. The check consults the process table instead. Verified both directions. Live, a running 30-second job survived a prune to --keep 1. Pinned with a fixture holding a finished job, a corpse (record says running, pid gone), and a genuinely live one — asserting the live one survives and the corpse does not. Proven to fail by dropping the liveness check. One false alarm worth recording: my first live test looked exactly like the bug, showing a running job pruned. It was not — my commands ran two minutes apart, so the "20-second" job had finished long before. The test was invalid, not the guard. A timing-sensitive check across separate shell turns proves nothing. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
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> |
||
|
|
c924b0934e |
feat(config): T-1277 — detach, and a failed job that looked busy
core/process.py spawns a child that outlives its parent: its own session, so a signal to the parent's group or a timeout kill does not take the work with it; re-execing reach by BARE NAME, because an absolute path would freeze the child to whichever checkout was current at spawn time and silently run the wrong source after a repoint; and streams kept separate exactly as in the foreground, events to <id>.jsonl and real output to <id>.out. Testing a case the ticket did not name found a real hole. Recording completion inside @command looked right and was wrong: a child that fails BEFORE any command runs — bad arguments, an unknown verb, an import error — never reaches that decorator. `reach --detach check bogus` left its metadata reading "running" forever with the process long gone. That is the exit-0 trap wearing a new disguise and worse than the original, because a failed job that looks busy sits somewhere nobody is watching, and a caller polling for completion would wait indefinitely on something that failed in milliseconds. So completion is recorded at the PROCESS's exit instead. main.py gains main(), wrapping cli() in a single try/finally, and the entry point moves to main:main. Every exit path now passes through one place. Removed from @command rather than left in both — two writers of one field is how they drift. Verified on three paths: success records done/0, a real drift failure records failed/1, and the parse failure that exposed the hole now records failed/2. One narrow conformance exemption, with its reason inline so it does not read as an oversight: the no-domain-imports-core.jobs invariant fired on main.py, correctly by its letter and wrongly by its purpose. main.py is not a command; it is the entry point, and it already owns --detach. Still open, and carried to T-1278: a child killed outright cannot record anything, so jobs list must reconcile against process liveness rather than trusting the file. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
5d83e1d2eb |
feat(config): T-1276 — every invocation is a job, carried ambiently
Streaming as a decorator, first half. Each invocation of reach gets an id and every event it emits is tagged with it, which is what will let a detached run's log be read back and what correlates the lines of a run that streamed for nine minutes. No command signature changed and no command imports core.jobs — that is the point, per the D-263 amendment: a command must not know jobs exist, because the alternative is call-site discipline wearing a different hat. A ContextVar rather than a module global. A global is correct only until something runs two invocations in one process — which a test harness or a future batch verb does immediately, and which would then interleave two jobs' events under one id with nothing reporting an error. The job context is the OUTERMOST wrapper, and it has to be. @logged emits from its finally and @handle_errors emits its verdict while unwinding, so a context established inside either would already be reset by the time the two most important events are written — leaving them the only untagged lines in the log, and they are precisely the ones a detached run gets read back for. Fixed in passing: the job id used local time while every event's ts is UTC, so an id read 155327 beside its own first log line reading 13:53:27. Two hours apart reads as a logging bug every time someone correlates them by eye. New conformance invariant — nothing outside core/ may import core.jobs. My first version of it inspected only the module path, so it missed `from tooling.core import jobs`, where the name is in the import LIST and which is the form anyone would actually write. It passed while checking nothing. Rewritten to catch all three reachable forms and then verified by committing a real violation, which it named by file and line. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
b5beda0df7 |
feat(config): T-1275 — bare reach is discovery, so it exits 0
Bare `reach` and bare `reach <domain>` printed help and exited 2, Click's usage-error convention. Running reach with no arguments is the DISCOVERY action — it is how the tool gets learned from nothing — and a caller that branches on exit status would read its own onboarding as a failure. Now they exit 0. D-263's exit-code contract is untouched: it governs failures, and printing a command list is not one. Verified across the whole matrix, because this change flirts with the exit-0 trap that record opens with — bare 0, bare domain 0, --help 0, unknown domain 2, unknown verb 2, real failure 1. All five are now pinned as a sixth conformance invariant, since an exit code regresses silently and nothing else would notice. Proven to fail by putting the 2 back. The implementation also collapses a duplicated class. core/cli.py holds ReachGroup with both shared behaviours — no-args-prints-help-and-exits-0, and unknown-name-enumerates — and LazyDomainGroup now extends it instead of subclassing TyperGroup directly, keeping only the laziness and the domain-specific wording. The enumeration logic previously existed twice in slightly different forms, which is how the root and the domains would have drifted into disagreeing about their own conventions. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
49fa6ada95 |
feat(config): T-1249 — the contract is a decorator, and now a test
Every non-zero exit names the command that would fix it, and still exits non-zero. Both halves matter; the second is the one that gets lost, because a tool that explains itself beautifully and exits 0 looks MORE correct while having silently disabled its own gate. core/errors.py holds ReachError(message, fix=) and @handle_errors. core/logging.py holds @logged, emitting through console rather than a second sink — one output path, so there is nothing to drift. core/command.py composes them, and the order is load-bearing: handle_errors wraps logged, so the logger sees the original exception. Inverted, every failure would be recorded as "SystemExit" and the log would say nothing about what went wrong while looking like it worked. core/ raises SystemExit, not typer.Exit. A service must be callable from a test, another service, or a future second front end, and an exception type that only makes sense inside a CLI leaks the transport into every layer. The check router is retrofitted off its hand-rolled verdict-and-exit pattern — exactly the boilerplate this removes — and test_check_parity.py passes unchanged across the retrofit. That test predates the decorators and pins exit codes against the old script, so it is independent evidence, not a test tuned to match new behaviour. Unknown domains and unknown verbs now enumerate what exists instead of only saying no. That needed a shared group class, which collided with "no typer outside main.py and router.py" — resolved by sharpening the invariant rather than breaking it, since its purpose is that a SERVICE never knows it was called from a CLI. Transport now lives in main.py, router.py and core/cli.py; never in service.py, schemas.py or helpers.py. The upside is that cli.domain() carries the settings that were previously per-router decisions, including the load-bearing rich_markup_mode=None that one forgetful domain could have undone. test_conformance.py makes five invariants executable, AST-based rather than grep. Scoped to the package, not the 123 legacy scripts — and deliberately so: as T-1250 moves each script into domains/, it lands inside the scope and the rules start applying automatically, so the test's reach grows with the migration. Proven to fail before being trusted: removing @command and removing a fix= each produced a failure naming the file, the line and the reason. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
559f3d82dc |
chore(config): T-1258 — tooling/ becomes an importable package
The skeleton the reach CLI hangs off. Nothing moves yet: this adds the package, the bounded core/, and explicit setuptools discovery. core/console.py is the single output path, and the split it enforces is the whole design — stdout carries the command's actual output so `reach ... | jq` keeps working, stderr carries the event stream as JSONL. Rendering happens at the sink: a terminal gets human text, anything else gets raw JSONL, so a live view and a job log are one artefact in two presentations. Emitting is optional — the gates emit nothing — and verdict() prints once, last, carrying its remedy as a structured field. core/config.py resolves the repo root from __file__ against a project.yaml sentinel, with an SR_REPO_ROOT override. No subprocess and no git call: this is on the gate path, and cwd is not a reliable signal anyway since a hook runs from the root and an agent call may not. Both paths are validated, because a silent fallback is how you end up editing one checkout and checking another. Discovery is configured explicitly rather than left to flat-layout auto-discovery, which would have had to choose between erroring on the ambiguity and quietly shipping client/ or docs/. Verified: top_level.txt contains exactly "tooling". Verified beyond the happy path — the sentinel rejects SR_REPO_ROOT=/tmp and names both remedies; debug events are suppressed at the default threshold while the verdict is not; stdout stays clean with stderr redirected away; and the three unconditional push-gate checks still pass now that tooling/ is a package, which was the real regression risk. Two findings recorded on the tickets. make setup-venv is stale — it calls .venv/bin/pip, but the venv was created by uv and has no pip, so the recorded procedure and the actual state have already diverged (T-1261 owns the fix). And settled-reach-tooling had never actually been installed: site-packages held the dependencies but no dist-info, which follows from there being no __init__.py to expose. This is the first commit where `import tooling` means anything. .venv/ was only ignored via .git/info/exclude, which is machine-local, so a fresh clone or a new worktree did not ignore it at all. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |