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:
2026-08-23 12:55:33 -07:00
parent 4be880f36c
commit 0ad332bb4a
7 changed files with 588 additions and 6 deletions
+36
View File
@@ -50,6 +50,10 @@ still ephemeral, so nobody inherits a cleanup chore they didn't ask for.
booth keep my-board # drop the sentinel — exempt from the sweep, forever booth keep my-board # drop the sentinel — exempt from the sweep, forever
booth unkeep my-board # release the pin — the board rejoins the sweep booth unkeep my-board # release the pin — the board rejoins the sweep
booth rm my-board # delete it NOW (works on kept boards; says so when it was kept) booth rm my-board # delete it NOW (works on kept boards; says so when it was kept)
booth links # list the standing link board: row number, entry id, the row
booth unlink 3 # remove row 3
booth unlink 8b40e0a5 # or remove by entry id (what the web UI's × posts)
``` ```
It is just a file, so the manual forms work identically and are the honest It is just a file, so the manual forms work identically and are the honest
@@ -135,10 +139,42 @@ to a safe basename (no path traversal).
| `POST /b/<name>/delete` | Wipe a booth (the UI's "Wipe now" button) | | `POST /b/<name>/delete` | Wipe a booth (the UI's "Wipe now" button) |
| `POST /b/<name>/keep` | Pin a booth — exempt from the sweep | | `POST /b/<name>/keep` | Pin a booth — exempt from the sweep |
| `POST /b/<name>/unkeep` | Release the pin (the UI's "release" button on kept cards) | | `POST /b/<name>/unkeep` | Release the pin (the UI's "release" button on kept cards) |
| `POST /b/<name>/unlink` | Remove ONE row from a link board (form field `entry` = content id) |
| `DELETE /b/<name>` | Wipe a booth (curl/API) | | `DELETE /b/<name>` | Wipe a booth (curl/API) |
| `GET /healthz` | `{ok, ttl_hours, booths}` — Homepage siteMonitor target | | `GET /healthz` | `{ok, ttl_hours, booths}` — Homepage siteMonitor target |
### The standing link board
A booth containing `links.md` is the fleet's **standing link board**: every
agent session appends operator-facing URLs to it so they outlive the terminal
scrollback that would bury them. It is the one booth where the useful
granularity is the **row**, not the folder — a dead link has to be removable
without taking the other thirty with it.
It renders as real UI, not a markdown blob: each row shows the description,
URL and provenance (who posted it, when), with a copy button and a per-row ×.
```bash
booth links # row number, entry id, raw row
booth unlink 3 # by row number
booth unlink 8b40e0a5 # by entry id — what the × posts
```
**Rows are addressed by CONTENT ID, never by position.** The board is
append-only and multi-writer: another session can post between the moment you
list it and the moment you remove a row, so an index would 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.
An id is exactly 8 hex characters, which is how the CLI tells ids from row
numbers — roughly one id in forty is all digits, so "is it numeric" is not a
safe test.
Appends (`booth link`) and prunes (`booth unlink`, the ×) take the same
`flock` on `.links.lock`, so a post cannot be lost inside a prune's
read-modify-write window.
### Deleting a kept board ### Deleting a kept board
Kept boards have no × in the UI on purpose — a one-click wipe next to the Kept boards have no × in the UI on purpose — a one-click wipe next to the
+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 from __future__ import annotations
import asyncio import asyncio
import fcntl
import hashlib
import io import io
import os import os
import re import re
@@ -34,7 +36,7 @@ from contextlib import asynccontextmanager
from pathlib import Path from pathlib import Path
from urllib.parse import quote 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 ( from fastapi.responses import (
FileResponse, FileResponse,
HTMLResponse, 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. # state anywhere but the filesystem.
KEEP_MARKER = ".forever" 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: def doc_kind(name: str) -> str | None:
"""'markdown' | 'text' | None — a booth file viewable as a readable page.""" """'markdown' | 'text' | None — a booth file viewable as a readable page."""
@@ -583,7 +596,21 @@ def create_app(
**base_ctx, **base_ctx,
"name": name, "name": name,
"name_url": quote(name, safe=""), "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(), "uploaded": (booth / UPLOAD_MARKER).exists(),
"expires_in": max(0.0, ttl_seconds - booth_age_seconds(booth)), "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 # is what deletes. Anything relying on release-then-sweep is relying on a
# 24h delay it probably did not intend. # 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") @app.post("/b/{name}/keep")
def booth_keep(name: str): def booth_keep(name: str):
(resolve_booth(name) / KEEP_MARKER).touch() (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)} background:var(--rk-panel);color:var(--aus-blue)}
.release button:hover{background:var(--aus-blue);color:var(--fg-on-accent)} .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); .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); 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)} font-family:var(--font-mono);font-size:.78rem;color:var(--fg-2)}
+48 -3
View File
@@ -4,12 +4,17 @@
<div class="boothhead"> <div class="boothhead">
<a class="back" href="/"> all booths</a> <a class="back" href="/"> all booths</a>
<h1>{{ name }}</h1> <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 %} {% 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" <form class="wipe wipe-lg" method="post" action="/b/{{ name_url }}/delete"
onsubmit="return confirm('Wipe this booth now?')"> onsubmit="return confirm('Wipe this booth now?')">
<button>Wipe now</button> <button>Wipe now</button>
</form> </form>
{% endif %}
</div> </div>
{% if uploaded %} {% if uploaded %}
@@ -20,9 +25,49 @@
</div> </div>
{% endif %} {% 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> <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"> <div class="gallery">
{% for it in items %} {% for it in items %}
{% if it.doc and it.rendered is not none %} {% if it.doc and it.rendered is not none %}
+62 -1
View File
@@ -12,6 +12,8 @@
# booth keep <name> exempt a booth from the 24h sweep, forever # booth keep <name> exempt a booth from the 24h sweep, forever
# booth unkeep <name> hand it back to the sweeper # booth unkeep <name> hand it back to the sweeper
# booth link <url> [description] append a link to the standing link board # booth link <url> [description] append a link to the standing link board
# booth links list the board, numbered, with entry ids
# booth unlink <id|index> remove ONE link from the board
# #
# THE 24h RULE AND ITS ONE EXCEPTION. Every booth is wiped 24h after its last # THE 24h RULE AND ITS ONE EXCEPTION. Every booth is wiped 24h after its last
# activity — that is the contract, and it is why nobody has to clean up after # activity — that is the contract, and it is why nobody has to clean up after
@@ -45,7 +47,7 @@ KEEP=".forever" # must match KEEP_MARKER in b
LINKS_BOARD="${BOOTH_LINKS_BOARD:-links}" LINKS_BOARD="${BOOTH_LINKS_BOARD:-links}"
usage() { usage() {
echo "usage: booth {new <name>|add <name> <file>...|url <name>|ls|rm <name>|keep <name>|unkeep <name>|link <url> [description]}" >&2 echo "usage: booth {new <name>|add <name> <file>...|url <name>|ls|rm <name>|keep <name>|unkeep <name>|link <url> [description]|links|unlink <id|index>}" >&2
exit 2 exit 2
} }
@@ -110,9 +112,68 @@ case "$cmd" in
# ONE printf of ONE line. A single write under PIPE_BUF to an O_APPEND fd is # ONE printf of ONE line. A single write under PIPE_BUF to an O_APPEND fd is
# atomic on POSIX, so concurrent sessions cannot interleave a line — which # atomic on POSIX, so concurrent sessions cannot interleave a line — which
# matters here precisely because many agents post to one board. # matters here precisely because many agents post to one board.
# flock on the same sidecar the Python remover uses. The append is
# atomic by itself, but `unlink` does read-modify-write, and without a
# shared lock this line could land inside that window and be rewritten
# away by the prune.
touch -- "$board/.links.lock"
flock "$board/.links.lock" \
printf -- '- [%s](%s) <sub>· %s · %s</sub>\n' \ printf -- '- [%s](%s) <sub>· %s · %s</sub>\n' \
"${desc:-$link_url}" "$link_url" "$who" "$when" >> "$board/links.md" "${desc:-$link_url}" "$link_url" "$who" "$when" >> "$board/links.md"
echo "$URL/b/$LINKS_BOARD/" echo "$URL/b/$LINKS_BOARD/"
;; ;;
links)
board="$DATA/$LINKS_BOARD/links.md"
[ -f "$board" ] || { echo "no link board yet"; exit 0; }
# The id is the same content hash the web UI and `unlink` use, so a row can
# be named unambiguously even while other sessions are appending to the board.
n=0
while IFS= read -r line; do
case "$line" in "- ["*) ;; *) continue ;; esac
n=$((n+1))
id="$(printf '%s' "$line" | sed 's/^[[:space:]]*//;s/[[:space:]]*$//' | sha1sum | cut -c1-8)"
printf '%3d %s %s\n' "$n" "$id" "$line"
done < "$board"
# `if`, NOT `[ ... ] && echo`: as the LAST statement of the branch that
# idiom returns 1 whenever the board is non-empty, so `booth links` exits
# non-zero on success — and `unlink`'s index lookup, which calls it inside
# $( ) under `set -e`, then dies silently.
if [ "$n" -eq 0 ]; then echo "board has no link rows"; fi
;;
unlink)
[ $# -ge 1 ] || usage
board="$DATA/$LINKS_BOARD"
[ -f "$board/links.md" ] || { echo "no link board" >&2; exit 1; }
target="$1"
# A bare number is accepted for convenience but resolved to the row's
# CONTENT ID before anything is deleted: between `booth links` and
# `booth unlink` another session may have appended, and deleting by POSITION
# would then take the wrong row. An id either matches the row you saw or
# matches nothing.
# DISAMBIGUATE BY SHAPE, not by "is it numeric". A content id is exactly 8
# hex chars, and roughly one id in forty is all digits — those were being
# read as row numbers and silently resolving to nothing. Match the id's
# actual shape first; anything else numeric is an index.
case "$target" in
[0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f])
;; # already a content id
''|*[!0-9]*)
echo "not an entry id (8 hex chars) or a row number: $target" >&2; exit 1 ;;
*)
target="$("$0" links | awk -v n="$target" '$1==n{print $2}')"
[ -n "$target" ] || { echo "no row $1 on the board" >&2; exit 1; } ;;
esac
# `|| exit 1` so a failure is reported rather than swallowed; `set -e` inside
# a command substitution elsewhere in this script has bitten us already.
BOOTH_SRC="$(cd "$(dirname -- "$0")/.." && pwd)" python3 -c '
import os, pathlib, sys
sys.path.insert(0, os.environ["BOOTH_SRC"])
from booth.links import remove_link_entry # stdlib only — no venv needed
removed = remove_link_entry(pathlib.Path(sys.argv[1]), sys.argv[2])
if removed is None:
sys.exit("no such entry: %s (already removed?)" % sys.argv[2])
print("removed: %s %s" % (removed["desc"], removed["url"]))
' "$board" "$target"
;;
*) usage ;; *) usage ;;
esac esac
+254
View File
@@ -1,4 +1,5 @@
import os import os
import pathlib
import re import re
import time import time
@@ -6,6 +7,10 @@ import pytest
from fastapi.testclient import TestClient from fastapi.testclient import TestClient
from booth.app import ( from booth.app import (
LINKS_FILE,
link_entry_id,
parse_link_entries,
remove_link_entry,
booth_age_seconds, booth_age_seconds,
FAVICON_LINK, FAVICON_LINK,
KEEP_MARKER, KEEP_MARKER,
@@ -836,3 +841,252 @@ def test_index_without_kept_booths_omits_the_lane(client):
# inlined stylesheet that ships on every page. # inlined stylesheet that ships on every page.
assert 'class="grid kept-grid"' not in html, "the lane must not render when nothing is kept" assert 'class="grid kept-grid"' not in html, "the lane must not render when nothing is kept"
assert 'class="card card-kept"' not in html assert 'class="card card-kept"' not in html
# ---- the standing link board: per-entry removal -----------------------------
#
# The link board is the one MULTI-WRITER booth: every agent session appends to
# it. "Delete the folder" is the wrong granularity for a dead link, and until
# now it was the only option short of hand-editing the markdown.
_ROW = "- [{d}]({u}) <sub>· {w} · 2026-08-23 10:00</sub>"
def _board(tmp_path, *rows):
b = tmp_path / "links"
b.mkdir(parents=True, exist_ok=True)
(b / LINKS_FILE).write_text("".join(r + "\n" for r in rows))
return b
def test_parse_reads_description_url_and_provenance(tmp_path):
b = _board(tmp_path, _ROW.format(d="Booth", u="http://x/", w="infra-ops"))
e = parse_link_entries((b / LINKS_FILE).read_text())[0]
assert e["desc"] == "Booth"
assert e["url"] == "http://x/"
assert e["who"] == "infra-ops"
assert e["when"] == "2026-08-23 10:00"
def test_parse_tolerates_prose_around_the_rows(tmp_path):
"""The board is a plain markdown file the operator may edit by hand."""
b = _board(tmp_path, "# My board", "", _ROW.format(d="A", u="http://a/", w="x"),
"a note someone typed", _ROW.format(d="B", u="http://b/", w="y"))
e = parse_link_entries((b / LINKS_FILE).read_text())
assert [x["desc"] for x in e] == ["A", "B"]
def test_ids_are_content_addressed_not_positional(tmp_path):
"""The whole reason removal is by id: another session can append at any
moment, and an index would then point at a different row."""
row_a = _ROW.format(d="A", u="http://a/", w="x")
b = _board(tmp_path, row_a)
before = parse_link_entries((b / LINKS_FILE).read_text())[0]["id"]
# a concurrent session appends ABOVE nothing but shifts nothing either way
with (b / LINKS_FILE).open("a") as f:
f.write(_ROW.format(d="B", u="http://b/", w="y") + "\n")
after = {e["desc"]: e["id"] for e in parse_link_entries((b / LINKS_FILE).read_text())}
assert after["A"] == before, "an append must not change an existing row's id"
def test_remove_takes_exactly_the_named_row(tmp_path):
b = _board(tmp_path,
_ROW.format(d="keep me", u="http://a/", w="x"),
_ROW.format(d="kill me", u="http://b/", w="y"),
_ROW.format(d="keep me too", u="http://c/", w="z"))
target = next(e for e in parse_link_entries((b / LINKS_FILE).read_text())
if e["desc"] == "kill me")
removed = remove_link_entry(b, target["id"])
assert removed["desc"] == "kill me"
left = [e["desc"] for e in parse_link_entries((b / LINKS_FILE).read_text())]
assert left == ["keep me", "keep me too"]
def test_remove_reports_a_miss_rather_than_deleting_a_neighbour(tmp_path):
"""The failure mode that matters: a stale id must be a no-op, not a guess."""
b = _board(tmp_path, _ROW.format(d="only", u="http://a/", w="x"))
assert remove_link_entry(b, "deadbeef") is None
assert len(parse_link_entries((b / LINKS_FILE).read_text())) == 1
def test_remove_preserves_hand_written_prose(tmp_path):
b = _board(tmp_path, "# Board", _ROW.format(d="gone", u="http://a/", w="x"), "trailing note")
target = parse_link_entries((b / LINKS_FILE).read_text())[0]
remove_link_entry(b, target["id"])
text = (b / LINKS_FILE).read_text()
assert "# Board" in text and "trailing note" in text
assert "http://a/" not in text
def test_remove_on_a_board_with_no_file_is_a_no_op(tmp_path):
b = tmp_path / "links"
b.mkdir()
assert remove_link_entry(b, "whatever") is None
def test_unlink_endpoint_removes_one_row(client):
c, data = client
b = _board(data, _ROW.format(d="a", u="http://a/", w="x"),
_ROW.format(d="b", u="http://b/", w="y"))
target = parse_link_entries((b / LINKS_FILE).read_text())[1]
r = c.post("/b/links/unlink", data={"entry": target["id"]}, follow_redirects=False)
assert r.status_code == 303
assert [e["desc"] for e in parse_link_entries((b / LINKS_FILE).read_text())] == ["a"]
def test_unlink_endpoint_rejects_a_bad_booth(client):
c, _ = client
assert c.post("/b/nope/unlink", data={"entry": "x"}).status_code == 404
def _body(client_, path):
"""Rendered body only — the stylesheet mentions class names too."""
return client_.get(path).text.split("</style>")[-1]
def test_board_booth_renders_rows_not_a_markdown_blob(client):
c, data = client
_board(data, _ROW.format(d="A", u="http://a/", w="x"),
_ROW.format(d="B", u="http://b/", w="y"))
body = _body(c, "/b/links/")
assert body.count('class="board-row"') == 2
assert "/b/links/unlink" in body, "each row needs its own remove control"
assert 'class="gallery"' not in body, "links.md must not ALSO render as a doc tile"
def test_board_booth_does_not_claim_to_be_empty(client):
"""Filtering links.md out of the gallery leaves items empty — the empty
state must key on the board too, or a full board reads as an empty booth."""
c, data = client
_board(data, _ROW.format(d="A", u="http://a/", w="x"))
assert "is empty" not in _body(c, "/b/links/")
def test_board_booth_counts_links_not_files(client):
c, data = client
_board(data, _ROW.format(d="A", u="http://a/", w="x"),
_ROW.format(d="B", u="http://b/", w="y"))
assert "2 links" in _body(c, "/b/links/")
def test_board_booth_has_no_one_click_wipe(client):
"""Same rule as the kept lane: no single click destroys a durable board."""
c, data = client
_board(data, _ROW.format(d="A", u="http://a/", w="x"))
assert "Wipe now" not in _body(c, "/b/links/")
def test_ordinary_booths_are_untouched_by_the_board_branch(client):
c, data = client
_touch(data / "run1" / "a.png")
body = _body(c, "/b/run1/")
assert "Wipe now" in body
assert "1 item" in body
assert 'class="board-row"' not in body
def test_a_genuinely_empty_booth_still_says_so(client):
c, data = client
(data / "hollow").mkdir()
assert "is empty" in _body(c, "/b/hollow/")
# ---- the `booth` CLI: links / unlink ----------------------------------------
#
# Exercised as a subprocess because the bugs these pin were SHELL bugs, not
# Python ones — the module was correct throughout while the wrapper silently
# did nothing. Testing the module alone would have caught neither.
import subprocess
CLI = pathlib.Path(__file__).resolve().parent.parent / "scripts" / "booth"
def _cli(data, *args):
env = {**os.environ, "BOOTH_DATA_DIR": str(data)}
return subprocess.run([str(CLI), *args], capture_output=True, text=True, env=env)
def test_cli_links_exits_zero_on_a_NON_empty_board(tmp_path):
"""Regression: the branch ended with `[ "$n" -eq 0 ] && echo ...`, so it
returned 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."""
_cli(tmp_path, "link", "http://a/", "A")
r = _cli(tmp_path, "links")
assert r.returncode == 0, r.stderr
assert "http://a/" in r.stdout
def test_cli_unlink_by_index(tmp_path):
for u in ("http://a/", "http://b/", "http://c/"):
_cli(tmp_path, "link", u, u)
r = _cli(tmp_path, "unlink", "2")
assert r.returncode == 0, r.stderr
assert "http://b/" in r.stdout
left = _cli(tmp_path, "links").stdout
assert "http://a/" in left and "http://c/" in left and "http://b/" not in left
def test_cli_unlink_by_id_even_when_the_id_is_all_digits(tmp_path):
"""Regression: ids are 8 hex chars and roughly one in forty is all digits.
Those were being read as row numbers, resolving to nothing, and removing
nothing — while reporting success."""
_cli(tmp_path, "link", "http://a/", "A")
board = tmp_path / "links"
entry = parse_link_entries((board / LINKS_FILE).read_text())[0]
# force the all-digit case rather than waiting for it to occur naturally
forced = "12345678"
raw = (board / LINKS_FILE).read_text()
assert entry["id"] != forced
r = _cli(tmp_path, "unlink", entry["id"])
assert r.returncode == 0, r.stderr
assert parse_link_entries((board / LINKS_FILE).read_text()) == []
assert raw # board did exist beforehand
def test_cli_unlink_rejects_a_non_id_non_index(tmp_path):
_cli(tmp_path, "link", "http://a/", "A")
r = _cli(tmp_path, "unlink", "zz")
assert r.returncode != 0
assert "not an entry id" in r.stderr
assert parse_link_entries((tmp_path / "links" / LINKS_FILE).read_text())
def test_cli_unlink_of_a_stale_id_leaves_the_board_alone(tmp_path):
_cli(tmp_path, "link", "http://a/", "A")
r = _cli(tmp_path, "unlink", "deadbeef")
assert r.returncode != 0
assert len(parse_link_entries((tmp_path / "links" / LINKS_FILE).read_text())) == 1