diff --git a/services/booth/README.md b/services/booth/README.md index d058734..c3dcb19 100644 --- a/services/booth/README.md +++ b/services/booth/README.md @@ -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//delete` | Wipe a booth (the UI's "Wipe now" button) | | `POST /b//keep` | Pin a booth — exempt from the sweep | | `POST /b//unkeep` | Release the pin (the UI's "release" button on kept cards) | +| `POST /b//unlink` | Remove ONE row from a link board (form field `entry` = content id) | | `DELETE /b/` | 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 diff --git a/services/booth/booth/app.py b/services/booth/booth/app.py index 1a63617..314b727 100644 --- a/services/booth/booth/app.py +++ b/services/booth/booth/app.py @@ -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() diff --git a/services/booth/booth/links.py b/services/booth/booth/links.py new file mode 100644 index 0000000..0cbfe2e --- /dev/null +++ b/services/booth/booth/links.py @@ -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) · who · when +_LINK_RE = re.compile( + r"^- \[(?P.*?)\]\((?P[^)]*)\)" + r"(?:\s*·\s*(?P[^·]*?)\s*·\s*(?P[^<]*?)\s*)?\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) diff --git a/services/booth/booth/templates/base.html b/services/booth/booth/templates/base.html index 03157f6..cf299a3 100644 --- a/services/booth/booth/templates/base.html +++ b/services/booth/booth/templates/base.html @@ -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)} diff --git a/services/booth/booth/templates/booth.html b/services/booth/booth/templates/booth.html index ebfb242..39e9ad8 100644 --- a/services/booth/booth/templates/booth.html +++ b/services/booth/booth/templates/booth.html @@ -4,12 +4,17 @@
‹ all booths

{{ name }}

- {% if uploaded %}⬆ pickup {% endif %}{{ items|length }} item{{ '' if items|length == 1 else 's' }} · expires in {{ expires_in|dur }} + {% if uploaded %}⬆ pickup {% 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 %} {% if items %}⬇ zip{% 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 %}
+ {% endif %}
{% if uploaded %} @@ -20,9 +25,49 @@ {% 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. #} +
+
+ {{ board|length }} link{{ '' if board|length == 1 else 's' }} + newest last · appended by any session · × removes one row +
+ {% for e in board %} +
+
+ {{ e.desc }} +
{{ e.url }}
+
+
+ {% if e.who %}{{ e.who }}{% endif %} + {% if e.when %}{{ e.when }}{% endif %} +
+ +
+ + +
+
+ {% endfor %} +
+{% endif %} + +{% if not items and not board %}
This booth is empty.
-{% 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