Three operator reports, one of them a real defect I had claimed was working. THE REVEAL BUTTON DID NOTHING, 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. Two commits and a README paragraph said click-to-reveal worked, and the suite passed the entire time because nothing asserted against the SERVED page -- the template really did contain the code. Two guards, both confirmed to FAIL when the defect is reintroduced rather than merely added and assumed protective: * test_reveal_handler_actually_reaches_the_served_page greps the response * test_no_orphaned_markup_after_the_content_block guards the structure While moving it, caught a second instance of the same class: the explanatory comment I wrote for the fix contained a literal Jinja endblock tag, which Jinja would have parsed as a real tag and used to close the block early. KEPT-LANE ×. Wiping a kept booth required release-then-find-it-in-the-other- lane. That protected nothing and cost a hunt -- the board you just released is loose in a feed that turns over, and you have to go find it to finish a job you had already decided on. Protection now lives in the confirmation, which names the booth and says KEPT, instead of in the number of lanes you must traverse. Release stays as the reversible option. IN-BOOTH KEEP. `☆ keep` / `★ kept — release` beside "Wipe now", so promoting does not mean navigating back to the index. The booth page did not previously know its own kept state; it does now. Both post a `next` field to stay put -- and `next` is a form field, so it is attacker-controlled: only same-site absolute paths are honoured, with `//host`, schemes and backslashes refused, tested. 173 tests pass.
487 lines
24 KiB
Markdown
487 lines
24 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/`.
|
||
|
||
## 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.
|
||
|
||
## 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.
|