feat(booth): per-row link removal + render the link board as real UI

The standing link board is the one MULTI-WRITER booth -- every agent session
appends operator-facing URLs to it. "Delete the folder" was the only
granularity available, so removing one dead link meant hand-editing markdown.
It is 32 rows and only grows.

  booth links                 row number, entry id, raw row
  booth unlink 3              by row number
  booth unlink 8b40e0a5       by entry id (what the UI's x posts)
  POST /b/<name>/unlink       form field `entry` = content id

ROWS ARE ADDRESSED BY CONTENT ID, NEVER BY POSITION. The board is append-only
and multi-writer: another session can post between listing it and clicking x,
and an index would then delete a neighbour. An id either matches the row you
saw or matches nothing. A row number typed at the CLI is resolved to its id
BEFORE anything is deleted. Appends and prunes now take the same flock on
.links.lock, so a post cannot be lost inside a prune's read-modify-write.

UI: a booth carrying links.md renders as rows -- description, URL, provenance,
copy button, per-row x -- instead of a markdown blob. links.md is filtered out
of the gallery so it does not appear twice; the header counts LINKS not files;
the empty-state and the one-click "Wipe now" both stand down for a board (same
rule as the kept lane: nothing durable is one click from gone).

booth/links.py extracted, STDLIB ONLY. The CLI needs this logic and must not
require the service venv -- importing app.py drags in FastAPI, so deleting a
line from a text file would have needed a web framework installed.

THREE BUGS FOUND BY TESTING, all in the shell wrapper while the module was
correct throughout -- module-only tests would have caught none of them:

- `[ "$n" -eq 0 ] && echo ...` as the LAST statement made `booth links` exit 1
  whenever the board had rows. `unlink`'s index lookup calls it inside $( )
  under `set -e`, so a successful listing killed the caller and the removal
  silently did nothing while reporting success.
- ids are 8 hex chars and roughly one in forty is ALL DIGITS; those were read
  as row numbers, resolved to nothing, and removed nothing. Now disambiguated
  by the id's actual shape, not by "is it numeric".
- filtering links.md out of the gallery left `items` empty, so a full board
  rendered "This booth is empty" and an empty <div class="gallery"> under 32
  visible rows.

87 tests (was 76): parser tolerance of hand-written prose, content-id
stability across concurrent appends, removal precision, UI branch behaviour
for board/normal/empty booths, and subprocess CLI tests pinning the two shell
bugs. Deployed to nh3-dev and verified against the live 32-row board
read-only; board file byte-identical afterwards.
This commit is contained in:
vh
2026-08-23 12:55:33 -07:00
parent d88f235688
commit 22ec06fcaa
7 changed files with 588 additions and 6 deletions
+45 -2
View File
@@ -23,6 +23,8 @@ State is the filesystem — `ls ~/booth-data` tells you everything. That is the
from __future__ import annotations
import asyncio
import fcntl
import hashlib
import io
import os
import re
@@ -34,7 +36,7 @@ from contextlib import asynccontextmanager
from pathlib import Path
from urllib.parse import quote
from fastapi import FastAPI, File, HTTPException, Request, UploadFile
from fastapi import FastAPI, File, Form, HTTPException, Request, UploadFile
from fastapi.responses import (
FileResponse,
HTMLResponse,
@@ -70,6 +72,17 @@ DOC_MAX_BYTES = 2 * 1024 * 1024 # above this, a doc is handed back raw, not ren
# state anywhere but the filesystem.
KEEP_MARKER = ".forever"
# The link-board logic lives in booth/links.py (stdlib only) so the `booth` CLI
# can use it without pulling FastAPI in. Re-exported here because call sites and
# tests already reference these names through app.
from booth.links import ( # noqa: E402
LINK_LOCK,
LINKS_FILE,
link_entry_id,
parse_link_entries,
remove_link_entry,
)
def doc_kind(name: str) -> str | None:
"""'markdown' | 'text' | None — a booth file viewable as a readable page."""
@@ -583,7 +596,21 @@ def create_app(
**base_ctx,
"name": name,
"name_url": quote(name, safe=""),
"items": build_gallery(booth),
# links.md is rendered AS the board below, so it must not also
# appear as a markdown doc tile — that would show the same
# content twice, once interactive and once not.
"items": [
it for it in build_gallery(booth)
if not ((booth / LINKS_FILE).is_file() and it["name"] == LINKS_FILE)
],
# A booth carrying links.md is the standing link board: render
# its rows as real UI (link, provenance, per-row remove) instead
# of a markdown blob you can only edit by hand. Empty list for
# every other booth, so the template branch simply does not fire.
"board": (
parse_link_entries((booth / LINKS_FILE).read_text())
if (booth / LINKS_FILE).is_file() else []
),
"uploaded": (booth / UPLOAD_MARKER).exists(),
"expires_in": max(0.0, ttl_seconds - booth_age_seconds(booth)),
},
@@ -693,6 +720,22 @@ def create_app(
# is what deletes. Anything relying on release-then-sweep is relying on a
# 24h delay it probably did not intend.
@app.post("/b/{name}/unlink")
def board_unlink(name: str, entry: str = Form(...)):
"""Remove ONE row from a link board, by content id.
Deliberately not by index: the board is append-only and multi-writer,
so between rendering the page and clicking × another session may have
posted. A content id either matches the row the operator saw or matches
nothing — it can never resolve to a neighbour.
"""
removed = remove_link_entry(resolve_booth(name), entry)
if removed is None:
# Already gone (double-click, stale tab, someone else pruned it).
# Not an error worth a 404 page — the desired end state holds.
pass
return RedirectResponse(url=f"/b/{quote(name, safe='')}/", status_code=303)
@app.post("/b/{name}/keep")
def booth_keep(name: str):
(resolve_booth(name) / KEEP_MARKER).touch()
+107
View File
@@ -0,0 +1,107 @@
"""Standing link board: parse and prune the multi-writer link log.
STDLIB ONLY, ON PURPOSE. This lives apart from app.py because the `booth` CLI
needs it and the CLI must not require the service's venv — importing app.py
drags in FastAPI, so a shell tool that only wants to delete a line would need
a web framework installed. The board is a text file; its logic should cost a
text file's worth of dependencies.
"""
from __future__ import annotations
import fcntl
import hashlib
import os
import re
from pathlib import Path
# ---- the standing link board ------------------------------------------------
#
# One booth (`links` by convention) is a MULTI-WRITER append log: every agent
# session on the fleet posts operator-facing URLs to it so they outlive the
# terminal scrollback that would otherwise bury them. That makes it the one
# booth where "delete the whole folder" is the wrong granularity — a single
# dead link has to be removable without taking the other thirty with it.
#
# Entries are identified by a CONTENT HASH, never by line number. Indexes are
# racy here by construction: another session can append between the moment you
# list the board and the moment you remove a row, and index-based removal would
# then delete the wrong line. A content id is stable against concurrent
# appends — the worst case is that the row is already gone, which is reported
# rather than silently deleting a neighbour.
LINKS_FILE = "links.md"
LINK_LOCK = ".links.lock"
# - [description](url) <sub>· who · when</sub>
_LINK_RE = re.compile(
r"^- \[(?P<desc>.*?)\]\((?P<url>[^)]*)\)"
r"(?:\s*<sub>·\s*(?P<who>[^·]*?)\s*·\s*(?P<when>[^<]*?)\s*</sub>)?\s*$"
)
def link_entry_id(raw: str) -> str:
"""Stable short id for a board row. Content-addressed, so it survives
concurrent appends by other sessions and cannot drift like an index."""
return hashlib.sha1(raw.strip().encode()).hexdigest()[:8]
def parse_link_entries(text: str) -> list[dict]:
"""Rows of the standing link board, newest last (posting order).
Non-matching lines (a heading someone added by hand, a blank) are skipped
rather than rejected: the board is a plain markdown file the operator is
explicitly allowed to edit, so the parser must tolerate prose around the
rows it understands.
"""
out: list[dict] = []
for i, raw in enumerate(text.splitlines()):
m = _LINK_RE.match(raw.strip())
if not m:
continue
out.append({
"id": link_entry_id(raw),
"raw": raw,
"line": i,
"desc": (m.group("desc") or "").strip(),
"url": (m.group("url") or "").strip(),
"who": (m.group("who") or "").strip(),
"when": (m.group("when") or "").strip(),
})
return out
def remove_link_entry(board: Path, entry_id: str) -> dict | None:
"""Remove one row by content id. Returns the removed entry, or None.
Held under an exclusive flock on a sidecar lock file for the whole
read-modify-write, and the CLI's append path takes the same lock — so a
concurrent `booth link` cannot be lost to this rewrite. Written to a temp
file and os.replace'd, so a crash mid-write cannot truncate the board.
"""
path = board / LINKS_FILE
if not path.exists():
return None
lock = board / LINK_LOCK
lock.touch(exist_ok=True)
with lock.open("r+") as lf:
fcntl.flock(lf, fcntl.LOCK_EX)
try:
text = path.read_text()
kept, removed = [], None
for raw in text.splitlines(keepends=True):
if removed is None and link_entry_id(raw) == entry_id:
m = _LINK_RE.match(raw.strip())
if m:
removed = {"id": entry_id, "raw": raw.rstrip("\n"),
"desc": (m.group("desc") or "").strip(),
"url": (m.group("url") or "").strip()}
continue
kept.append(raw)
if removed is None:
return None
tmp = path.with_suffix(path.suffix + ".tmp")
tmp.write_text("".join(kept))
os.replace(tmp, path)
return removed
finally:
fcntl.flock(lf, fcntl.LOCK_UN)
+36
View File
@@ -131,6 +131,42 @@
background:var(--rk-panel);color:var(--aus-blue)}
.release button:hover{background:var(--aus-blue);color:var(--fg-on-accent)}
/* ---- the standing link board ------------------------------------------
Rows, not a markdown blob. Dense enough that thirty entries stay
scannable, with provenance de-emphasised so the description leads and the
× only surfaces on hover — destructive controls should not compete for
attention with the thing you came to read. */
.board{border:1px solid var(--border-subtle);border-radius:.5rem;overflow:hidden;
background:var(--rk-panel);margin:.6rem 0 1rem}
.board-head{display:flex;align-items:baseline;gap:.6rem;padding:.5rem .75rem;
border-bottom:1px solid var(--border-subtle);background:var(--bg)}
.board-title{font-weight:600;font-size:.85rem}
.board-note{font-size:.72rem;opacity:.55}
.board-row{display:flex;align-items:center;gap:.6rem;padding:.45rem .75rem;
border-bottom:1px solid var(--border-subtle);transition:background .1s}
.board-row:last-child{border-bottom:0}
.board-row:hover{background:var(--bg)}
.board-main{flex:1 1 auto;min-width:0}
.board-link{font-size:.9rem;text-decoration:none;font-weight:500}
.board-link:hover{text-decoration:underline}
.board-url{font-size:.7rem;opacity:.45;overflow:hidden;text-overflow:ellipsis;
white-space:nowrap;font-family:ui-monospace,SFMono-Regular,Menlo,monospace}
.board-meta{flex:0 0 auto;display:flex;flex-direction:column;align-items:flex-end;
gap:.05rem;font-size:.68rem;opacity:.5;white-space:nowrap}
.board-who{font-weight:600}
.board-copy,.board-rm button{opacity:0;transition:opacity .12s;flex:0 0 auto}
.board-row:hover .board-copy,.board-row:hover .board-rm button,
.board-copy:focus,.board-rm button:focus{opacity:1}
.board-rm{flex:0 0 auto;margin:0}
.board-rm button{font:inherit;font-size:1rem;line-height:1;padding:.1rem .35rem;
border:0;background:none;cursor:pointer;color:var(--fg);border-radius:.25rem}
.board-rm button:hover{background:#c0392b;color:#fff}
@media (max-width:600px){
/* No hover on touch — controls must be permanently visible or unreachable. */
.board-copy,.board-rm button{opacity:1}
.board-meta{display:none}
}
.pickup-note{margin:-.5rem 0 1.5rem;padding:.6rem .85rem;border:1px solid var(--border-subtle);
border-left:3px solid var(--aus-bright-cyan);border-radius:var(--radius-md);background:var(--rk-well);
font-family:var(--font-mono);font-size:.78rem;color:var(--fg-2)}
+48 -3
View File
@@ -4,12 +4,17 @@
<div class="boothhead">
<a class="back" href="/">‹ all booths</a>
<h1>{{ name }}</h1>
<span class="sub">{% if uploaded %}<span class="badge">⬆ pickup</span> {% endif %}{{ items|length }} item{{ '' if items|length == 1 else 's' }} · expires in {{ expires_in|dur }}</span>
<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 %}{% else %}{{ items|length }} item{{ '' if items|length == 1 else 's' }} · expires in {{ expires_in|dur }}{% endif %}</span>
{% if items %}<a class="dl-link" href="/b/{{ name_url }}/?download=1" title="download this booth as a zip">⬇ zip</a>{% endif %}
{# A durable multi-writer board gets no one-click wipe — same rule as the
kept lane on the index. Remove rows with the per-row ×, or release the
board from the index and wipe it from there. #}
{% if not board %}
<form class="wipe wipe-lg" method="post" action="/b/{{ name_url }}/delete"
onsubmit="return confirm('Wipe this booth now?')">
<button>Wipe now</button>
</form>
{% endif %}
</div>
{% if uploaded %}
@@ -20,9 +25,49 @@
</div>
{% endif %}
{% if not items %}
{% if board %}
{# THE STANDING LINK BOARD. Every agent session on the fleet appends here, so
this is the one booth where the useful granularity is the ROW, not the
folder. Rendered as real UI rather than a markdown blob so a dead link can
be removed without hand-editing the file — and so provenance (who posted
it, when) is readable at a glance, which is the whole reason a bare URL
three days old is useless.
Removal posts a CONTENT ID, never a row number: another session can append
between this page rendering and the × being clicked, and an index would
then delete a neighbour. #}
<div class="board">
<div class="board-head">
<span class="board-title">{{ board|length }} link{{ '' if board|length == 1 else 's' }}</span>
<span class="board-note">newest last · appended by any session · × removes one row</span>
</div>
{% for e in board %}
<div class="board-row">
<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>
<div class="board-meta">
{% if e.who %}<span class="board-who">{{ e.who }}</span>{% endif %}
{% if e.when %}<span class="board-when">{{ e.when }}</span>{% endif %}
</div>
<button type="button" class="copy-btn board-copy" data-copy="{{ e.url }}" title="copy URL">⧉</button>
<form class="board-rm" method="post" action="/b/{{ name_url }}/unlink"
onsubmit="return confirm('Remove this link?\n\n{{ e.desc }}\n{{ e.url }}\n\nThe rest of the board is untouched.')">
<input type="hidden" name="entry" value="{{ e.id }}">
<button title="remove this link">×</button>
</form>
</div>
{% endfor %}
</div>
{% endif %}
{% if not items and not board %}
<div class="empty">This booth is empty.</div>
{% else %}
{% elif items %}
{# `elif items` and not a bare `else`: a board booth has NO gallery items (its
links.md is rendered as the board above and filtered out), so a plain else
would emit an empty <div class="gallery"> under the board. #}
<div class="gallery">
{% for it in items %}
{% if it.doc and it.rendered is not none %}