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:
@@ -48,7 +48,8 @@ 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
|
||||
booth unkeep my-board # release the pin — the board rejoins the sweep
|
||||
booth rm my-board # delete it NOW (works on kept boards; says so when it was kept)
|
||||
```
|
||||
|
||||
It is just a file, so the manual forms work identically and are the honest
|
||||
@@ -132,9 +133,31 @@ to a safe basename (no path traversal).
|
||||
| `GET /b/<name>/<file>` | Serve a file out of the booth |
|
||||
| `POST /upload` | Upload files → new pickup booth; 303-redirects to `/b/<id>/` (id in `Location`) |
|
||||
| `POST /b/<name>/delete` | Wipe a booth (the UI's "Wipe now" button) |
|
||||
| `POST /b/<name>/keep` | Pin a booth — exempt from the sweep |
|
||||
| `POST /b/<name>/unkeep` | Release the pin (the UI's "release" button on kept cards) |
|
||||
| `DELETE /b/<name>` | Wipe a booth (curl/API) |
|
||||
| `GET /healthz` | `{ok, ttl_hours, booths}` — Homepage siteMonitor target |
|
||||
|
||||
|
||||
### Deleting a kept board
|
||||
|
||||
Kept boards have no × in the UI on purpose — a one-click wipe next to the
|
||||
durable stuff is a footgun. But *deliberate* must not mean *impossible*, which
|
||||
is what it meant until 2026-08-23: the only routes out were ssh or a
|
||||
hand-written API call.
|
||||
|
||||
Now it is two deliberate steps. **Release** on the kept card drops the
|
||||
sentinel and the board moves to the ephemeral lane, where the × already lives;
|
||||
wipe it from there. Release is reversible — press keep again and nothing was
|
||||
lost. From the CLI, `booth rm <name>` deletes a kept board immediately and
|
||||
tells you it was kept.
|
||||
|
||||
**Do not "unkeep and let it expire."** Removing the sentinel *bumps the booth
|
||||
directory's mtime*, and a booth's age is the newest mtime in its tree — so a
|
||||
released board's clock **resets** and it survives another full TTL.
|
||||
Unkeep-and-wait is a 24-hour delay, not a delete. Use the × or `booth rm` when
|
||||
you mean now.
|
||||
|
||||
## Ops
|
||||
|
||||
Runs as a **user-level** systemd service on nh3-dev (no root, no Docker),
|
||||
|
||||
@@ -679,6 +679,31 @@ def create_app(
|
||||
|
||||
return RedirectResponse(url=f"/b/{quote(booth_id, safe='')}/", status_code=303)
|
||||
|
||||
# Releasing a kept board. The kept lane has no wipe control on purpose —
|
||||
# destroying a durable board should not be one misclick — but "deliberate"
|
||||
# had been built as "impossible from the UI": the only ways out were ssh or
|
||||
# a hand-written API call. These two routes make the release step reachable
|
||||
# while keeping deletion two deliberate acts (release, then wipe).
|
||||
#
|
||||
# NOTE ON THE TTL, which is not intuitive: removing the sentinel BUMPS the
|
||||
# booth directory's mtime, and booth_age_seconds reads the newest mtime in
|
||||
# the tree — so a released board's clock resets to zero and it survives
|
||||
# another full TTL. "Unkeep and let the sweeper take it" therefore does NOT
|
||||
# delete promptly. Release is the step that makes the × available; the ×
|
||||
# is what deletes. Anything relying on release-then-sweep is relying on a
|
||||
# 24h delay it probably did not intend.
|
||||
|
||||
@app.post("/b/{name}/keep")
|
||||
def booth_keep(name: str):
|
||||
(resolve_booth(name) / KEEP_MARKER).touch()
|
||||
return RedirectResponse(url="/", status_code=303)
|
||||
|
||||
@app.post("/b/{name}/unkeep")
|
||||
def booth_unkeep(name: str):
|
||||
# missing_ok: releasing an already-released board is a no-op, not a 500.
|
||||
(resolve_booth(name) / KEEP_MARKER).unlink(missing_ok=True)
|
||||
return RedirectResponse(url="/", status_code=303)
|
||||
|
||||
@app.post("/b/{name}/delete")
|
||||
def booth_delete_form(name: str):
|
||||
shutil.rmtree(resolve_booth(name))
|
||||
|
||||
@@ -122,6 +122,14 @@
|
||||
.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)}
|
||||
/* Release sits where the ephemeral card's × sits, but reads as a word rather
|
||||
than a destructive glyph — it is not the delete, it is what unlocks it. */
|
||||
.release{position:absolute;top:.4rem;right:.4rem;opacity:0;transition:opacity .12s}
|
||||
.card-kept:hover .release,.release:focus-within{opacity:1}
|
||||
.release button{font:inherit;font-size:.72rem;line-height:1;padding:.22rem .45rem;
|
||||
border-radius:.3rem;cursor:pointer;border:1px solid var(--aus-blue);
|
||||
background:var(--rk-panel);color:var(--aus-blue)}
|
||||
.release button:hover{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);
|
||||
|
||||
@@ -37,9 +37,21 @@
|
||||
<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. #}
|
||||
{# Still no × here — a one-click wipe next to the durable stuff is a
|
||||
footgun. But "deliberate" must not mean "impossible from the UI",
|
||||
which is what it meant before: the only routes out were ssh or a
|
||||
hand-written API call. Release drops the sentinel and the board moves
|
||||
to the ephemeral lane, where the × already lives. Two deliberate
|
||||
acts, both reachable, and the first one is reversible.
|
||||
|
||||
The confirm says "wipe it from there" rather than "let it expire" on
|
||||
purpose: releasing BUMPS the directory mtime, so the board's age
|
||||
resets and it survives another full TTL. Unkeep-and-wait is a 24h
|
||||
delay, not a delete. #}
|
||||
<form class="release" method="post" action="/b/{{ b.name_url }}/unkeep"
|
||||
onsubmit="return confirm('Release \u201c{{ b.name }}\u201d?\n\nIt moves to the ephemeral lane so you can wipe it from there. Nothing is deleted by this step.')">
|
||||
<button title="release this board so it can be wiped">release</button>
|
||||
</form>
|
||||
</article>
|
||||
{% endfor %}
|
||||
</div>
|
||||
|
||||
@@ -20,6 +20,17 @@
|
||||
# durable operator-facing boards, not for run output. `unkeep` is just `rm` of
|
||||
# the sentinel, so putting a board back under the sweeper costs nothing.
|
||||
#
|
||||
# DELETING A KEPT BOARD: `booth rm <name>` works on kept boards too and deletes
|
||||
# NOW — it announces that the board was kept, so wiping something durable is
|
||||
# never silent. In the web UI it is two deliberate steps: `release` on the kept
|
||||
# card drops the sentinel, the card moves to the ephemeral lane, and the × wipes
|
||||
# it from there.
|
||||
#
|
||||
# DO NOT "unkeep and let it expire". Removing the sentinel BUMPS the booth
|
||||
# directory's mtime, and a booth's age is the newest mtime in its tree — so a
|
||||
# released board's clock RESETS and it survives another full 24h. Unkeep-and-wait
|
||||
# is a delay, not a delete. Use `rm` (or the UI ×) when you mean now.
|
||||
#
|
||||
# `link` is the reason the exception exists: agent sessions hand the operator
|
||||
# URLs that then drown in terminal scrollback. They go on a standing kept board
|
||||
# instead, with provenance, so they outlive the session that produced them.
|
||||
@@ -66,8 +77,13 @@ case "$cmd" in
|
||||
;;
|
||||
rm)
|
||||
[ $# -ge 1 ] || usage
|
||||
# Say so when the thing destroyed was durable. Not a block — a CLI user
|
||||
# naming a booth is being explicit — but a kept board disappearing must not
|
||||
# look identical to run output disappearing.
|
||||
was_kept=""
|
||||
[ -e "$DATA/$1/$KEEP" ] && was_kept=" (was KEPT — durable board)"
|
||||
rm -rf -- "${DATA:?}/$1"
|
||||
echo "wiped $1"
|
||||
echo "wiped $1$was_kept"
|
||||
;;
|
||||
keep)
|
||||
[ $# -ge 1 ] || usage
|
||||
|
||||
@@ -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")
|
||||
|
||||
Reference in New Issue
Block a user