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 — `//`
|
||||
|
||||
@@ -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))
|
||||
@@ -14,6 +14,7 @@ import hashlib
|
||||
import os
|
||||
import re
|
||||
from pathlib import Path
|
||||
from urllib.parse import unquote, urlsplit
|
||||
|
||||
# ---- the standing link board ------------------------------------------------
|
||||
#
|
||||
@@ -194,3 +195,52 @@ def order_for_display(entries: list[dict], pinned: set[str]) -> list[dict]:
|
||||
stamped = [{**e, "pinned": e["id"] in pinned} for e in entries]
|
||||
stamped.reverse() # newest first
|
||||
return [e for e in stamped if e["pinned"]] + [e for e in stamped if not e["pinned"]]
|
||||
|
||||
|
||||
# ---- what counts as a booth link -------------------------------------------
|
||||
|
||||
|
||||
def booth_target(url: str) -> str | None:
|
||||
"""The booth NAME a URL points at, or None when it is not a booth link.
|
||||
|
||||
ONE PREDICATE, THREE CALLERS — the CLI's `link` refusal, the board's
|
||||
dead-row marker, and `bench import`'s classifier. They must agree: a rule
|
||||
that refuses a shape the board then fails to mark as dead (or the reverse)
|
||||
is two readers of one truth, which is the bug this repo has now paid for
|
||||
three times. `tests/test_benches.py` runs one table through every caller.
|
||||
|
||||
HOST-AGNOSTIC AND PATH-SHAPED. A row is a booth link when its path is
|
||||
`/b/<name>` or `/b/<name>/...`, whatever the host. NOT a host allowlist: the
|
||||
fleet reaches this service as `10.100.10.50:8090`, `localhost:8090` and
|
||||
`nh3-dev.nh3.internal:8090`, and an allowlist would silently fail to refuse
|
||||
from whichever name somebody used next — a rule that fails OPEN on the exact
|
||||
case it exists to catch. The accepted cost is that a third-party URL with a
|
||||
`/b/<x>` path reads as a booth link; that failure is visible (a refusal
|
||||
naming the reason) rather than silent, and no such URL is on the board.
|
||||
|
||||
THE NAME SEGMENT IS PERCENT-DECODED. `app.py` emits booth links through
|
||||
`quote(name, safe="")`, so a booth whose name needs encoding appears on the
|
||||
board encoded. Comparing the raw segment against a directory name would mark
|
||||
every such booth permanently dead and echo the encoded form back at the
|
||||
poster in the refusal message.
|
||||
|
||||
The returned name passes the SAME addressability rules `resolve_booth`
|
||||
enforces (non-empty, no leading dot, no separator, no `..`), so the two
|
||||
cannot disagree about what is reachable.
|
||||
|
||||
NEVER RAISES. A board row is arbitrary operator-editable text; a predicate
|
||||
that raises on one row takes the whole page.
|
||||
"""
|
||||
try:
|
||||
parts = urlsplit((url or "").strip())
|
||||
if parts.scheme.lower() not in ("http", "https"):
|
||||
return None
|
||||
segments = parts.path.split("/")
|
||||
if len(segments) < 3 or segments[1] != "b":
|
||||
return None
|
||||
name = unquote(segments[2])
|
||||
except (ValueError, UnicodeDecodeError):
|
||||
return None
|
||||
if not name or name.startswith(".") or "/" in name or "\\" in name or ".." in name:
|
||||
return None
|
||||
return name
|
||||
|
||||
@@ -496,7 +496,30 @@
|
||||
.markdown-body table{border-collapse:collapse;display:block;overflow-x:auto}
|
||||
.markdown-body th,.markdown-body td{border:1px solid var(--rk-line,#252a35);padding:.4em .7em}
|
||||
.markdown-body img{max-width:100%}
|
||||
</style>
|
||||
|
||||
/* U6 — the bench registry, on the standing board's page only. */
|
||||
.benches{margin:1rem 0;border:1px solid var(--line,#2a2a2a);border-radius:6px;overflow:hidden}
|
||||
.bench-head{display:flex;gap:.6rem;align-items:baseline;padding:.5rem .7rem;background:rgba(255,255,255,.03)}
|
||||
.bench-title{font-weight:600}
|
||||
.bench-note,.bench-empty{opacity:.6;font-size:.85em}
|
||||
.bench-empty{padding:.6rem .7rem}
|
||||
.bench-err{padding:.6rem .7rem;color:#f2b8b5;background:rgba(242,184,181,.08)}
|
||||
.bench-row{display:flex;gap:.6rem;align-items:center;padding:.45rem .7rem;border-top:1px solid var(--line,#2a2a2a)}
|
||||
.bench-row.is-retired{opacity:.5}
|
||||
.bench-state{font-size:.7em;text-transform:uppercase;letter-spacing:.06em;padding:.1rem .4rem;border-radius:3px;background:rgba(255,255,255,.08)}
|
||||
.bench-row.is-live .bench-state{background:rgba(120,200,140,.18)}
|
||||
.bench-row.is-promoted .bench-state{background:rgba(130,170,240,.18)}
|
||||
.bench-main{flex:1;min-width:0}
|
||||
.bench-url{font-size:.78em;opacity:.55;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
|
||||
.bench-acts{display:flex;gap:.3rem}
|
||||
.bench-to,.bench-rm{font-size:.75em;padding:.15rem .4rem;cursor:pointer}
|
||||
.bench-add{display:flex;gap:.4rem;padding:.5rem .7rem;border-top:1px solid var(--line,#2a2a2a)}
|
||||
.bench-add input[type=url]{flex:2;min-width:0}
|
||||
.bench-add input[type=text]{flex:1;min-width:0}
|
||||
/* A board row whose booth has been swept. Marked, never auto-removed. */
|
||||
.board-row.board-dead{opacity:.45}
|
||||
.board-dead-tag{font-size:.9em;color:#f2b8b5;opacity:.9}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<header class="topbar">
|
||||
|
||||
@@ -66,7 +66,64 @@
|
||||
{% else %}
|
||||
<h1>{{ name }}</h1>
|
||||
{% endif %}
|
||||
<span class="sub">{% if uploaded %}<span class="badge">⬆ pickup</span> {% endif %}{% if board %}{{ board|length }} link{{ '' if board|length == 1 else 's' }}{% if items %} · {{ items|length }} file{{ '' if items|length == 1 else 's' }}{% endif %} · {{ lifetime(kept, hold, expires_in) }}{% else %}{% if marks_open %}<span class="badge badge-mark">{{ marks_open }} open</span> · {% endif %}{{ items|length }} item{{ '' if items|length == 1 else 's' }} · {{ lifetime(kept, hold, expires_in) }}{% endif %}</span>
|
||||
<span class="sub">{% if uploaded %}<span class="badge">⬆ pickup</span> {% endif %}{% if board or benches or benches_error %}
|
||||
{# THE BENCH REGISTRY. A bench is a running thing — jackdaw's current bench,
|
||||
talk's current bench, the things that get promoted to Homepage when they
|
||||
are fully deployed. NOT a booth (a booth announces itself and is swept) and
|
||||
NOT a bookmark (a repo page, a model card — those stay on the board below).
|
||||
|
||||
Identity is the NORMALIZED URL, so re-announcing a bench updates its row
|
||||
instead of appending a fifth. `talk` was on the board five times.
|
||||
|
||||
ORDER: state (live → promoted → retired), then name, then id as a total
|
||||
tie-break so two benches sharing a name cannot swap between renders.
|
||||
|
||||
The href is `b.url` — the URL AS POSTED — never `b.id`. The id is
|
||||
normalized for identity; a server that cares about a trailing slash or a
|
||||
case-sensitive path would 404 on it. #}
|
||||
<div class="benches">
|
||||
<div class="bench-head">
|
||||
<span class="bench-title">{{ benches|length }} bench{{ '' if benches|length == 1 else 'es' }}</span>
|
||||
<span class="bench-note">a running thing, registered · re-posting updates the row</span>
|
||||
</div>
|
||||
{% if benches_error %}
|
||||
{# DAMAGED AND ABSENT MUST NOT RENDER THE SAME. Only one of them needs a
|
||||
human, and the v0.2.2 outage was learned by treating them alike. #}
|
||||
<div class="bench-err">the bench registry could not be read: {{ benches_error }}</div>
|
||||
{% elif not benches %}
|
||||
<div class="bench-empty">no benches registered yet — <code>booth bench add <url> <name></code></div>
|
||||
{% endif %}
|
||||
{% for b in benches %}
|
||||
<div class="bench-row is-{{ b.state }}">
|
||||
<span class="bench-state">{{ b.state }}</span>
|
||||
<div class="bench-main">
|
||||
<a class="bench-link" href="{{ b.url }}" target="_blank" rel="noopener">{{ b.name or b.url }}</a>
|
||||
<div class="bench-url">{{ b.url }}</div>
|
||||
</div>
|
||||
<div class="bench-meta">
|
||||
{% if b.owner %}<span class="bench-who">{{ b.owner }}</span>{% endif %}
|
||||
</div>
|
||||
<form class="bench-acts" method="post" action="/b/{{ name_url }}/bench-state">
|
||||
<input type="hidden" name="bench" value="{{ b.id }}">
|
||||
{% for s in ("live", "promoted", "retired") %}
|
||||
{% if s != b.state %}
|
||||
<button type="submit" name="state" value="{{ s }}" class="bench-to">{{ s }}</button>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
<button type="submit" class="bench-rm" formaction="/b/{{ name_url }}/bench-remove"
|
||||
title="remove this bench">×</button>
|
||||
</form>
|
||||
</div>
|
||||
{% endfor %}
|
||||
<form class="bench-add" method="post" action="/b/{{ name_url }}/bench-add">
|
||||
<input type="url" name="url" placeholder="https://host:port/" required>
|
||||
<input type="text" name="name" placeholder="what it is">
|
||||
<button type="submit">register</button>
|
||||
</form>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if board %}{{ board|length }} link{{ '' if board|length == 1 else 's' }}{% if items %} · {{ items|length }} file{{ '' if items|length == 1 else 's' }}{% endif %} · {{ lifetime(kept, hold, expires_in) }}{% else %}{% if marks_open %}<span class="badge badge-mark">{{ marks_open }} open</span> · {% endif %}{{ items|length }} item{{ '' if items|length == 1 else 's' }} · {{ lifetime(kept, hold, expires_in) }}{% endif %}</span>
|
||||
{% if items %}<a class="dl-link" href="/b/{{ name_url }}/?download=1" title="download this booth as a zip">⬇ zip</a>{% endif %}
|
||||
{{ provenance(manifest) }}
|
||||
{# A durable multi-writer board gets no one-click wipe — same rule as the
|
||||
@@ -143,14 +200,17 @@
|
||||
formaction="/b/{{ name_url }}/unlink-many">🗑 delete <span id="board-selcount">0</span></button>
|
||||
</div>
|
||||
{% for e in board %}
|
||||
<div class="board-row{% if e.pinned %} is-pinned{% endif %}">
|
||||
{# DEAD: the row points at a booth that has been swept. 156 of 221 rows.
|
||||
MARKED, never removed — removal is the operator ticking the box and using
|
||||
the bulk control that was already here. #}
|
||||
<div class="board-row{% if e.pinned %} is-pinned{% endif %}{% if e.dead %} board-dead{% endif %}">
|
||||
<input class="board-check" type="checkbox" name="sel" value="{{ e.id }}" aria-label="select {{ e.desc }}">
|
||||
<button type="submit" class="board-pin{% if e.pinned %} on{% endif %}" formaction="/b/{{ name_url }}/pin"
|
||||
name="entry" value="{{ e.id }}" aria-pressed="{{ 'true' if e.pinned else 'false' }}"
|
||||
title="{{ 'unpin' if e.pinned else 'pin to top' }}">{{ '★' if e.pinned else '☆' }}</button>
|
||||
<div class="board-main">
|
||||
<a class="board-link" href="{{ e.url }}" target="_blank" rel="noopener">{{ e.desc }}</a>
|
||||
<div class="board-url">{{ e.url }}</div>
|
||||
<div class="board-url">{{ e.url }}{% if e.dead %} <span class="board-dead-tag">booth is gone</span>{% endif %}</div>
|
||||
</div>
|
||||
<div class="board-meta">
|
||||
{% if e.who %}<span class="board-who">{{ e.who }}</span>{% endif %}
|
||||
|
||||
Reference in New Issue
Block a user