Files
esh-pfi-infrastructure/services/booth/README.md
T
vh c335c38c19 fix(booth): a partial ask answer is recorded, not refused
Operator: the form failed when a question was left blank. Refusing the whole
submission over one blank threw away the picks that were made, and the HTML
`required` on the radios blocked it in the browser before the server saw it.

- answered questions recorded; blank ones land in `unanswered`; `complete` says
  whether the set is finished; a blank question carrying a note keeps the note
- `required` dropped from both templates so the browser cannot block a partial
- refused only when there is no pick anywhere AND no notes (a 400 — that would
  flip an open ask to answered with no decision recorded); a choice outside the
  option list is still an error
- new ◐ partial state with an n/N count; skipped questions render as skipped
- README + global CLAUDE.md tell reading sessions to check `complete`
- 154 tests; v0.1.15
2026-09-09 15:05:18 -07:00

20 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)

booth links                # list the standing link board: row number, entry id, the row
booth unlink 3             # remove row 3
booth unlink 8b40e0a5      # or remove by entry id (what the web UI's × posts)

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.

Asks — let the operator pick one of N, and read the pick back

The one interactive primitive. A session needs a human decision — which render wins, which plan, go/no-go — and wants to act on it without a chat round-trip. Drop a question in a booth; the page renders it as a radio form with a notes field; the operator's submit writes an answer sidecar the session reads. Filesystem is still the state:

<booth>/<stem>.ask.json      the question   (a session writes it)
<booth>/<stem>.answer.json   the answer     (the web UI writes it, atomically)
# On nh3-dev — pose, then block until answered (default 1h), then act on it:
booth ask r18-ab winner "Which render wins?" "A — baseline" "B — cudaMallocAsync"
booth answer r18-ab winner --wait            # prints the answer JSON when it lands
booth answer r18-ab winner                   # non-blocking: exit 1 while unanswered
booth asks   r18-ab                          # list a booth's asks + state

# Options can carry an id + detail line instead of a bare label — write the
# JSON yourself (booth.asks.write_ask validates the same way):
cat > ~/booth-data/r18-ab/plan.ask.json <<'EOF'
{"title": "optional short label above the question",
 "prompt": "Ship which?",
 "options": [{"id": "a", "label": "Plan A", "detail": "smaller diff, no migration"},
             {"id": "b", "label": "Plan B", "detail": "cleaner, needs the DB change"}],
 "notes": true, "notes_label": "why / conditions"}
EOF

# From another host: rsync the ask in, then poll the sidecar over HTTP:
curl -sf http://10.100.10.50:8090/b/r18-ab/winner.answer.json   # 404 until answered

Several questions, one form. Give the ask a questions list instead of prompt+options; the page renders one form with a radio group per question and a single submit, every question required. Per-question notes: true adds a small text field under that question; the form-level notes stays one field for the whole ask. The answer is keyed by question:

cat > ~/booth-data/r18-ab/batch.ask.json <<'EOF'
{"title": "R18 batch review",
 "questions": [
   {"key": "r1", "prompt": "Render 1 — keep?", "options": ["keep", "drop"], "notes": true},
   {"key": "r2", "prompt": "Render 2 — keep?", "options": ["keep", "drop"]},
   {"key": "seed", "prompt": "Reseed the batch?", "options": ["yes", "no"]}],
 "notes": true, "notes_label": "anything else"}
EOF
# -> batch.answer.json: {"stem", "title", "answers": {"r1": {"prompt", "choice",
#    "choice_index", "label", "notes"}, "r2": {...}, "seed": {...}}, "notes", "answered_at", "answered_by"}

Both shapes also carry unanswered (the question keys left blank; [null] for a blank single-question ask) and complete (false until every question has a pick). A reading session should check complete before acting on a multi-question answer, and treat a key in unanswered as "not decided", never as "declined".

The single-question answer: {"stem", "prompt", "choice", "choice_index", "label", "notes", "unanswered", "complete", "answered_at", "answered_by"}choice is the option id (the label itself for string options), choice_index its 0-based position, answered_by the client address. POST /b/<name>/answer is what the form submits — fields ask plus choice / notes (single) or choice.<key> / notes.<key> / notes (multi); a missing or bad choice is a 400, an unknown stem a 404.

Rules of the primitive:

  • Radio, one pick per question. ≥ 2 options, ≤ 40 per question, ≤ 30 questions per ask. No multi-select checkboxes (not yet asked for). Many asks per booth are fine — each is its own form and its own sidecar; use questions when the picks belong together and should land as one answer.
  • Re-answering overwrites. The sidecar is the current answer, not a log. The page shows the recorded answer with a collapsed change answer form.
  • Blanks are legal — a partial answer is recorded, not refused. Leaving a question alone is a real outcome ("none of these", "not listened to yet"), and refusing the whole submission over one blank threw away the picks that WERE made. So every answered question is recorded, every blank one lands in unanswered, and complete says whether the set is finished. The radios carry no HTML required, so the browser does not block the submit either. A question left blank but carrying a note keeps the note (choice: null). The one refusal is a submission with no pick anywhere and no notes — a 400, because it would flip an open ask to "answered" while recording no decision, which is worse for the reading session than leaving it open. A choice that is not in the option list is still an error: that is a broken form, not a skipped question. Partially-answered asks show as ◐ partial with an n/N count; re-submitting fills in the rest.
  • Open asks are flagged — an amber ? N asks badge on the index card and in the booth header — so a waiting question is visible from the front page.
  • A broken ask is shown as broken, not hidden: if the JSON does not validate, the page says why, so a session never thinks it posted a question the operator cannot see.
  • Ask/answer files are not gallery items and do not count toward the booth's item count; they render as the panel above the gallery. Answering bumps the booth's mtime, so it lives another TTL — the session has 24h to read it.
  • Works with JavaScript off (plain form POST). No auth, same as everything here.

