feat(booth): kept boards — a .forever sentinel and a standing link board

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 <url> [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.
This commit is contained in:
vh
2026-08-19 09:34:53 -07:00
parent 36a79ff453
commit 95228299bc
6 changed files with 335 additions and 12 deletions
+51 -2
View File
@@ -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")
+16 -1
View File
@@ -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 @@
<body>
<header class="topbar">
<a class="brand" href="/"><span class="dot"></span><span class="name">The&nbsp;Booth</span></a>
<span class="tagline">ephemeral media · auto-wipes {{ ttl_hours }}h</span>
<span class="tagline">ephemeral media · auto-wipes {{ ttl_hours }}h · kept boards don't</span>
</header>
<main>{% block content %}{% endblock %}</main>
<footer class="foot">
+38
View File
@@ -10,10 +10,48 @@
<button class="up-go" type="submit">Get pickup id →</button>
</form>
{% if kept %}
{# Kept boards render FIRST and look different on purpose: they are durable
operator-facing things (the agent link board, standing reports) and the
point of the lane is that they cannot be lost in a feed that turns over
every day. No countdown — they have no expiry to advertise. #}
<h2 class="lane-head">Kept <span class="lane-note">· no expiry · <code>{{ keep_marker }}</code></span></h2>
<div class="grid kept-grid">
{% for b in kept %}
<article class="card card-kept">
<a class="thumb" href="/b/{{ b.name_url }}/">
{% if b.thumb_url %}
<img loading="lazy" src="/b/{{ b.name_url }}/{{ b.thumb_url }}" alt="">
{% elif b.has_index %}
<div class="ph">▦ page</div>
{% elif b.kinds.video %}
<div class="ph">▶ video</div>
{% elif b.kinds.audio %}
<div class="ph">♪ audio</div>
{% else %}
<div class="ph">◆ files</div>
{% endif %}
<span class="badge badge-kept">★ kept</span>
</a>
<div class="meta">
<a class="name" href="/b/{{ b.name_url }}/">{{ b.name }}</a>
<div class="sub">{{ b.count }} item{{ '' if b.count == 1 else 's' }} · kept · <a class="dl-link" href="/b/{{ b.name_url }}/?download=1" title="download this booth as a zip">⬇ zip</a></div>
</div>
{# No × here. Wiping a kept board should be a deliberate act — remove the
sentinel first (it rejoins the sweep), or delete the folder by hand. A
one-click wipe next to the durable stuff is a footgun. #}
</article>
{% endfor %}
</div>
{% if booths %}<h2 class="lane-head">Ephemeral <span class="lane-note">· wiped {{ ttl_hours }}h after last activity</span></h2>{% endif %}
{% endif %}
{% if not booths %}
{% if not kept %}
<div class="empty">
No booths yet. Upload files above, or drop a folder into <code>{{ data_dir }}</code>.
</div>
{% endif %}
{% else %}
<div class="grid">
{% for b in booths %}