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 23cccf5f53
commit 6770ba26d6
7 changed files with 336 additions and 13 deletions
+122
View File
@@ -7,6 +7,7 @@ from fastapi.testclient import TestClient
from booth.app import (
FAVICON_LINK,
KEEP_MARKER,
build_gallery,
classify,
create_app,
@@ -14,6 +15,8 @@ from booth.app import (
generate_pickup_id,
human_dur,
is_expired,
is_kept,
list_booths,
render_doc,
safe_upload_name,
sweep_once,
@@ -528,3 +531,122 @@ def test_view_single_image_no_nav(client):
assert r.status_code == 200
# no arrow anchors with a single image (the .vnav CSS rule is always present)
assert 'class="vnav vprev"' not in r.text and 'class="vnav vnext"' not in r.text
# ---- kept booths: the `.forever` sentinel -----------------------------------
#
# The Booth's whole contract is "wiped 24h after last activity". A kept booth is
# the deliberate exception: an operator-facing board (agent-posted links, a
# standing report) that must outlive the sweep and stay separated from the
# ephemeral traffic so it does not get lost in it.
def _stale(path, seconds=10_000):
"""Age a booth and everything in it well past any test TTL."""
t = time.time() - seconds
for p in sorted(path.rglob("*"), reverse=True):
os.utime(p, (t, t))
os.utime(path, (t, t))
def test_is_kept_detects_the_sentinel(tmp_path):
plain = tmp_path / "plain"
plain.mkdir()
kept = tmp_path / "kept"
_touch(kept / KEEP_MARKER)
assert not is_kept(plain)
assert is_kept(kept)
def test_kept_booth_survives_the_sweep(tmp_path):
"""The point of the whole feature: expiry does not apply to a kept booth."""
doomed = tmp_path / "doomed"
_touch(doomed / "a.png")
_stale(doomed)
kept = tmp_path / "links"
_touch(kept / "a.png")
_touch(kept / KEEP_MARKER)
_stale(kept)
wiped = sweep_once(tmp_path, ttl_seconds=3600)
assert wiped == ["doomed"]
assert not doomed.exists()
assert kept.exists(), "a booth carrying the sentinel must never be swept"
def test_kept_booth_is_still_reported_expired_by_age(tmp_path):
"""is_expired stays a pure age question; only the sweeper honours the pin.
Keeping these separate means `expires_in` arithmetic and the reaper policy
cannot drift into each other.
"""
kept = tmp_path / "links"
_touch(kept / KEEP_MARKER)
_stale(kept)
assert is_expired(kept, ttl_seconds=3600)
assert sweep_once(tmp_path, ttl_seconds=3600) == []
def test_list_booths_flags_kept(tmp_path):
_touch(tmp_path / "ephemeral" / "a.png")
_touch(tmp_path / "links" / "a.png")
_touch(tmp_path / "links" / KEEP_MARKER)
by_name = {b["name"]: b for b in list_booths(tmp_path, ttl_seconds=3600)}
assert by_name["ephemeral"]["kept"] is False
assert by_name["links"]["kept"] is True
def test_sentinel_is_not_counted_as_an_item(tmp_path):
"""It is a dotfile, so it must not inflate the item count or become a tile."""
_touch(tmp_path / "links" / "a.png")
_touch(tmp_path / "links" / KEEP_MARKER)
booth = next(b for b in list_booths(tmp_path, ttl_seconds=3600) if b["name"] == "links")
assert booth["count"] == 1
def test_index_separates_kept_from_ephemeral(client):
c, data = client
_touch(data / "scratch" / "a.png")
_touch(data / "links" / "a.png")
_touch(data / "links" / KEEP_MARKER)
html = c.get("/").text
# Assert on the lane's markup, not on the word "Kept" — that string also
# appears in the stylesheet comment that is served on every page, so a bare
# substring check passes for the wrong reason.
assert 'class="grid kept-grid"' in html, "kept booths need their own lane"
assert 'class="card card-kept"' in html
# The kept lane is rendered before the ephemeral grid, so the operator sees
# durable boards first rather than hunting for them among the churn.
assert html.index("links") < html.index("scratch")
def test_kept_booth_shows_kept_instead_of_a_countdown(client):
c, data = client
_touch(data / "links" / "a.png")
_touch(data / "links" / KEEP_MARKER)
html = c.get("/").text
assert "expires in" not in html, "a kept booth has no expiry to advertise"
def test_index_without_kept_booths_omits_the_lane(client):
c, data = client
_touch(data / "scratch" / "a.png")
html = c.get("/").text
# The full attribute form, because the bare class names also appear in the
# inlined stylesheet that ships on every page.
assert 'class="grid kept-grid"' not in html, "the lane must not render when nothing is kept"
assert 'class="card card-kept"' not in html