Files
booth/README.md
T
vh d88f235688 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.
2026-08-23 10:42:32 -07:00

8.6 KiB
Raw Blame History

The Booth

A dead-simple standing web server for shuttling ephemeral files between the operator and CC sessions — A/B renders, smoke-test screenshots, audio/video samples, or anything you want to hand off. It works both directions:

  • Session → operator: a session drops a folder of files on disk; the Booth renders it as a browsable "booth".
  • Operator/anyone → pickup: upload files through the browser (or curl -F) and get a human-readable pickup id like 4-wombat or star-84.

Either way it wipes 24h after the last activity. No database — the filesystem is the state.

  • Live: http://10.100.10.50:8090/ (nh3-dev) · linked from Homepage → Apps → The Booth
  • Data dir: ~/booth-data/ on nh3-dev (one subfolder per booth)
  • TTL: 24h, measured from the newest mtime in a booth's tree (it lives while you're touching it, self-destructs 24h after you stop)

How a session posts

A booth is just a folder under the data dir. Three ways, cheapest first:

# 1. On nh3-dev — the helper (services/booth/scripts/booth):
booth add my-run  out/a.png out/b.png       # creates booth + copies, prints URL
booth new  my-run                            # empty booth, then cp/mv into ~/booth-data/my-run/
booth url  my-run                            # just print the URL
booth ls                                     # list booths
booth rm   my-run                            # wipe now (TTL would anyway)

# 2. On nh3-dev — raw, no helper:
mkdir -p ~/booth-data/my-run && cp out/*.png ~/booth-data/my-run/
#   -> http://10.100.10.50:8090/b/my-run/

# 3. From another host — rsync into the data dir:
rsync -a ./out/  nh3-dev:booth-data/my-run/

Then hand the operator http://10.100.10.50:8090/b/my-run/.

Kept boards — the one exception to the 24h rule

A booth containing a .forever dotfile is never swept, and renders in its own Kept lane at the top of the index (blue top edge, ★ kept badge, no countdown, no one-click wipe). Everything else is unchanged: the default is still ephemeral, so nobody inherits a cleanup chore they didn't ask for.

booth keep   my-board      # drop the sentinel — exempt from the sweep, forever
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 mental model:

touch ~/booth-data/my-board/.forever    # keep
rm    ~/booth-data/my-board/.forever    # unkeep
rm -rf ~/booth-data/my-board            # delete outright, whenever you like

Why this exists: agent sessions hand the operator URLs — a booth of renders, a PR, a dashboard — and they drown in terminal scrollback. Kept boards are where those go instead.

booth link <url> [description]

Appends one line to the links board ($BOOTH_LINKS_BOARD, default links), creating it and marking it kept on first use. Each entry carries provenance — who posted it and when — because a bare URL is unreadable three days later. links.md renders as a readable page in the booth.

The append is a single printf of a single line to an O_APPEND fd, which is atomic under PIPE_BUF on POSIX. That matters here specifically: many agents post to one board, and interleaved half-lines would be the obvious failure.

Deliberately not a database. The board is a markdown file — editable with any editor, greppable, and trivially prunable by hand, which is the whole point of the Booth's filesystem-is-the-state model.

Upload for pickup

The reverse direction — put files in through the web, pick them up by id:

  • Browser: the index page has an Upload files for pickup panel (drag-drop or click). Submit → you land on a booth with a human-readable id (4-wombat, star-84) whose files each have a ⬇ download link.
  • curl (a remote session with no ssh to nh3-dev can use this too):
    curl -sS -i -F 'files=@out/a.png' -F 'files=@out/b.png' \
      http://10.100.10.50:8090/upload | grep -i location
    #   Location: /b/star-84/   <- the pickup id
    
  • Pick up at http://10.100.10.50:8090/b/<id>/ (download links), or on nh3-dev straight off disk at ~/booth-data/<id>/.

Uploads are stamped as pickup booths (a ⬆ pickup badge in the UI) and expire on the same 24h TTL. Limits: BOOTH_MAX_FILES files (default 50) and BOOTH_MAX_UPLOAD_MB total per submission (default 1024); filenames are reduced to a safe basename (no path traversal).

What a booth renders

  • Has its own index.html? → served verbatim (its relative assets — chart.png, report.css — resolve out of the same folder). Build whatever page you want.
  • No index.html? → auto-gallery of the folder's media:
    • images (png jpg jpeg gif webp avif svg bmp) → <img> (click → full-screen viewer with Fit / 1:1 — the toggle only appears when the image is larger than the viewport — plus download and ✕/Esc back to the gallery)
    • video (webm mp4 ogv m4v mov) → <video controls>
    • audio (mp3 wav ogg flac m4a opus aac) → <audio controls>
    • anything else → a download link
  • Captions: a <file>.txt or same-stem <stem>.txt sidecar is folded in as that item's caption — the natural way to label an A/B pair:
    a.png              b.png
    a.txt  "baseline"  b.png.txt  "cudaMallocAsync (winner)"
    

Routes

Route Purpose
GET / Index — one card per booth (newest first), with expiry countdown
GET /b/<name>/ A booth (its index.html, else auto-gallery)
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), alongside the other fleet sidecars (herald, zellij-web, ttyd).

systemctl --user status booth.service
systemctl --user restart booth.service
journalctl --user -u booth.service -f          # sweeper logs "[booth] swept …"

Config is env in the unit (booth.service): BOOTH_DATA_DIR, BOOTH_TTL_HOURS, BOOTH_HOST_LABEL, BOOTH_SWEEP_INTERVAL_MIN, BOOTH_MAX_UPLOAD_MB (default 1024), BOOTH_MAX_FILES (default 50).

Install / update

cd services/booth
uv venv && uv pip install fastapi "uvicorn[standard]" jinja2 python-multipart   # runtime deps
cp booth.service ~/.config/systemd/user/booth.service
systemctl --user daemon-reload && systemctl --user enable --now booth.service

Code runs straight from this checkout (the unit's WorkingDirectory / ExecStart point here), so "deploy an update" = edit + systemctl --user restart booth.service.

Tests

cd services/booth && uv pip install pytest httpx && .venv/bin/python -m pytest -q

Notes / non-goals

  • No auth. LAN/WG-internal only, ephemeral content — don't drop secrets in a booth, and note anyone on the LAN can upload (bounded by the size/file limits). Uploaded files are served back with their own content-type, so an uploaded index.html renders as a page (a feature for custom reports; keep it in mind).
  • Booth names with /, .., or a leading . are rejected; file serving and uploaded filenames are guarded against path traversal and symlink escape.