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
+64 -9
View File
@@ -1,12 +1,28 @@
#!/usr/bin/env bash
# booth — post media to The Booth (dead simple). A booth is just a folder under
# $BOOTH_DATA_DIR; this is sugar over mkdir/cp so you get the URL back.
# booth — post media and links to The Booth (dead simple). A booth is just a
# folder under $BOOTH_DATA_DIR; this is sugar over mkdir/cp so you get the URL
# back.
#
# booth new <name> make an empty booth, print its URL
# booth add <name> <file>... copy files into a booth (creates it), print URL
# booth url <name> print a booth's URL
# booth ls list booths
# booth rm <name> wipe a booth now (TTL would eventually anyway)
# booth new <name> make an empty booth, print its URL
# booth add <name> <file>... copy files into a booth (creates it), print URL
# booth url <name> print a booth's URL
# booth ls list booths (kept ones marked ★)
# booth rm <name> wipe a booth now (TTL would eventually anyway)
#
# booth keep <name> exempt a booth from the 24h sweep, forever
# booth unkeep <name> hand it back to the sweeper
# booth link <url> [description] append a link to the standing link board
#
# THE 24h RULE AND ITS ONE EXCEPTION. Every booth is wiped 24h after its last
# activity — that is the contract, and it is why nobody has to clean up after
# themselves. `keep` drops a `.forever` sentinel that exempts one booth from the
# sweep and moves it into its own lane at the top of the index. Use it for
# 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.
#
# `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.
#
# On a host that is NOT nh3-dev, rsync into the data dir instead, e.g.:
# rsync -a ./out/ nh3-dev:booth-data/my-run/
@@ -14,8 +30,13 @@ set -euo pipefail
DATA="${BOOTH_DATA_DIR:-$HOME/booth-data}"
URL="${BOOTH_URL:-http://10.100.10.50:8090}"
KEEP=".forever" # must match KEEP_MARKER in booth/app.py
LINKS_BOARD="${BOOTH_LINKS_BOARD:-links}"
usage() { echo "usage: booth {new <name>|add <name> <file>...|url <name>|ls|rm <name>}" >&2; exit 2; }
usage() {
echo "usage: booth {new <name>|add <name> <file>...|url <name>|ls|rm <name>|keep <name>|unkeep <name>|link <url> [description]}" >&2
exit 2
}
cmd="${1:-}"; shift || true
case "$cmd" in
@@ -36,12 +57,46 @@ case "$cmd" in
echo "$URL/b/$1/"
;;
ls)
ls -1 -- "$DATA" 2>/dev/null || true
[ -d "$DATA" ] || exit 0
for d in "$DATA"/*/; do
[ -d "$d" ] || continue
n="$(basename -- "$d")"
if [ -e "$d$KEEP" ]; then echo "★ $n"; else echo " $n"; fi
done
;;
rm)
[ $# -ge 1 ] || usage
rm -rf -- "${DATA:?}/$1"
echo "wiped $1"
;;
keep)
[ $# -ge 1 ] || usage
[ -d "$DATA/$1" ] || { echo "no such booth: $1" >&2; exit 1; }
: > "$DATA/$1/$KEEP"
echo "kept (exempt from the sweep): $URL/b/$1/"
;;
unkeep)
[ $# -ge 1 ] || usage
rm -f -- "$DATA/$1/$KEEP"
echo "unkept — $1 rejoins the 24h sweep"
;;
link)
[ $# -ge 1 ] || usage
link_url="$1"; shift
desc="${*:-}"
board="$DATA/$LINKS_BOARD"
mkdir -p -- "$board"
: > "$board/$KEEP" # the board is durable by definition
# Provenance, because a bare URL is unreadable three days later: who posted
# it, from where, and when.
who="${ALTHING_HANDLE:-${BOOTH_SOURCE:-$(hostname -s 2>/dev/null || echo unknown)}}"
when="$(date '+%Y-%m-%d %H:%M')"
# ONE printf of ONE line. A single write under PIPE_BUF to an O_APPEND fd is
# atomic on POSIX, so concurrent sessions cannot interleave a line — which
# matters here precisely because many agents post to one board.
printf -- '- [%s](%s) <sub>· %s · %s</sub>\n' \
"${desc:-$link_url}" "$link_url" "$who" "$when" >> "$board/links.md"
echo "$URL/b/$LINKS_BOARD/"
;;
*) usage ;;
esac