Where the form renders

Two booth shapes, two placements. Either way the ask is never invisible — that is the guarantee; markup only moves it somewhere better.

Auto-gallery booth (no index.html of its own): the asks panel renders above the gallery, styled like the rest of the Booth. Nothing to do.

A booth serving its own index.html: that page is returned verbatim, so the Booth substitutes placeholders in your markup rather than rendering a panel above a gallery that does not exist. The question then sits with the artifact it is about (operator ruling 2026-09-09: "the asks should be inline with the artifacts, not on a separate page").

When inline is worth the markup, and when it is not. The test is whether the artifact can be held in the head while the form is on screen. Two short images side by side — no, the appended form is fine. Twenty audio clips, five per voice across four voices — yes: on a separate page the operator is choosing from memory of the audio, not from the audio, and by the fourth voice that memory is gone. That is the case this mechanism exists for (framing owed to tts-dev, 2026-09-09, from the redo-anchors audition).

<div data-booth-ask="anchors"></div>          <!-- the whole ask: every question + submit -->
<div data-booth-ask="anchors:lawson"></div>   <!-- just that one question's radios -->
<div data-booth-ask-submit="anchors"></div>   <!-- the notes field + submit button -->
<!-- booth:ask anchors:lawson -->             <!-- comment form, identical behaviour -->

Per-question fragments bind to one form via the HTML5 form= attribute, so a four-voice audition puts each radio group under that voice's audio and still submits every pick in a single POST — which is what a multi-question ask requires. Fragments ship their own scoped styles, inherit nothing from your page, and use no JavaScript.

Put the placeholder outside any CSS grid or flex container, or it becomes a cell in it — measured on redo-anchors, where the first attempt rendered as a 224 px sixth grid cell wedged between two audio players. A sibling of the block it belongs to is right.

The fallbacks, so a page can never strand a question:

you marked up what happens
nothing the whole ask is appended at the end of the page
some questions, no submit the rest of the questions and a submit block are appended
a stem this booth does not have your markup is left alone, untouched; the real ask is still appended

An amber ? N open asks chip floats top-right as a jump link to the first open ask, and GET /b/<name>/asks still renders every ask on a plain page of its own — useful when you want to hand someone only the question.

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)
GET /b/<name>/asks The asks panel on its own page — the only place a verbatim-index.html booth can show its asks
POST /b/<name>/answer Answer an ask (fields ask = stem, choice/choice.<key>, notes/notes.<key>, back); writes <stem>.answer.json, 303 back
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)
POST /b/<name>/unlink Remove ONE row from a link board (form field entry = content id)
POST /b/<name>/unlink-many Remove SEVERAL rows — the multi-select delete (repeated form field sel = content ids)
POST /b/<name>/pin Toggle a row's pinned/favorite state (form field entry = content id)
DELETE /b/<name> Wipe a booth (curl/API)
GET /healthz {ok, ttl_hours, booths} — Homepage siteMonitor target

A booth containing links.md is the fleet's standing link board: every agent session appends operator-facing URLs to it so they outlive the terminal scrollback that would bury them. It is the one booth where the useful granularity is the row, not the folder — a dead link has to be removable without taking the other thirty with it.

It renders as real UI, not a markdown blob: each row shows the description, URL and provenance (who posted it, when), with a copy button and a per-row ×.

Order: pinned first, then newest on top. The board is an append log, so the most recently posted link leads — the one you almost certainly came to grab. Rows you want to keep in view regardless of churn get the (pin/favorite), which floats them to a group at the very top; click it again to unpin. The header shows N pinned when any are.

Multi-select delete. Tick the checkbox on any set of rows and hit 🗑 delete to remove them all in one go (with a count confirmation). The select-all box in the header toggles the lot. The per-row × is still there for a single quick removal. Everything — checkboxes, ×, ★, bulk delete — works with JavaScript off (plain form POSTs via formaction); JS only adds select-all and the live count.

booth links                # row number, entry id, raw row
booth unlink 3             # by row number
booth unlink 8b40e0a5      # by entry id — what the × posts

Rows are addressed by CONTENT ID, never by position. The board is append-only and multi-writer: another session can post between the moment you list it and the moment you remove a row, so an index would delete a neighbour. An id either matches the row you saw or matches nothing. A row number typed at the CLI is resolved to its id before anything is deleted. The multi-select delete (/unlink-many) carries the same guarantee per selected id.

An id is exactly 8 hex characters, which is how the CLI tells ids from row numbers — roughly one id in forty is all digits, so "is it numeric" is not a safe test.

Pin state lives in a .pins sidecar (one content id per line), never inline in links.md. That keeps links.md a pure append log — booth link stays a single atomic write, which is what lets many sessions post concurrently — and means pinning a row never changes its content id. A pin whose row is later removed is dropped automatically; a pin orphaned by a hand-edit is inert (the renderer only stars a row a live id still matches). Pins are a UI action; there is no booth pin CLI yet.

Appends (booth link) and prunes (booth unlink, unlink-many, the ×) take the same flock on .links.lock, and pin toggles take it too, so a post cannot be lost inside a prune's or a toggle's read-modify-write window.

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
ln -sfn "$PWD/scripts/booth" ~/.local/bin/booth     # the `booth` CLI on PATH (nh3-dev has this)
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.