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:
+45
-2
@@ -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
@@ -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 %}
|
||||
|
||||
Reference in New Issue
Block a user