feat(booth): kept boards can be deleted from the UI; document the TTL-reset trap

Kept boards had no delete path in the UI at all. The kept lane deliberately
omits the wipe control -- a one-click wipe next to the durable stuff is a
footgun -- but "deliberate" had been implemented as "impossible": the only
routes out were ssh or a hand-written API call.

Now it is two deliberate acts. A `release` control on kept cards drops the
sentinel, the board moves to the ephemeral lane, and the existing x wipes it
from there. Release is reversible -- POST /b/<name>/keep pins it again.

  POST /b/<name>/unkeep   release the pin
  POST /b/<name>/keep     pin it (round-trip, so release is not a one-way door)

FOUND WHILE TESTING, and it invalidates the previously-documented workaround:
removing the sentinel BUMPS the booth directory's mtime, and booth age is the
newest mtime in the tree -- so a released board's clock RESETS from 10,000s to
0s and it survives another full TTL. The old comment said "remove the sentinel
first (it rejoins the sweep)", which is true but means the board lives another
24h, not that it gets reaped. Unkeep-and-wait is a delay, not a delete.

test_releasing_a_board_RESETS_its_ttl_clock pins that behaviour deliberately so
nobody re-derives the workaround. Release is what unlocks the x; the x is what
deletes.

CLI: `booth rm` already worked on kept boards but said nothing about it. It now
announces "(was KEPT -- durable board)" so wiping something durable can never
look identical to wiping run output. Not a block -- a CLI user naming a booth
is being explicit.

5 new tests (67 pass). Verified live on nh3-dev: release renders on all four
kept boards, the ephemeral lane keeps its x, and the links board is untouched
with its sentinel intact.
This commit is contained in:
vh
2026-08-23 10:42:32 -07:00
parent b8a535507a
commit 4be880f36c
6 changed files with 187 additions and 5 deletions
+98
View File
@@ -6,6 +6,7 @@ import pytest
from fastapi.testclient import TestClient
from booth.app import (
booth_age_seconds,
FAVICON_LINK,
KEEP_MARKER,
build_gallery,
@@ -690,6 +691,103 @@ def test_list_booths_flags_kept(tmp_path):
assert by_name["links"]["kept"] is True
# ---- releasing a kept board so it can be deleted ---------------------------
#
# The kept lane deliberately has no wipe control: destroying a durable board
# should not be one misclick. But "deliberate" had been implemented as
# "impossible from the UI" — the only routes out were ssh or a hand-crafted
# API call. These endpoints make the documented workflow (drop the sentinel,
# the board rejoins the sweep, then wipe it like anything else) actually
# reachable, while keeping it two deliberate steps rather than one.
def test_unkeep_releases_a_kept_board(client):
c, data = client
_touch(data / "links" / "a.png")
_touch(data / "links" / KEEP_MARKER)
r = c.post("/b/links/unkeep", follow_redirects=False)
assert r.status_code == 303
assert not is_kept(data / "links"), "the sentinel must be gone"
assert (data / "links" / "a.png").exists(), "unkeep must not touch content"
def test_unkeep_is_idempotent_on_an_unkept_board(client):
"""Releasing something already released is a no-op, not a 500."""
c, data = client
_touch(data / "run1" / "a.png")
r = c.post("/b/run1/unkeep", follow_redirects=False)
assert r.status_code == 303
assert (data / "run1" / "a.png").exists()
def test_keep_pins_a_board_and_round_trips(client):
"""Reversible: the release step must not be a one-way door."""
c, data = client
_touch(data / "board" / "a.png")
assert c.post("/b/board/keep", follow_redirects=False).status_code == 303
assert is_kept(data / "board")
assert c.post("/b/board/unkeep", follow_redirects=False).status_code == 303
assert not is_kept(data / "board")
def test_keep_and_unkeep_go_through_the_same_name_guard(client):
"""Both mutating routes must use resolve_booth, not raw path joining.
A name containing a slash never reaches the handler at all (the router has
no matching path), so the interesting cases are the ones that DO reach it:
a dotfile name and a name that simply is not a booth. Both must 404 rather
than create a stray sentinel somewhere.
"""
c, data = client
for route in ("keep", "unkeep"):
assert c.post(f"/b/.hidden/{route}").status_code == 404
assert c.post(f"/b/nope/{route}").status_code == 404
assert not (data / ".hidden").exists(), "must not have created anything"
assert list(data.iterdir()) == [], "data dir untouched by rejected calls"
def test_releasing_a_board_RESETS_its_ttl_clock(tmp_path):
"""Counter-intuitive, and the reason release-then-sweep is not a delete path.
Removing the sentinel bumps the booth directory's mtime, and age is the
newest mtime in the tree — so a board that was 10,000s stale reads as 0s
old the instant it is released, and survives another full TTL. This test
pins that behaviour deliberately: anyone who "unkeeps and waits" is waiting
a fresh 24h, not reaping something already expired. Delete via the wipe
route instead, which release is what unlocks.
"""
kept = tmp_path / "links"
_touch(kept / "a.png")
_touch(kept / KEEP_MARKER)
_stale(kept)
assert sweep_once(tmp_path, ttl_seconds=3600) == [], "pinned: exempt"
assert booth_age_seconds(kept) > 3600
(kept / KEEP_MARKER).unlink()
assert booth_age_seconds(kept) < 60, "unlink bumped the dir mtime"
assert sweep_once(tmp_path, ttl_seconds=3600) == [], "so it is NOT swept yet"
assert kept.exists()
def test_released_board_is_sweepable_once_it_ages_again(tmp_path):
"""It does rejoin the sweep — just on a fresh clock, not the old one."""
released = tmp_path / "links"
_touch(released / "a.png")
_stale(released)
assert sweep_once(tmp_path, ttl_seconds=3600) == ["links"]
assert not released.exists()
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")