c335c38c19
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
415 lines
20 KiB
Markdown
415 lines
20 KiB
Markdown
# 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:
|
||
|
||
```bash
|
||
# 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.
|
||
|
||
```bash
|
||
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:
|
||
|
||
```bash
|
||
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.
|
||
|
||
### The standing link board
|
||
|
||
```bash
|
||
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)
|
||
```
|
||
|
||
```bash
|
||
# 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:
|
||
|
||
```bash
|
||
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).
|
||
|
||
```html
|
||
<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):**
|
||
```bash
|
||
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 |
|
||
|
||
|
||
### The standing link board
|
||
|
||
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.
|
||
|
||
```bash
|
||
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).
|
||
|
||
```bash
|
||
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
|
||
|
||
```bash
|
||
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
|
||
|
||
```bash
|
||
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.
|