From 6770ba26d60ca707bef8c7348e1883ae3e28818e Mon Sep 17 00:00:00 2001 From: Vuong Hoang Date: Wed, 19 Aug 2026 09:34:53 -0700 Subject: [PATCH] =?UTF-8?q?feat(booth):=20kept=20boards=20=E2=80=94=20a=20?= =?UTF-8?q?`.forever`=20sentinel=20and=20a=20standing=20link=20board?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Agent sessions hand the operator URLs and they drown in terminal scrollback. The Booth is the right home for them — it already has the one property that decides adoption, which is that a session can publish with mkdir and cp, no API key, no schema, no deploy — but everything in it dies in 24h. So: a booth containing `.forever` is never swept, and renders in its own Kept lane at the top of the index. Opt-in per booth, so the ephemeral default is untouched and nobody inherits a cleanup chore. `rm` the sentinel and the board rejoins the sweep; the CLI verbs are sugar over exactly that, which keeps the filesystem-is-the-state model honest. The pin is deliberately NOT wired into is_expired(). That stays a pure age question feeding the `expires_in` countdown; only sweep_once() honours the sentinel. Keeping expiry arithmetic and reaper policy apart means they cannot drift into each other. Kept cards are visually separated per Australis: a 2px top edge in aurora blue, the one accent border the system sanctions. They show "kept" instead of a countdown, and they deliberately lose the one-click wipe button — a × next to the durable stuff is a footgun, so removing a kept board is a two-step act. `booth link [description]` appends to the standing `links` board, creating and keeping it on first use. Entries carry provenance (handle or hostname, plus a timestamp) because a bare URL is unreadable three days later. The append is one printf of one line to an O_APPEND fd — atomic under PIPE_BUF on POSIX — which matters because many agents post to one board and interleaved half-lines would be the obvious failure mode. Seven tests cover the sentinel: detection, survival of a sweep that wipes its neighbour, the deliberate is_expired/sweep_once split, the listing flag, the sentinel not inflating item counts, and both lane-rendering directions. Two of them originally asserted on the bare strings "Kept" and "kept-grid", which passed for the wrong reason — those also appear in the inlined stylesheet served on every page — so they now assert the full class attribute. 55 pass. Also corrects the Homepage card's description, which advertised a flat 24h TTL that is no longer the whole story. --- services/booth/README.md | 44 ++++++++ services/booth/booth/app.py | 53 +++++++++- services/booth/booth/templates/base.html | 17 ++- services/booth/booth/templates/index.html | 38 +++++++ services/booth/scripts/booth | 73 +++++++++++-- services/booth/tests/test_booth.py | 122 ++++++++++++++++++++++ stacks/homepage/conf/services.yaml | 2 +- 7 files changed, 336 insertions(+), 13 deletions(-) diff --git a/services/booth/README.md b/services/booth/README.md index 048bc30..e0ef2c6 100644 --- a/services/booth/README.md +++ b/services/booth/README.md @@ -39,6 +39,50 @@ rsync -a ./out/ nh3-dev:booth-data/my-run/ Then hand the operator `http://10.100.10.50:8090/b/my-run/`. +## Kept boards — the one exception to the 24h rule + +A booth containing a **`.forever`** dotfile is **never swept**, and renders in +its own **Kept** lane at the top of the index (blue top edge, `★ kept` badge, no +countdown, no one-click wipe). Everything else is unchanged: the default is +still ephemeral, so nobody inherits a cleanup chore they didn't ask for. + +```bash +booth keep my-board # drop the sentinel — exempt from the sweep, forever +booth unkeep my-board # remove it — the board rejoins the sweep +``` + +It is just a file, so the manual forms work identically and are the honest +mental model: + +```bash +touch ~/booth-data/my-board/.forever # keep +rm ~/booth-data/my-board/.forever # unkeep +rm -rf ~/booth-data/my-board # delete outright, whenever you like +``` + +**Why this exists:** agent sessions hand the operator URLs — a booth of renders, +a PR, a dashboard — and they drown in terminal scrollback. Kept boards are where +those go instead. + +### The standing link board + +```bash +booth link [description] +``` + +Appends one line to the **`links`** board (`$BOOTH_LINKS_BOARD`, default +`links`), creating it and marking it kept on first use. Each entry carries +provenance — who posted it and when — because a bare URL is unreadable three +days later. `links.md` renders as a readable page in the booth. + +The append is a single `printf` of a single line to an `O_APPEND` fd, which is +atomic under `PIPE_BUF` on POSIX. That matters here specifically: many agents +post to one board, and interleaved half-lines would be the obvious failure. + +Deliberately **not** a database. The board is a markdown file — editable with +any editor, greppable, and trivially prunable by hand, which is the whole point +of the Booth's filesystem-is-the-state model. + ## Upload for pickup The reverse direction — put files in through the web, pick them up by id: diff --git a/services/booth/booth/app.py b/services/booth/booth/app.py index d450387..85b176f 100644 --- a/services/booth/booth/app.py +++ b/services/booth/booth/app.py @@ -10,6 +10,12 @@ Model (deliberately dead-simple, no database): * 24h TTL: a background sweeper wipes any booth untouched for TTL hours. A booth's age is measured from the *newest* mtime in its tree, so it lives while it's being worked on and self-destructs TTL hours after the last activity. + * KEPT BOOTHS: a booth containing the KEEP_MARKER dotfile (`.forever`) is exempt + from the sweep and renders in its own lane above the ephemeral grid. That is the + home for durable operator-facing boards — chiefly the standing link board agent + sessions post to, whose whole purpose is to survive longer than the scrollback + it replaces. Opt-in per booth, so the ephemeral default is unchanged and nobody + inherits a cleanup chore; `rm` the sentinel and the booth rejoins the sweep. State is the filesystem — `ls ~/booth-data` tells you everything. That is the whole point. """ @@ -57,6 +63,13 @@ MARKDOWN_EXTS = {".md", ".markdown", ".mdown"} TEXT_EXTS = {".txt", ".text", ".log"} DOC_MAX_BYTES = 2 * 1024 * 1024 # above this, a doc is handed back raw, not rendered +# Sentinel dotfile that exempts a booth from the TTL sweep — see the "kept +# booths" note in the module docstring. A dotfile because the existing listing +# code already skips dotfiles, so it costs nothing in item counts or galleries, +# and because `touch`/`rm` is the entire user interface: no flag to remember, no +# state anywhere but the filesystem. +KEEP_MARKER = ".forever" + def doc_kind(name: str) -> str | None: """'markdown' | 'text' | None — a booth file viewable as a readable page.""" @@ -135,14 +148,30 @@ def booth_age_seconds(path: Path, now: float | None = None) -> float: def is_expired(path: Path, ttl_seconds: float, now: float | None = None) -> bool: + """Pure age question. Deliberately does NOT consider the keep sentinel. + + Expiry arithmetic (what `expires_in` renders) and reaper policy (what + actually gets deleted) are kept apart so they cannot drift into each other. + Only `sweep_once` honours the pin. + """ return booth_age_seconds(path, now) > ttl_seconds +def is_kept(path: Path) -> bool: + """True if this booth carries the keep sentinel and must never be swept.""" + return (path / KEEP_MARKER).exists() + + def sweep_once(data_dir: Path, ttl_seconds: float, now: float | None = None) -> list[str]: """Wipe every direct-child booth older than the TTL. Returns names wiped. Only ever removes direct children of data_dir (never data_dir itself), and skips dotfolders so a stray control dir can opt out. + + A booth carrying KEEP_MARKER is exempt no matter how stale it is. That is + the one escape hatch from the 24h contract, and it is opt-in per booth: the + default stays ephemeral, so nobody inherits a cleanup chore they did not ask + for. Removing the sentinel hands the booth straight back to the sweeper. """ wiped: list[str] = [] if not data_dir.is_dir(): @@ -151,6 +180,8 @@ def sweep_once(data_dir: Path, ttl_seconds: float, now: float | None = None) -> if not child.is_dir() or child.name.startswith("."): continue try: + if is_kept(child): + continue if is_expired(child, ttl_seconds, now): shutil.rmtree(child) wiped.append(child.name) @@ -185,6 +216,7 @@ def list_booths(data_dir: Path, ttl_seconds: float, now: float | None = None) -> "thumb_url": thumb_url, "has_index": (child / "index.html").is_file(), "uploaded": (child / UPLOAD_MARKER).exists(), + "kept": is_kept(child), "expires_in": max(0.0, ttl_seconds - (now - mtime)), "mtime": mtime, } @@ -454,7 +486,12 @@ def create_app( app = FastAPI(title="The Booth", lifespan=lifespan) ttl_display = int(ttl_hours) if float(ttl_hours).is_integer() else ttl_hours - base_ctx = {"ttl_hours": ttl_display, "host": host_label, "data_dir": str(data_dir)} + base_ctx = { + "ttl_hours": ttl_display, + "host": host_label, + "data_dir": str(data_dir), + "keep_marker": KEEP_MARKER, # shown in the kept lane so the mechanism is discoverable + } def resolve_booth(name: str) -> Path: if not name or name.startswith(".") or "/" in name or "\\" in name or ".." in name: @@ -471,8 +508,20 @@ def create_app( @app.get("/", response_class=HTMLResponse) def index(request: Request): + # Two lanes, split here rather than in the template: kept boards are a + # different KIND of thing from the ephemeral churn — durable, deliberate, + # operator-facing — and burying them in a feed that turns over daily is + # exactly how they would get lost, which is the problem they exist to + # solve. Kept renders first. + everything = list_booths(data_dir, ttl_seconds) return templates.TemplateResponse( - request, "index.html", {**base_ctx, "booths": list_booths(data_dir, ttl_seconds)} + request, + "index.html", + { + **base_ctx, + "kept": [b for b in everything if b["kept"]], + "booths": [b for b in everything if not b["kept"]], + }, ) @app.get("/healthz") diff --git a/services/booth/booth/templates/base.html b/services/booth/booth/templates/base.html index db0ef30..c2e099a 100644 --- a/services/booth/booth/templates/base.html +++ b/services/booth/booth/templates/base.html @@ -108,6 +108,21 @@ .card .thumb{position:relative} .thumb .badge{position:absolute;top:.5rem;left:.5rem;box-shadow:var(--shadow-2)} + /* Kept lane. The accent is a 2px TOP edge in aurora blue — the one accent + border Australis sanctions (never a coloured left border), and it marks the + card as featured without changing its fill, so kept and ephemeral still + read as the same family of object. */ + .lane-head{margin:1.9rem 0 .8rem;font-family:var(--font-mono);font-size:.68rem;font-weight:600; + letter-spacing:.14em;text-transform:uppercase;color:var(--aus-bright-cyan); + display:flex;align-items:center;gap:.7rem} + .lane-head::after{content:"";flex:1;height:1px;background:var(--border-subtle)} + .lane-note{font-weight:400;letter-spacing:.06em;color:var(--fg-3);text-transform:none} + .lane-note code{font-size:.95em;color:var(--fg-2)} + .kept-grid{margin-bottom:.4rem} + .card-kept{border-top:2px solid var(--aus-blue)} + .card-kept:hover{border-color:var(--aus-blue);border-top-color:var(--aus-bright-blue)} + .badge-kept{background:var(--aus-blue);color:var(--fg-on-accent)} + .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)} @@ -202,7 +217,7 @@
The Booth - ephemeral media · auto-wipes {{ ttl_hours }}h + ephemeral media · auto-wipes {{ ttl_hours }}h · kept boards don't
{% block content %}{% endblock %}