Files
booth/README.md
T
vh 5e41108cd3 fix(marks): a write over a damaged mark file was wiping the booth's judgment
Three defects and a missing test, all surfaced by the cross-frontier contract
panel dispatched before implementation and triaged after it (heid, four arms,
artifact-only, thread 01M33VSNFER4N1554G0Y0VC9C8). v0.2.0 was already tagged and
announced to fifteen handles when they landed, which is the argument for running
the gate at all.

DATA LOSS. `marks_for` is deliberately lenient — an unparseable `.marks.json`
reads as "no marks" so a review page still loads. The write path inherited that
leniency through the same reader, so one flag click appended a single entry to an
empty list and atomically replaced the file: every mark in the booth gone,
silently, from a click. Reproduced first, then fixed.

The fix is an asymmetry, not a retreat from leniency. Reads stay lenient; writes
go strict through `_read_raw_strict`, which distinguishes bytes-present-but-
unreadable from absent and valid-but-empty, and raises `MarksCorrupt`. The
damaged bytes are left on disk. Routes answer 409 rather than 500 — the service
is fine and the request was well-formed, the state on disk is not — and the body
says what to do, because the alternative the operator reaches for otherwise is
deleting the file, which is the thing being protected. The CLI says it in one
line instead of a traceback.

A PICK COULD NOT TARGET AN ITEM. `Mark.target` carried one, `marks_for_target`
retrieved by it, and the panel already rendered "on <item>" — but `declare_pick`
had no parameter for it, so no session could produce one. A question about one
artifact is the whole point of the 2026-09-09 inline-placement ruling; the door
was simply missing.

THE IMPORTER STRANDED AN ANSWER. A stem already present as a mark was skipped
wholesale. If a session had re-declared that stem through marks while the
operator's choice sat in the legacy sidecar, that choice was lost permanently —
reads are forbidden from looking at sidecars. The declaration is still skipped
(idempotence holds) but a legacy answer is now adopted when the existing mark is
an unanswered pick, and an answer made through marks is never overwritten.

INV-3 NAMED A SURFACE NOTHING TESTED. All four arms converged on it: the rule
protects gallery tile, zoom view and doc view; the falsifiable check covered one.
The doc view was implemented and untested, so shipping it unmarked would have
passed. Three tests now, one per surface.

The contract carries the full triage, including two findings accepted and NOT
closed: INV-2's and INV-5's checks comply in letter — openness can be re-derived
without spelling the grepped pattern, and importlib inside a function defeats the
AST walk. Both describe a future careless change, and the honest statement is
that these checks raise the cost of drifting rather than making it impossible.
Recorded rather than papered over.

Also pins the three prose ambiguities the panel found, normatively and once each:
what counts as open, the three distinct broken-declaration cases, and INV-6,
which had named a helper that does not exist and forbidden the calls that helper
must make.

253 tests.
2026-09-21 23:54:42 -07:00

