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:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user