feat(u6): benches — a registry with identity, and the rule enforced
The standing link board carried three jobs because only one of them had a surface. Re-measured before contracting, its 221 rows split into 178 booth announcements (156 already dead) and 43 non-booth rows, of which 8 are the same bench re-posted. U5 gave the booth announcement a home; this gives the running service one, and refuses the one shape that now has somewhere better to go. - booth/benches.py (new, stdlib-only and sibling-free): the Bench record, URL normalization as the identity, a lenient read on the render path and a strict read on the write path, atomic replace under an flock, and a stated total order (state rank, name casefolded, id). - links.booth_target: ONE predicate for "is this a booth URL", consumed by the CLI refusal, the board's dead marker and bench import. Host-agnostic, path-shaped, percent-decoded, never raises. - booth link refuses a booth URL, names `booth new --why`, and writes nothing — not the row, not the board directory, not the announcement. - The board marks rows whose booth has been swept. Nothing here deletes a row: removal stays the operator's two clicks through the existing bulk control. - booth bench add|ls|state|rm|import. import writes nothing without --apply and never edits links.md. - docs/archive/links-2026-09-22.md: the board archived verbatim into git. Identity is the FULL normalized URL, not the origin, and that was measured: origin identity collapses the 43 non-booth rows to 19 groups by merging eight distinct gitea repositories into one row, three unrelated HuggingFace model cards into one, and the two LRPG surfaces on 10.100.10.50:8321 — the design doc's own example of two real benches — into one. Full-URL identity still collapses both cases that doc names: talk 5 to 1, Peedlar 3 to 1. booth link is NOT deprecated. Roughly 14 of the 35 distinct non-booth targets are reference bookmarks for which the board is the right and only home; the design doc's plan to deprecate it would have evicted a third of its live content. Corrected there, along with what "normalized URL" means. The seam review found three real defects in the contract before any code: the claim that test_stdlib_only already forbids sibling imports (it exempts `booth` on purpose), naming resolve_booth as the dead marker's existence check (it raises HTTPException(404), so one swept booth would have 404'd the whole board page), and silence on percent-encoding (booth links are emitted through quote(name, safe=""), so a raw comparison marks every encoded booth dead forever). That both list_booths and sweep_once skip the registry was verified against the real functions rather than assumed. 444 -> 555 tests. Deployed and verified live: 23/23 booths 200, and the board renders 156 dead of 221 rows, matching an independent pre-implementation count. NOT TAGGED: both cold gates are in flight (contract review 01M35BWCJ806MT75NA630Y4WFH, code review 01M35CK8YKEKMV7T15JXEF6A8N) and the bug-hunt has not run. Per the v0.2.0 lesson, the tag waits for the gates.
This commit is contained in:
+88
-1
@@ -165,10 +165,19 @@ from booth.manifest import ( # noqa: E402
|
||||
read_manifest,
|
||||
write_manifest,
|
||||
)
|
||||
from booth.benches import ( # noqa: E402
|
||||
BENCH_STATES,
|
||||
normalize_bench_url,
|
||||
read_benches,
|
||||
remove_bench,
|
||||
set_bench_state,
|
||||
upsert_bench,
|
||||
)
|
||||
from booth.links import ( # noqa: E402
|
||||
LINK_LOCK,
|
||||
LINKS_FILE,
|
||||
PINS_FILE,
|
||||
booth_target,
|
||||
link_entry_id,
|
||||
order_for_display,
|
||||
parse_link_entries,
|
||||
@@ -917,6 +926,15 @@ def create_app(
|
||||
# `read_manifest` already take. A booth whose `links.md` cannot
|
||||
# be read renders as a booth with no board.
|
||||
"board": _board_rows(booth),
|
||||
# The bench registry, rendered on the STANDING BOARD's page and
|
||||
# nowhere else: it belongs to exactly one booth, and a read per
|
||||
# gallery page view would buy noise. `_board_rows` is empty for
|
||||
# every other booth, so this pair is read only when it renders.
|
||||
# `read_benches` never raises; a damaged registry costs its own
|
||||
# panel and says so, which is the v0.2.2 lesson.
|
||||
**dict(zip(("benches", "benches_error"),
|
||||
read_benches(data_dir) if (booth / LINKS_FILE).is_file()
|
||||
else ([], None))),
|
||||
# Marks: operator judgment attached to this booth or to one of
|
||||
# its items — a session's question (`pick`), the operator's own
|
||||
# remark (`note`), the operator's selection (`flag`). Rendered
|
||||
@@ -951,13 +969,41 @@ def create_app(
|
||||
try:
|
||||
if not (booth / LINKS_FILE).is_file():
|
||||
return []
|
||||
return order_for_display(
|
||||
rows = order_for_display(
|
||||
parse_link_entries((booth / LINKS_FILE).read_text()),
|
||||
read_pins(booth),
|
||||
)
|
||||
# DEAD = the row points at a booth that no longer exists. 156 of the
|
||||
# board's 221 rows are exactly that, and nothing on the page could
|
||||
# tell them apart, so the bulk-delete control that has existed since
|
||||
# before this unit was unusable at that scale. Marking is all this
|
||||
# does: removal stays the operator's two deliberate clicks, because
|
||||
# "a migration that deletes anything" is not in v1.
|
||||
for row in rows:
|
||||
target = booth_target(row["url"])
|
||||
row["dead"] = target is not None and not _booth_exists(target)
|
||||
return rows
|
||||
except (OSError, ValueError, UnicodeDecodeError):
|
||||
return []
|
||||
|
||||
def _booth_exists(name: str) -> bool:
|
||||
"""Whether a booth name is a live directory. NEVER RAISES.
|
||||
|
||||
SEAM REVIEW SR-2: this deliberately does NOT call `resolve_booth`, which
|
||||
raises HTTPException(404) — called once per board row, one swept booth
|
||||
would 404 the whole page, which is the opposite of the marker's purpose.
|
||||
`booth_target` has already applied the same addressability rules
|
||||
`resolve_booth` enforces, so the two cannot disagree about what is
|
||||
reachable; all that is left is the existence check itself.
|
||||
|
||||
Cost: one stat per booth-shaped row per render of the standing board —
|
||||
178 of 221 rows today, on the ONE booth that carries a links.md.
|
||||
"""
|
||||
try:
|
||||
return (data_dir / name).is_dir()
|
||||
except OSError:
|
||||
return False
|
||||
|
||||
def _mark_redirect(name: str, form, anchor: str) -> RedirectResponse:
|
||||
"""Land where the form was: the standalone marks page for a verbatim
|
||||
booth (its own index.html cannot show the recorded judgment), else the
|
||||
@@ -1459,6 +1505,47 @@ def create_app(
|
||||
toggle_pin(resolve_booth(name), entry)
|
||||
return RedirectResponse(url=f"/b/{quote(name, safe='')}/", status_code=303)
|
||||
|
||||
@app.post("/b/{name}/bench-add")
|
||||
def bench_add(name: str, url: str = Form(...), bname: str = Form("", alias="name")):
|
||||
"""Register or update a bench by normalized URL.
|
||||
|
||||
A rejected URL must not 500 the page it was posted from. THE REJECTION
|
||||
IS SILENT HERE, and that is stated rather than dressed up: the form's
|
||||
`type="url"` catches the ordinary typo in the browser before the post,
|
||||
and this `except` is the last resort for what slips past it — the bench
|
||||
simply does not appear. Surfacing the reason would need a flash message,
|
||||
which this service has no mechanism for; inventing one for a path the
|
||||
browser already guards is not worth a unit's scope.
|
||||
|
||||
`resolve_booth` is called for its 404: a POST at a booth that does not
|
||||
exist is not a silent no-op.
|
||||
"""
|
||||
resolve_booth(name)
|
||||
try:
|
||||
upsert_bench(data_dir, url, bname, "operator")
|
||||
except (ValueError, OSError):
|
||||
pass
|
||||
return RedirectResponse(url=f"/b/{quote(name, safe='')}/", status_code=303)
|
||||
|
||||
@app.post("/b/{name}/bench-state")
|
||||
def bench_state(name: str, bench: str = Form(...), state: str = Form(...)):
|
||||
resolve_booth(name)
|
||||
if state in BENCH_STATES:
|
||||
try:
|
||||
set_bench_state(data_dir, bench, state)
|
||||
except (ValueError, OSError):
|
||||
pass
|
||||
return RedirectResponse(url=f"/b/{quote(name, safe='')}/", status_code=303)
|
||||
|
||||
@app.post("/b/{name}/bench-remove")
|
||||
def bench_remove(name: str, bench: str = Form(...)):
|
||||
resolve_booth(name)
|
||||
try:
|
||||
remove_bench(data_dir, bench)
|
||||
except (ValueError, OSError):
|
||||
pass
|
||||
return RedirectResponse(url=f"/b/{quote(name, safe='')}/", status_code=303)
|
||||
|
||||
def _safe_next(nxt: str) -> str:
|
||||
"""Where to land after keep/unkeep. Defaults to the index; a booth page
|
||||
can ask to stay put. Only same-site absolute paths are honoured — `//`
|
||||
|
||||
Reference in New Issue
Block a user