471 lines
23 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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/`.
## Checking that controls can actually be clicked
```bash
<a python with playwright> scripts/layout-probe.py [URL ...]
```
⚠ **Markup inspection structurally cannot catch occlusion, and this UI has
shipped two dead controls in two days** — a reveal button whose handler Jinja
discarded, and a `×` that a sibling `release` form painted over completely
(30x22 px overlap on a 30px button; `elementFromPoint` at its centre returned
the other form). Both were reported by the operator. Both passed every test,
because the markup, the routes and the CSS were each individually correct.
The probe walks every button and link, scrolls it into view, and asks the
browser what a click at its centre would actually hit. It took four iterations
to become trustworthy, and each failure is worth knowing because they are the
traps in writing this kind of check at all:
1. `top.contains(el)` counted an **ancestor** overlay as a hit — which is the
exact case the probe exists to catch. It reported OK for a real overlay.
2. `elementFromPoint` is **viewport-relative**, so everything below the fold
read as occluded. Scroll first.
3. `getBoundingClientRect()` on a **wrapped inline** element is the union of
its line boxes, whose centre can sit in the gutter between them, on the
parent. Use `getClientRects()[0]`.
4. Only after all three does the positive control (a real overlay) fire while
the negative control (the clean page) stays silent. **Both were run.** A
probe that has never been seen to fail is not evidence of anything.
## Blurring an item (cosmetic censoring)
⚠⚠ **Blur is NOT access control.** A blurred item is still served at its own
URL, still included in the zip, still on disk. It hides a thing from a *glance*
— a shoulder, a screen-share, a scroll past something you did not want
full-size — and nothing else. The Booth has no auth by design: **if a thing must
not be SEEN by whoever can reach port 8090, it must not be in a booth.**
```bash
booth blur <name> <file>... # hide from a glance
booth unblur <name> <file>...
```
Or the **`◌ blur` / `◉ blurred`** button on every item in the booth page —
in the caption row for images, video, audio and plain files, and in the doc bar
beside ⤢ ⬇ ✕ for inline docs.
⚠ The toggle is emitted by ONE Jinja macro (`blurtoggle`) called from all three
item branches. booth.html renders docs, media and everything-else through
separate `<figure>` blocks, and this feature was twice shipped having patched
only some of them — first the blur class, then the toggle itself. Add a fourth
branch and you must call the macro from it;
`test_every_item_kind_gets_exactly_one_blur_toggle` counts toggles against
figures across mixed kinds and will fail if you don't.
- **State** is `.blurred` in the booth dir — one booth-relative item path per
line, the same filesystem-is-the-state idiom as `.pins` and `.forever`. An
empty set deletes the file rather than leaving a zero-byte one, so `ls -a`
tells the truth about whether anything here is blurred.
- **Reveal is per-viewer and never persisted.** Click 👁 reveal; a reload
re-hides. With JS off it stays blurred, which is the safe direction to fail.
⚠ This button shipped INERT on 2026-09-20 and stayed that way for a day. Its
handler sat after the content block's closing tag, and a child template's
out-of-block content is silently DISCARDED by Jinja — the button rendered,
the handler never reached the browser, and two commits plus this README said
it worked. The suite passed throughout because nothing asserted against the
served page. `test_reveal_handler_actually_reaches_the_served_page` now greps
the HTTP RESPONSE, and `test_no_orphaned_markup_after_the_content_block`
guards the structure. Both were confirmed to FAIL when the defect is
reintroduced, which is the only way to know a guard guards anything.
- **Covers inherit it.** If a booth's cover image is blurred, the index card's
thumb is blurred too — otherwise the front page undoes the censoring.
- **Inline docs are blurred too**, not just images and video. That branch puts
readable text straight on the page, so it needs this more than a picture does.
## Keeping a booth (round trip, both directions)
Three places, all doing the same thing:
- **Index, ephemeral card** — `★` promotes to the kept lane.
- **Index, kept card** — `release` demotes, and `×` now WIPES DIRECTLY. The old
rule was release-then-find-it-in-the-other-lane; that protected nothing and
cost a hunt, because the board you just released is loose in a feed that
turns over. Protection lives in the confirmation now, which names the booth
and says KEPT.
- **Inside a booth** — `☆ keep` / `★ kept — release`, beside *Wipe now*, so you
do not have to go back to the index. These post a `next` field to stay on the
page; `next` is a form field and therefore attacker-controlled, so only
same-site absolute paths are honoured (`//host`, schemes and backslashes are
refused).
Equivalent CLI: `booth keep <name>` / `booth unkeep <name>`.
⚠ Release BUMPS the directory mtime, so a released board's age resets and it
survives another full TTL. Unkeep-and-wait is a 24h delay, not a delete — which
is exactly why the direct `×` was worth adding.
⚠ Until 2026-09-19 the UI only went one way — the kept lane could release, but
an ephemeral booth could only be kept from a shell. The `/keep` route and the
CLI verb both already existed; only the button was missing.
## 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.
## Marks — operator judgment, attached to an artifact
The one **interactive** primitive, and one primitive for what used to be three
jobs:
| shape | who writes it | what it is |
|---|---|---|
| **`pick`** | a session declares the options, the operator chooses | "which render wins?" — this is what `booth ask` poses |
| **`note`** | the operator, in the browser | free text for the session that posted the work |
| **`flag`** | the operator, in the browser | *this one* — selecting winners out of a set |
All three are the operator judging something and the session reading the
judgment back. They live in **one file per booth**, so "does this booth still owe
me an answer?" is a single read:
```
<booth>/.marks.json every mark in the booth (dotfile: never a tile, never in the zip)
<booth>/.marks.lock the write lock (a session declares, the operator answers)
```
There are **no `note` or `flag` CLI verbs**, on purpose: the CLI is the session's
side of the loop, and a session does not author the operator's judgment. It reads
it.
```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 marks r18-ab # every mark in the booth, as JSON
booth marks r18-ab --wait # block while any pick is still open
# Options can carry an id + detail line instead of a bare label. Write the whole
# declaration yourself and it is validated by the same normaliser the page uses:
booth ask r18-ab plan "Ship which?" "Plan A" "Plan B" # or, for detail lines:
python3 -c '
import pathlib, sys; sys.path.insert(0, "/home/lkraven/development/booth")
from booth.marks import declare_pick
declare_pick(pathlib.Path("/home/lkraven/booth-data/r18-ab"), "plan", {
"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"})'
# From another host: rsync your work in, then read the judgment over HTTP.
# ONE request for the whole booth, rather than one per question.
curl -sf http://10.100.10.50:8090/b/r18-ab/marks.json | jq '.open, .marks[].answer'
```
**Several questions, one form.** Give the declaration a `questions` list instead
of `prompt`+`options`; the page renders one form with a radio group per question
and a single submit. Per-question `notes: true` adds a small text field under
that question; the form-level `notes` stays one field for the whole pick. The
answer is keyed by question:
```python
declare_pick(booth, "batch", {
"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"})
# -> answer: {"stem", "title", "answers": {"r1": {"prompt", "choice",
# "choice_index", "label", "notes"}, "r2": {...}, "seed": {...}},
# "unanswered", "complete", "notes", "answered_at", "answered_by"}
```
**A pick can be about ONE item, not just the booth.** Pass `target` — an item's
booth-relative path — and the question renders beside that artifact:
```python
declare_pick(booth, "which-crop", {"prompt": "Which crop?", "options": ["tight", "wide"]},
target="v3/DSC03389.jpg")
```
**A partial answer is recorded, not refused.** A question left blank is a
deliberate outcome — "none of these", "not yet", "ask me later" — so it lands in
`unanswered`, stays absent from `answers` unless it carried a note, and
`complete` stays false. **A partially-answered pick still counts as open**, which
is what the index badge reports. The one refusal is a submission carrying nothing
at all: no choice anywhere and no notes.
**Migrating a booth that predates marks.** The old two-sidecars-per-question
files (`<stem>.ask.json` / `<stem>.answer.json`) are imported, never deleted:
```bash
booth marks-import r18-ab # idempotent; the sidecars stay on disk
```
If the stem is already a mark the declaration is skipped, but a legacy answer
still gets adopted, so the operator's recorded choice is never stranded on disk.
**If a booth's `.marks.json` is damaged**, reads degrade to "no marks" so the page
still loads, and every WRITE refuses with a 409 rather than replacing the file —
which would otherwise wipe every judgment in that booth. Repair or move the file
by hand; nothing deletes it for you.
## 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>/marks` | The marks panel on its own page — the only place a verbatim-`index.html` booth can show its marks (`/asks` 308s here) |
| `GET /b/<name>/marks.json` | Every mark as JSON, plus `open` — the read path for a session that is not on this host |
| `POST /b/<name>/answer` | Answer a pick (fields `ask` = mark id, `choice`/`choice.<key>`, `notes`/`notes.<key>`, `back`); 303 back |
| `POST /b/<name>/note` | Attach free text to an item (`target`) or to the booth (`target` empty); `text` required |
| `POST /b/<name>/flag` | Flag or unflag one item (`target`, `on`) — an upsert; unflagging removes the mark |
| `POST /b/<name>/unmark` | Withdraw one mark (`mark` = its id) |
| `POST /b/<name>/import-asks` | Import this booth's legacy `*.ask.json` sidecars; idempotent, deletes nothing |
| `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.