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:
@@ -0,0 +1,341 @@
|
||||
"""Benches: a running thing, registered.
|
||||
|
||||
A bench is NOT a booth and NOT a bookmark. It is a durable middle-to-long-term
|
||||
testing surface — jackdaw's current bench, talk's current bench, the things that
|
||||
get promoted to Homepage when they are fully deployed. The standing link board
|
||||
absorbed the job because it was the only surface on offer, and an O_APPEND log
|
||||
with no identity turns "here is the bench again" into a fifth row rather than an
|
||||
update: `talk` is on the board five times and Peedlar's root three.
|
||||
|
||||
STDLIB ONLY, AND SIBLING-FREE, ON PURPOSE. `scripts/booth` imports this through
|
||||
a `python3 -c` heredoc under the system python3 with no venv, exactly as it
|
||||
imports `marks`, `asks`, `links` and `manifest`. A third-party import breaks
|
||||
`booth bench` on every fleet host; a `from booth.links import ...` breaks it on
|
||||
any host where both modules are not importable together, which is a second way
|
||||
for the same invariant to fall. `tests/test_benches.py` forbids both.
|
||||
|
||||
SINGLE-WRITER, MANY-READER — the opposite shape from `links.md`. The board is a
|
||||
multi-writer append log because seventeen agent handles post to it at once. This
|
||||
is the operator in one browser plus occasional CLI calls, so it is one file,
|
||||
rewritten whole under a lock, replaced atomically. Inheriting the append-log
|
||||
design here would be the mistake CLAUDE.md names by name.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import fcntl
|
||||
import json
|
||||
import os
|
||||
from dataclasses import dataclass, replace
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from typing import Iterable
|
||||
from urllib.parse import urlsplit, urlunsplit
|
||||
|
||||
# At the DATA ROOT, not inside a booth. A dotfile there is invisible to
|
||||
# `list_booths` and to `sweep_once` — both skip a child that is not a directory
|
||||
# AND a child whose name starts with a dot, so the registry fails two guards
|
||||
# rather than one. Verified against both functions (seam review SR-4, SR-5)
|
||||
# rather than assumed: had either guard been absent, the sweeper would have
|
||||
# eaten this file on its first tick.
|
||||
BENCHES_FILE = ".benches.json"
|
||||
BENCH_LOCK = ".benches.lock"
|
||||
|
||||
# live → promoted (to Homepage) → retired. Order is meaningful: it is the
|
||||
# first key of the rendered order, so a retired bench sinks.
|
||||
BENCH_STATES = ("live", "promoted", "retired")
|
||||
_STATE_RANK = {s: i for i, s in enumerate(BENCH_STATES)}
|
||||
|
||||
# Display budgets, not storage limits — these land in a panel row.
|
||||
NAME_MAX, OWNER_MAX, URL_MAX = 120, 64, 2048
|
||||
|
||||
# The read is on the render path, so it is bounded. 256 KiB holds thousands of
|
||||
# benches; the live board has 43 non-booth rows total.
|
||||
BENCHES_MAX_BYTES = 256 * 1024
|
||||
|
||||
_SCHEMES = ("http", "https")
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Bench:
|
||||
"""One registered bench.
|
||||
|
||||
`id` and `url` are two fields ON PURPOSE. The identity must be normalized so
|
||||
that re-posting updates rather than appends; the href must be verbatim so a
|
||||
server that cares about a trailing slash, a case-sensitive path or a query
|
||||
still works when the operator clicks it. Collapsing them would make the
|
||||
registry quietly change where a link goes — a bug that surfaces as "the
|
||||
bench 404s" and is never traced back here.
|
||||
"""
|
||||
|
||||
id: str # the normalized URL — identity, and the key on disk
|
||||
url: str # the URL as posted — what a click goes to
|
||||
name: str
|
||||
owner: str # an althing handle, or "booth" for the service
|
||||
state: str
|
||||
added: str # ISO-8601 with offset, from the FIRST registration
|
||||
updated: str # ISO-8601 with offset, from the most recent upsert
|
||||
error: str | None = None # a read-time verdict; never stored
|
||||
|
||||
|
||||
def normalize_bench_url(url: str) -> str:
|
||||
"""The identity of a bench. Raises ValueError with a reason a human can act on.
|
||||
|
||||
THE RULE, in full, because a vague identity is worse than a wrong one:
|
||||
|
||||
* surrounding whitespace stripped
|
||||
* scheme lowercased; anything but http/https refused
|
||||
* userinfo (`user:pass@host`) REFUSED, never stripped
|
||||
* host lowercased; an empty host refused
|
||||
* port dropped when it is the scheme default (80 http, 443 https)
|
||||
* path kept verbatim, except that a bare "/" becomes ""
|
||||
* query kept verbatim INCLUDING parameter order (a query is opaque)
|
||||
* fragment dropped
|
||||
|
||||
WHY THE FULL URL AND NOT THE ORIGIN — measured, not chosen. Collapsing the
|
||||
live board's 43 non-booth rows by origin yields 19 groups; by full URL, 35.
|
||||
The difference is not duplication: it is eight distinct gitea repositories
|
||||
merged into one row, three unrelated HuggingFace model cards merged into
|
||||
one, and the two LRPG surfaces on `10.100.10.50:8321` merged into one —
|
||||
which are the information-architecture doc's own example of two real
|
||||
benches. Origin identity destroys more than it deduplicates. Full-URL
|
||||
identity still collapses both cases that doc names: talk 5 → 1, Peedlar 3 → 1.
|
||||
|
||||
WHY THE QUERY IS IN AND THE FRAGMENT IS OUT. Three ShutterChute rows on the
|
||||
board differ only by `?token=`; they are three genuinely different one-shot
|
||||
links, and dropping the query would merge them into a bench that is none of
|
||||
them. A fragment is a position inside a page, never a different resource.
|
||||
"""
|
||||
raw = (url or "").strip()
|
||||
if not raw:
|
||||
raise ValueError("a bench needs a URL")
|
||||
if len(raw) > URL_MAX:
|
||||
raise ValueError(f"URL is longer than {URL_MAX} characters")
|
||||
try:
|
||||
parts = urlsplit(raw)
|
||||
except ValueError as exc: # malformed IPv6 literal, etc.
|
||||
raise ValueError(f"could not parse that URL: {exc}") from exc
|
||||
|
||||
scheme = parts.scheme.lower()
|
||||
if scheme not in _SCHEMES:
|
||||
raise ValueError(
|
||||
f"a bench must be http or https, not {parts.scheme or '(no scheme)'}"
|
||||
)
|
||||
if "@" in parts.netloc:
|
||||
# Refused, NOT stripped. Stripping would register a bench whose URL no
|
||||
# longer works while telling the poster it succeeded — and would put a
|
||||
# credential on a board that renders on an unauthenticated LAN surface
|
||||
# on the way there.
|
||||
raise ValueError("a bench URL must not carry credentials; strip the user:pass@ and re-post")
|
||||
try:
|
||||
host = (parts.hostname or "").lower()
|
||||
port = parts.port
|
||||
except ValueError as exc: # a non-numeric port
|
||||
raise ValueError(f"could not read the host or port: {exc}") from exc
|
||||
if not host:
|
||||
raise ValueError("that URL has no host")
|
||||
|
||||
default = {"http": 80, "https": 443}[scheme]
|
||||
netloc = host if port in (None, default) else f"{host}:{port}"
|
||||
# A bare "/" is the same resource as no path at all; a trailing slash on a
|
||||
# REAL path is not, and is left alone.
|
||||
path = "" if parts.path == "/" else parts.path
|
||||
return urlunsplit((scheme, netloc, path, parts.query, ""))
|
||||
|
||||
|
||||
# ---- storage ----------------------------------------------------------------
|
||||
|
||||
|
||||
def _now() -> str:
|
||||
return datetime.now(timezone.utc).isoformat(timespec="seconds")
|
||||
|
||||
|
||||
def _cap(value: object, limit: int, field: str) -> str:
|
||||
if not isinstance(value, str):
|
||||
raise ValueError(f"{field} must be text, not {type(value).__name__}")
|
||||
return value[:limit]
|
||||
|
||||
|
||||
def _bench_from(bench_id: str, row: object) -> Bench:
|
||||
"""One stored row to a record. Raises ValueError on any shape it cannot
|
||||
trust — this is the STRICT half, used by the write path and by the read
|
||||
path's single try/except."""
|
||||
if not isinstance(row, dict):
|
||||
raise ValueError(f"{bench_id}: expected an object, found {type(row).__name__}")
|
||||
state = row.get("state", "live")
|
||||
if state not in BENCH_STATES:
|
||||
raise ValueError(f"{bench_id}: unknown state {state!r}")
|
||||
return Bench(
|
||||
id=bench_id,
|
||||
url=_cap(row.get("url", bench_id), URL_MAX, "url"),
|
||||
name=_cap(row.get("name", ""), NAME_MAX, "name"),
|
||||
owner=_cap(row.get("owner", ""), OWNER_MAX, "owner"),
|
||||
state=state,
|
||||
added=_cap(row.get("added", ""), 64, "added"),
|
||||
updated=_cap(row.get("updated", ""), 64, "updated"),
|
||||
)
|
||||
|
||||
|
||||
def _read_bytes(path: Path) -> bytes:
|
||||
"""Read at most BENCHES_MAX_BYTES + 1 bytes.
|
||||
|
||||
BOUNDS THE READ, NEVER THE STAT. A FIFO reports st_size 0 and then blocks
|
||||
forever; a size cap that trusts `st_size` inherits a meaning it does not
|
||||
have, and the 2026-09-22 incident in this repo was exactly that — a bound
|
||||
that opened a service-wide hang. Reading one byte past the cap is how you
|
||||
learn you are over it without reading the rest.
|
||||
"""
|
||||
with path.open("rb") as fh:
|
||||
return fh.read(BENCHES_MAX_BYTES + 1)
|
||||
|
||||
|
||||
def _load_strict(root: Path) -> dict[str, Bench]:
|
||||
"""Every bench, or ValueError. The write path's reader.
|
||||
|
||||
Whole-file, not per-row: a registry with one unreadable row is a registry
|
||||
somebody has to look at, and quietly dropping the row is how a bench
|
||||
disappears without anyone being told.
|
||||
"""
|
||||
path = Path(root) / BENCHES_FILE
|
||||
if not path.exists():
|
||||
return {}
|
||||
blob = _read_bytes(path)
|
||||
if len(blob) > BENCHES_MAX_BYTES:
|
||||
raise ValueError(f"registry is larger than {BENCHES_MAX_BYTES} bytes")
|
||||
try:
|
||||
raw = json.loads(blob.decode("utf-8"))
|
||||
except (UnicodeDecodeError, json.JSONDecodeError) as exc:
|
||||
raise ValueError(f"registry is not valid JSON: {exc}") from exc
|
||||
if not isinstance(raw, dict):
|
||||
raise ValueError(f"registry must be an object keyed by URL, found {type(raw).__name__}")
|
||||
return {k: _bench_from(k, v) for k, v in raw.items()}
|
||||
|
||||
|
||||
def read_benches(root: Path) -> tuple[list[Bench], str | None]:
|
||||
"""Every registered bench in the rendered order, plus a read-time error.
|
||||
|
||||
NEVER RAISES. This runs on the render path, and the v0.2.2 lesson in this
|
||||
repo was learned the expensive way: a poisoned `.marks.json` returned 500
|
||||
for `/` and `/healthz` across all 25 booths. A registry that cannot be read
|
||||
costs its own panel, never the page.
|
||||
|
||||
ABSENT AND DAMAGED ARE DIFFERENT and must render differently — only one of
|
||||
them needs a human. Absent is `([], None)`; damaged is `([], "why")`.
|
||||
"""
|
||||
try:
|
||||
return order_benches(_load_strict(root).values()), None
|
||||
except ValueError as exc:
|
||||
return [], str(exc)
|
||||
except OSError as exc:
|
||||
return [], f"registry could not be read: {exc}"
|
||||
|
||||
|
||||
def _write_all(root: Path, benches: dict[str, Bench]) -> None:
|
||||
"""Atomic replace. Caller holds the lock.
|
||||
|
||||
Temp file + os.replace, so a reader never sees a partial file and a crash
|
||||
mid-write cannot truncate the registry into a shorter — and therefore
|
||||
quieter — set of benches. CLAUDE.md invariant 5.
|
||||
"""
|
||||
root = Path(root)
|
||||
path = root / BENCHES_FILE
|
||||
payload = {
|
||||
b.id: {"url": b.url, "name": b.name, "owner": b.owner,
|
||||
"state": b.state, "added": b.added, "updated": b.updated}
|
||||
# The key IS the id, so the record does not carry it twice — two copies
|
||||
# of one fact is two things that can disagree.
|
||||
for b in benches.values()
|
||||
}
|
||||
tmp = path.with_suffix(path.suffix + f".tmp.{os.getpid()}")
|
||||
tmp.write_text(json.dumps(payload, indent=2, sort_keys=True) + "\n")
|
||||
os.replace(tmp, path)
|
||||
|
||||
|
||||
class _Locked:
|
||||
"""Exclusive flock over the whole read-modify-write, on a sidecar."""
|
||||
|
||||
def __init__(self, root: Path):
|
||||
self.root = Path(root)
|
||||
self.root.mkdir(parents=True, exist_ok=True)
|
||||
self.path = self.root / BENCH_LOCK
|
||||
|
||||
def __enter__(self):
|
||||
self.path.touch(exist_ok=True)
|
||||
self.fh = self.path.open("r+")
|
||||
fcntl.flock(self.fh, fcntl.LOCK_EX)
|
||||
return self
|
||||
|
||||
def __exit__(self, *exc):
|
||||
fcntl.flock(self.fh, fcntl.LOCK_UN)
|
||||
self.fh.close()
|
||||
return False
|
||||
|
||||
|
||||
def upsert_bench(root: Path, url: str, name: str, owner: str) -> tuple[Bench, bool]:
|
||||
"""Register or update by normalized URL. Returns (bench, created).
|
||||
|
||||
READS ARE LENIENT, WRITES ARE STRICT — and this is the strict side. A write
|
||||
over a registry that cannot be parsed RAISES rather than starting a fresh
|
||||
one: on 2026-09-21 this repo learned that a tolerant writer over a damaged
|
||||
`.marks.json` wipes the operator's judgment, and a tolerant reader is a
|
||||
completely different decision from a tolerant writer.
|
||||
|
||||
`added` survives an update; `state` survives too, so a promoted bench that
|
||||
re-announces itself after a deploy is not silently demoted.
|
||||
"""
|
||||
bench_id = normalize_bench_url(url)
|
||||
with _Locked(root):
|
||||
benches = _load_strict(root) # raises on damaged — deliberate
|
||||
prior = benches.get(bench_id)
|
||||
now = _now()
|
||||
bench = Bench(
|
||||
id=bench_id,
|
||||
url=(url or "").strip(),
|
||||
name=_cap(name or "", NAME_MAX, "name"),
|
||||
owner=_cap(owner or "", OWNER_MAX, "owner"),
|
||||
state=prior.state if prior else "live",
|
||||
added=prior.added if prior else now,
|
||||
updated=now,
|
||||
)
|
||||
benches[bench_id] = bench
|
||||
_write_all(root, benches)
|
||||
return bench, prior is None
|
||||
|
||||
|
||||
def set_bench_state(root: Path, bench_id: str, state: str) -> Bench | None:
|
||||
"""Move a bench between live / promoted / retired. None if no such bench."""
|
||||
if state not in BENCH_STATES:
|
||||
raise ValueError(f"state must be one of {', '.join(BENCH_STATES)}, not {state!r}")
|
||||
with _Locked(root):
|
||||
benches = _load_strict(root)
|
||||
prior = benches.get(bench_id)
|
||||
if prior is None:
|
||||
return None
|
||||
moved = replace(prior, state=state, updated=_now())
|
||||
benches[bench_id] = moved
|
||||
_write_all(root, benches)
|
||||
return moved
|
||||
|
||||
|
||||
def remove_bench(root: Path, bench_id: str) -> Bench | None:
|
||||
"""Drop one bench. Returns the removed record, or None."""
|
||||
with _Locked(root):
|
||||
benches = _load_strict(root)
|
||||
gone = benches.pop(bench_id, None)
|
||||
if gone is None:
|
||||
return None
|
||||
_write_all(root, benches)
|
||||
return gone
|
||||
|
||||
|
||||
def order_benches(benches: Iterable[Bench]) -> list[Bench]:
|
||||
"""ORDER: (state rank, name casefolded, id).
|
||||
|
||||
live before promoted before retired, then alphabetical, with the id as a
|
||||
TOTAL tie-break so two benches sharing a name cannot swap between renders.
|
||||
CLAUDE.md invariant 6 — the Booth's job is comparison, and an order that
|
||||
moves between page loads files the operator's judgment against the wrong
|
||||
row. Pure: no I/O, and the input sequence is not mutated.
|
||||
"""
|
||||
return sorted(benches, key=lambda b: (_STATE_RANK.get(b.state, len(BENCH_STATES)),
|
||||
b.name.casefold(), b.id))
|
||||
Reference in New Issue
Block a user