0ad332bb4a
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.
108 lines
4.2 KiB
Python
108 lines
4.2 KiB
Python
"""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)
|