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:
@@ -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 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 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
|
||||
@@ -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>/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>/unlink` | Remove ONE row from a link board (form field `entry` = content id) |
|
||||
| `DELETE /b/<name>` | Wipe a booth (curl/API) |
|
||||
| `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
|
||||
|
||||
Kept boards have no × in the UI on purpose — a one-click wipe next to the
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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)
|
||||
@@ -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)}
|
||||
|
||||
@@ -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 %}
|
||||
|
||||
@@ -12,6 +12,8 @@
|
||||
# booth keep <name> exempt a booth from the 24h sweep, forever
|
||||
# booth unkeep <name> hand it back to the sweeper
|
||||
# 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
|
||||
# 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}"
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
@@ -110,9 +112,68 @@ case "$cmd" in
|
||||
# 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
|
||||
# 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' \
|
||||
"${desc:-$link_url}" "$link_url" "$who" "$when" >> "$board/links.md"
|
||||
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 ;;
|
||||
esac
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import os
|
||||
import pathlib
|
||||
import re
|
||||
import time
|
||||
|
||||
@@ -6,6 +7,10 @@ import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from booth.app import (
|
||||
LINKS_FILE,
|
||||
link_entry_id,
|
||||
parse_link_entries,
|
||||
remove_link_entry,
|
||||
booth_age_seconds,
|
||||
FAVICON_LINK,
|
||||
KEEP_MARKER,
|
||||
@@ -836,3 +841,252 @@ def test_index_without_kept_booths_omits_the_lane(client):
|
||||
# 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="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
|
||||
|
||||
Reference in New Issue
Block a user