feat(booth): asks — a multiple-choice question a session poses in a booth, answered by the operator as a radio form + notes, written back as an answer sidecar

- booth/asks.py (stdlib): <stem>.ask.json question / <stem>.answer.json answer; normalise+validate, atomic write, list with answer folded in, broken asks surfaced not hidden
- POST /b/<name>/answer: validates choice against the ask (400), unknown stem 404, re-answer overwrites
- booth.html asks panel above the gallery; amber open / green answered; JS-off form POST; index card + booth header badge for open asks
- CLI: booth ask / asks / answer [--wait [SECS]]; remote sessions poll <stem>.answer.json over HTTP
- ask/answer files excluded from gallery items and item counts; 23 tests; v0.1.9
This commit is contained in:
vh
2026-09-09 07:24:16 -07:00
parent 5de5583762
commit 3fe01225a9
9 changed files with 721 additions and 6 deletions
+55
View File
@@ -88,6 +88,60 @@ 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 any editor, greppable, and trivially prunable by hand, which is the whole point
of the Booth's filesystem-is-the-state model. 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'
{"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
```
The answer: `{"stem", "prompt", "choice", "choice_index", "label", "notes",
"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` (form fields `ask`, `choice`, `notes`)
is what the form submits; a bad choice is a 400, an unknown stem a 404.
Rules of the primitive:
- **Radio, one pick.** ≥ 2 options, ≤ 40. No multi-select (not yet asked for).
- **Re-answering overwrites.** The sidecar is the *current* answer, not a log.
The page shows the recorded answer with a collapsed *change answer* form.
- **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.
## Upload for pickup ## Upload for pickup
The reverse direction — put files in through the web, pick them up by id: The reverse direction — put files in through the web, pick them up by id:
@@ -136,6 +190,7 @@ to a safe basename (no path traversal).
| `GET /b/<name>/` | A booth (its `index.html`, else auto-gallery) | | `GET /b/<name>/` | A booth (its `index.html`, else auto-gallery) |
| `GET /b/<name>/<file>` | Serve a file out of the booth | | `GET /b/<name>/<file>` | Serve a file out of the booth |
| `POST /upload` | Upload files → new pickup booth; 303-redirects to `/b/<id>/` (id in `Location`) | | `POST /upload` | Upload files → new pickup booth; 303-redirects to `/b/<id>/` (id in `Location`) |
| `POST /b/<name>/answer` | Answer an ask (form fields `ask` = stem, `choice` = option id, `notes`); writes `<stem>.answer.json`, 303 back to the booth |
| `POST /b/<name>/delete` | Wipe a booth (the UI's "Wipe now" button) | | `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>/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>/unkeep` | Release the pin (the UI's "release" button on kept cards) |
+47 -2
View File
@@ -75,6 +75,16 @@ KEEP_MARKER = ".forever"
# The link-board logic lives in booth/links.py (stdlib only) so the `booth` CLI # The link-board logic lives in booth/links.py (stdlib only) so the `booth` CLI
# can use it without pulling FastAPI in. Re-exported here because call sites and # can use it without pulling FastAPI in. Re-exported here because call sites and
# tests already reference these names through app. # tests already reference these names through app.
from booth.asks import ( # noqa: E402
ANSWER_SUFFIX,
ASK_SUFFIX,
AskError,
is_answer_file,
is_ask_file,
list_asks,
valid_stem,
write_answer,
)
from booth.links import ( # noqa: E402 from booth.links import ( # noqa: E402
LINK_LOCK, LINK_LOCK,
LINKS_FILE, LINKS_FILE,
@@ -215,7 +225,14 @@ def list_booths(data_dir: Path, ttl_seconds: float, now: float | None = None) ->
for child in data_dir.iterdir(): for child in data_dir.iterdir():
if not child.is_dir() or child.name.startswith("."): if not child.is_dir() or child.name.startswith("."):
continue continue
files = [p for p in child.rglob("*") if p.is_file() and not p.name.startswith(".")] files = [
p for p in child.rglob("*")
if p.is_file() and not p.name.startswith(".")
and not is_ask_file(p.name) and not is_answer_file(p.name)
]
# Asks are questions, not items: counted separately so the index can
# flag a booth that is waiting on the operator.
asks = list_asks(child)
kinds = {"image": 0, "video": 0, "audio": 0, "other": 0} kinds = {"image": 0, "video": 0, "audio": 0, "other": 0}
thumb_url = None thumb_url = None
for f in files: for f in files:
@@ -234,6 +251,8 @@ def list_booths(data_dir: Path, ttl_seconds: float, now: float | None = None) ->
"has_index": (child / "index.html").is_file(), "has_index": (child / "index.html").is_file(),
"uploaded": (child / UPLOAD_MARKER).exists(), "uploaded": (child / UPLOAD_MARKER).exists(),
"kept": is_kept(child), "kept": is_kept(child),
"asks_total": len(asks),
"asks_open": sum(1 for a in asks if a["answer"] is None and not a["error"]),
"expires_in": max(0.0, ttl_seconds - (now - mtime)), "expires_in": max(0.0, ttl_seconds - (now - mtime)),
"mtime": mtime, "mtime": mtime,
} }
@@ -249,7 +268,12 @@ def build_gallery(child: Path) -> list[dict]:
next to `a.png`) is consumed as that item's caption rather than shown itself — next to `a.png`) is consumed as that item's caption rather than shown itself —
the natural way to label an A/B pair. the natural way to label an A/B pair.
""" """
all_files = [p for p in child.rglob("*") if p.is_file() and not p.name.startswith(".")] all_files = [
p for p in child.rglob("*")
if p.is_file() and not p.name.startswith(".")
# `*.ask.json` / `*.answer.json` render as the asks panel, not as tiles
and not is_ask_file(p.name) and not is_answer_file(p.name)
]
by_rel = {p.relative_to(child).as_posix(): p for p in all_files} by_rel = {p.relative_to(child).as_posix(): p for p in all_files}
caption: dict[str, str] = {} caption: dict[str, str] = {}
sidecars: set[str] = set() sidecars: set[str] = set()
@@ -620,11 +644,32 @@ def create_app(
) )
if (booth / LINKS_FILE).is_file() else [] if (booth / LINKS_FILE).is_file() else []
), ),
# Asks: multiple-choice questions a session left for the
# operator, rendered as forms above the gallery (open ones)
# or as their recorded answer. See booth/asks.py.
"asks": list_asks(booth),
"uploaded": (booth / UPLOAD_MARKER).exists(), "uploaded": (booth / UPLOAD_MARKER).exists(),
"expires_in": max(0.0, ttl_seconds - booth_age_seconds(booth)), "expires_in": max(0.0, ttl_seconds - booth_age_seconds(booth)),
}, },
) )
@app.post("/b/{name}/answer")
def booth_answer(request: Request, name: str, ask: str = Form(...), choice: str = Form(...),
notes: str = Form("")):
"""Record the operator's answer to one ask: validates the choice
against the ask's options and writes `<stem>.answer.json` atomically.
Re-submitting overwrites — the sidecar is the current answer. 404 for
an unknown/invalid stem, 400 for a choice the ask does not offer."""
booth = resolve_booth(name)
if not valid_stem(ask) or not (booth / f"{ask}{ASK_SUFFIX}").is_file():
raise HTTPException(status_code=404, detail="no such ask")
who = request.client.host if request.client else ""
try:
write_answer(booth, ask, choice, notes, who=who)
except AskError as exc:
raise HTTPException(status_code=400, detail=str(exc))
return RedirectResponse(url=f"/b/{quote(name, safe='')}/#ask-{quote(ask, safe='')}", status_code=303)
@app.get("/b/{name}/view", response_class=HTMLResponse) @app.get("/b/{name}/view", response_class=HTMLResponse)
def booth_view_file(request: Request, name: str, f: str): def booth_view_file(request: Request, name: str, f: str):
booth = resolve_booth(name) booth = resolve_booth(name)
+212
View File
@@ -0,0 +1,212 @@
"""Asks: a session poses a multiple-choice question in a booth; the operator
answers it in the browser; the answer lands as a sidecar the session reads.
STDLIB ONLY, like links.py, so the `booth` CLI can write an ask and read an
answer without the service's venv.
Filesystem is the state, same as everything else in the Booth:
<booth>/<stem>.ask.json the question (written by a session)
<booth>/<stem>.answer.json the answer (written by the web UI)
Ask schema (what a session writes):
{"prompt": "Which render wins?",
"options": ["A — baseline", "B — cudaMallocAsync"], # ≥ 2, strings or
# [{"id": "a", "label": "A — baseline", "detail": "…"}, …]
"notes": true, # optional, default true: show a free-text field
"notes_label": "why?"} # optional placeholder for that field
Answer schema (what the operator's submit writes, atomically):
{"stem": "winner", "prompt": "…",
"choice": "b", # the option id (== label for string options)
"choice_index": 1, # 0-based position in `options`
"label": "B — cudaMallocAsync",
"notes": "less banding on the gradient",
"answered_at": "2026-09-09T07:12:03-07:00",
"answered_by": "10.100.10.20"}
Re-answering overwrites: the sidecar is the current answer, not a log. A
session that wants history keeps its own.
"""
from __future__ import annotations
import json
import os
import re
from datetime import datetime
from pathlib import Path
ASK_SUFFIX = ".ask.json"
ANSWER_SUFFIX = ".answer.json"
PROMPT_MAX = 2000
LABEL_MAX = 400
DETAIL_MAX = 1000
NOTES_MAX = 8000
MAX_OPTIONS = 40
_STEM_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._-]{0,120}$")
class AskError(ValueError):
"""An ask file that cannot be rendered — reported, never a crash."""
def is_ask_file(name: str) -> bool:
return name.endswith(ASK_SUFFIX) and len(name) > len(ASK_SUFFIX)
def is_answer_file(name: str) -> bool:
return name.endswith(ANSWER_SUFFIX) and len(name) > len(ANSWER_SUFFIX)
def ask_stem(name: str) -> str:
return name[: -len(ASK_SUFFIX)]
def valid_stem(stem: str) -> bool:
return bool(_STEM_RE.match(stem)) and ".." not in stem
def normalize_ask(raw: dict, stem: str) -> dict:
"""Validate + normalise an ask document. Raises AskError on anything the
renderer could not honour. Options come back as [{id, label, detail}]."""
if not isinstance(raw, dict):
raise AskError("ask must be a JSON object")
prompt = raw.get("prompt")
if not isinstance(prompt, str) or not prompt.strip():
raise AskError("ask needs a non-empty string `prompt`")
opts_in = raw.get("options")
if not isinstance(opts_in, list) or len(opts_in) < 2:
raise AskError("ask needs a list `options` with at least 2 entries")
if len(opts_in) > MAX_OPTIONS:
raise AskError(f"too many options (max {MAX_OPTIONS})")
options: list[dict] = []
seen: set[str] = set()
for i, o in enumerate(opts_in):
if isinstance(o, str):
oid, label, detail = o, o, ""
elif isinstance(o, dict):
label = o.get("label")
if not isinstance(label, str) or not label.strip():
raise AskError(f"option {i} needs a non-empty string `label`")
oid = o.get("id", label)
detail = o.get("detail", "") or ""
if not isinstance(oid, str) or not oid.strip():
raise AskError(f"option {i} has a bad `id`")
if not isinstance(detail, str):
raise AskError(f"option {i} has a non-string `detail`")
else:
raise AskError(f"option {i} must be a string or an object")
oid = oid.strip()
if oid in seen:
raise AskError(f"duplicate option id {oid!r}")
seen.add(oid)
options.append({"id": oid, "label": label.strip()[:LABEL_MAX], "detail": detail.strip()[:DETAIL_MAX]})
notes = raw.get("notes", True)
if not isinstance(notes, bool):
raise AskError("`notes` must be true/false")
notes_label = raw.get("notes_label", "notes")
if not isinstance(notes_label, str):
raise AskError("`notes_label` must be a string")
return {
"stem": stem,
"prompt": prompt.strip()[:PROMPT_MAX],
"options": options,
"notes": notes,
"notes_label": notes_label.strip()[:80] or "notes",
}
def load_ask(booth: Path, stem: str) -> dict:
"""Parsed + normalised ask for `stem`. Raises AskError if unreadable/invalid."""
path = Path(booth) / f"{stem}{ASK_SUFFIX}"
try:
raw = json.loads(path.read_text(encoding="utf-8"))
except FileNotFoundError:
raise AskError("no such ask")
except (OSError, ValueError) as exc:
raise AskError(f"unreadable ask: {exc}")
return normalize_ask(raw, stem)
def read_answer(booth: Path, stem: str) -> dict | None:
path = Path(booth) / f"{stem}{ANSWER_SUFFIX}"
try:
data = json.loads(path.read_text(encoding="utf-8"))
except FileNotFoundError:
return None
except (OSError, ValueError):
return None
return data if isinstance(data, dict) else None
def list_asks(booth: Path) -> list[dict]:
"""Every ask in a booth (top level only), oldest first by file mtime, each
with its current answer folded in (`answer` is None while open). An invalid
ask file is returned with `error` set so the page can say so instead of
silently hiding the question a session thinks it posted."""
booth = Path(booth)
out: list[dict] = []
if not booth.is_dir():
return out
files = [p for p in booth.iterdir() if p.is_file() and not p.name.startswith(".") and is_ask_file(p.name)]
files.sort(key=lambda p: (p.stat().st_mtime, p.name))
for p in files:
stem = ask_stem(p.name)
try:
ask = load_ask(booth, stem)
except AskError as exc:
out.append({"stem": stem, "prompt": None, "options": [], "notes": False,
"notes_label": "notes", "error": str(exc), "answer": None})
continue
ask["error"] = None
ask["answer"] = read_answer(booth, stem)
out.append(ask)
return out
def write_answer(booth: Path, stem: str, choice: str, notes: str = "", who: str = "") -> dict:
"""Record the operator's answer. Validates the choice against the ask,
writes `<stem>.answer.json` via temp-file + os.replace so a reader never
sees a half-written document. Returns the answer written."""
ask = load_ask(booth, stem) # raises AskError if the ask is gone/invalid
idx = next((i for i, o in enumerate(ask["options"]) if o["id"] == choice), None)
if idx is None:
raise AskError("choice is not one of the ask's options")
notes = (notes or "").replace("\r\n", "\n").strip()[:NOTES_MAX]
answer = {
"stem": stem,
"prompt": ask["prompt"],
"choice": choice,
"choice_index": idx,
"label": ask["options"][idx]["label"],
"notes": notes if ask["notes"] else "",
"answered_at": datetime.now().astimezone().isoformat(timespec="seconds"),
"answered_by": who or "",
}
path = Path(booth) / f"{stem}{ANSWER_SUFFIX}"
tmp = path.with_suffix(path.suffix + ".tmp")
tmp.write_text(json.dumps(answer, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
os.replace(tmp, path)
return answer
def write_ask(booth: Path, stem: str, prompt: str, options: list, notes: bool = True,
notes_label: str = "notes") -> Path:
"""Author an ask from code/CLI. Validates through the same normaliser the
renderer uses, so a session cannot post a question the page would reject."""
if not valid_stem(stem):
raise AskError("bad stem: letters, digits, . _ - only")
doc = {"prompt": prompt, "options": options, "notes": notes, "notes_label": notes_label}
normalize_ask(doc, stem)
booth = Path(booth)
booth.mkdir(parents=True, exist_ok=True)
path = booth / f"{stem}{ASK_SUFFIX}"
tmp = path.with_suffix(path.suffix + ".tmp")
tmp.write_text(json.dumps(doc, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
os.replace(tmp, path)
return path
+54
View File
@@ -257,6 +257,60 @@
backdrop-filter:blur(6px);transition:.14s var(--ease-out)} backdrop-filter:blur(6px);transition:.14s var(--ease-out)}
.wipe button:hover{border-color:var(--aus-red);color:#fff;background:var(--aus-red)} .wipe button:hover{border-color:var(--aus-red);color:#fff;background:var(--aus-red)}
/* Asks — a session's multiple-choice question awaiting the operator.
Amber = "needs you" while open (the one colour the page does not otherwise
use for state), green check once answered; the accent is a TOP edge, per
Australis, never a coloured left border. */
.badge-ask{background:var(--aus-bright-yellow);color:var(--fg-on-accent)}
.thumb .badge+.badge-ask{top:2.2rem}
.asks{display:flex;flex-direction:column;gap:.9rem;margin:.2rem 0 1.4rem}
.ask{border:1px solid var(--border-subtle);border-top:2px solid var(--aus-bright-yellow);
border-radius:.5rem;background:var(--rk-panel);overflow:hidden}
.ask.is-answered{border-top-color:var(--aus-bright-green)}
.ask.is-broken{border-top-color:var(--aus-bright-red)}
.ask-head{display:flex;align-items:center;gap:.6rem;padding:.4rem .8rem;
border-bottom:1px solid var(--border-subtle);background:var(--rk-well);
font-family:var(--font-mono);font-size:.68rem;color:var(--fg-3)}
.ask-state{font-weight:700;letter-spacing:.08em;text-transform:uppercase;color:var(--aus-bright-yellow)}
.ask.is-answered .ask-state{color:var(--aus-bright-green)}
.ask.is-broken .ask-state{color:var(--aus-bright-red)}
.ask-when{white-space:nowrap}
.ask-prompt{margin:.85rem .9rem .5rem;font-size:1.02rem;font-weight:600;color:var(--fg-0);white-space:pre-wrap}
.ask-error{margin:.8rem .9rem;color:var(--aus-bright-red);font-size:.85rem}
.ask-answer{margin:.2rem .9rem .6rem;padding:.55rem .75rem;border:1px solid var(--border-subtle);
border-radius:var(--radius-md);background:rgba(81,224,138,.06)}
.ask-answer-choice{font-weight:600;color:var(--fg-0)}
.ask-answer-choice::before{content:"✓ ";color:var(--aus-bright-green)}
.ask-answer-notes{margin:.4rem 0 0;white-space:pre-wrap;font-family:var(--font-sans);font-size:.86rem;
color:var(--fg-1)}
.ask-answer-file{display:block;margin-top:.35rem;font-family:var(--font-mono);font-size:.68rem;color:var(--fg-3)}
.ask-answer-file a{color:var(--fg-2)}
.ask-formwrap{margin:0 .9rem .8rem}
.ask-change{cursor:pointer;font-family:var(--font-mono);font-size:.7rem;letter-spacing:.06em;
text-transform:uppercase;color:var(--fg-3);list-style:none;user-select:none}
.ask-change::-webkit-details-marker{display:none}
.ask-formwrap[open]>.ask-change{margin-bottom:.4rem}
.ask-formwrap:not([open])>.ask-change{color:var(--aus-bright-cyan)}
.ask-options{display:flex;flex-direction:column;gap:.35rem}
.ask-opt{display:flex;align-items:flex-start;gap:.6rem;padding:.5rem .65rem;cursor:pointer;
border:1px solid var(--border-subtle);border-radius:var(--radius-md);background:var(--rk-well);
transition:border-color .12s,background .12s}
.ask-opt:hover{border-color:var(--border-strong)}
.ask-opt:has(input:checked){border-color:var(--aus-bright-cyan);background:rgba(66,220,209,.07)}
.ask-opt input{margin:.2rem 0 0;accent-color:var(--aus-bright-cyan);flex:0 0 auto}
.ask-opt-main{display:flex;flex-direction:column;gap:.1rem;min-width:0}
.ask-opt-label{font-size:.92rem;color:var(--fg-0)}
.ask-opt-detail{font-size:.76rem;color:var(--fg-3);white-space:pre-wrap}
.ask-notes{display:block;width:100%;box-sizing:border-box;margin:.6rem 0 0;padding:.5rem .6rem;
font:inherit;font-size:.88rem;color:var(--fg-0);background:var(--rk-well);
border:1px solid var(--border-subtle);border-radius:var(--radius-md);resize:vertical}
.ask-notes:focus{outline:none;border-color:var(--aus-bright-cyan);box-shadow:var(--glow-cyan)}
.ask-actions{display:flex;justify-content:flex-end;margin-top:.6rem}
.ask-submit{cursor:pointer;font-family:var(--font-mono);font-size:.74rem;letter-spacing:.06em;
padding:.42rem .9rem;border-radius:var(--radius-sm);border:1px solid var(--aus-bright-cyan);
background:var(--aus-bright-cyan);color:var(--fg-on-accent);font-weight:700;transition:.14s var(--ease-out)}
.ask-submit:hover{background:var(--aus-cyan);border-color:var(--aus-cyan)}
/* booth page */ /* booth page */
.boothhead{display:flex;align-items:center;gap:1rem;flex-wrap:wrap; .boothhead{display:flex;align-items:center;gap:1rem;flex-wrap:wrap;
padding-bottom:1rem;margin-bottom:1.4rem;border-bottom:1px solid var(--border-subtle)} padding-bottom:1rem;margin-bottom:1.4rem;border-bottom:1px solid var(--border-subtle)}
+58 -2
View File
@@ -4,7 +4,7 @@
<div class="boothhead"> <div class="boothhead">
<a class="back" href="/">‹ all booths</a> <a class="back" href="/">‹ all booths</a>
<h1>{{ name }}</h1> <h1>{{ name }}</h1>
<span class="sub">{% if uploaded %}<span class="badge">⬆ pickup</span> {% endif %}{% if board %}{{ board|length }} link{{ '' if board|length == 1 else 's' }}{% if items %} · {{ items|length }} file{{ '' if items|length == 1 else 's' }}{% endif %}{% else %}{{ items|length }} item{{ '' if items|length == 1 else 's' }} · expires in {{ expires_in|dur }}{% endif %}</span> <span class="sub">{% if uploaded %}<span class="badge">⬆ pickup</span> {% endif %}{% if board %}{{ board|length }} link{{ '' if board|length == 1 else 's' }}{% if items %} · {{ items|length }} file{{ '' if items|length == 1 else 's' }}{% endif %}{% else %}{% set open_asks = asks|selectattr('answer', 'none')|rejectattr('error')|list|length %}{% if open_asks %}<span class="badge badge-ask">{{ open_asks }} open ask{{ '' if open_asks == 1 else 's' }}</span> · {% endif %}{{ items|length }} item{{ '' if items|length == 1 else 's' }} · expires in {{ expires_in|dur }}{% endif %}</span>
{% if items %}<a class="dl-link" href="/b/{{ name_url }}/?download=1" title="download this booth as a zip">⬇ zip</a>{% endif %} {% if items %}<a class="dl-link" href="/b/{{ name_url }}/?download=1" title="download this booth as a zip">⬇ zip</a>{% endif %}
{# A durable multi-writer board gets no one-click wipe — same rule as the {# A durable multi-writer board gets no one-click wipe — same rule as the
kept lane on the index. Remove rows with the per-row ×, or release the kept lane on the index. Remove rows with the per-row ×, or release the
@@ -25,6 +25,62 @@
</div> </div>
{% endif %} {% endif %}
{% if asks %}
{# ASKS. A session left multiple-choice questions here for the operator
(`<stem>.ask.json`). Open ones render as a radio form; answering POSTs to
/answer, which writes `<stem>.answer.json` for the session to read. Works
with JS off — plain form POST. Answered asks show the recorded answer and a
collapsed "change" form, since the sidecar is the CURRENT answer. #}
<section class="asks">
{% for a in asks %}
<article class="ask{% if a.answer %} is-answered{% elif a.error %} is-broken{% endif %}" id="ask-{{ a.stem }}">
<header class="ask-head">
<span class="ask-state">{% if a.error %}⚠ broken{% elif a.answer %}✓ answered{% else %}? open{% endif %}</span>
<span class="ask-stem"><code>{{ a.stem }}.ask.json</code></span>
<span class="board-spacer"></span>
{% if a.answer %}<span class="ask-when">{{ a.answer.answered_at }}{% if a.answer.answered_by %} · {{ a.answer.answered_by }}{% endif %}</span>{% endif %}
</header>
{% if a.error %}
<p class="ask-error">This ask could not be read: {{ a.error }}</p>
{% else %}
<p class="ask-prompt">{{ a.prompt }}</p>
{% if a.answer %}
<div class="ask-answer">
<div class="ask-answer-choice">{{ a.answer.label }}</div>
{% if a.answer.notes %}<pre class="ask-answer-notes">{{ a.answer.notes }}</pre>{% endif %}
<span class="ask-answer-file">→ <a href="{{ a.stem }}.answer.json">{{ a.stem }}.answer.json</a></span>
</div>
{% endif %}
<details class="ask-formwrap"{% if not a.answer %} open{% endif %}>
<summary class="ask-change">{% if a.answer %}change answer{% else %}answer{% endif %}</summary>
<form class="ask-form" method="post" action="/b/{{ name_url }}/answer">
<input type="hidden" name="ask" value="{{ a.stem }}">
<div class="ask-options">
{% for o in a.options %}
<label class="ask-opt{% if a.answer and a.answer.choice == o.id %} is-current{% endif %}">
<input type="radio" name="choice" value="{{ o.id }}" required
{% if a.answer and a.answer.choice == o.id %}checked{% endif %}>
<span class="ask-opt-main">
<span class="ask-opt-label">{{ o.label }}</span>
{% if o.detail %}<span class="ask-opt-detail">{{ o.detail }}</span>{% endif %}
</span>
</label>
{% endfor %}
</div>
{% if a.notes %}
<textarea class="ask-notes" name="notes" rows="3" placeholder="{{ a.notes_label }} (optional)">{{ a.answer.notes if a.answer else '' }}</textarea>
{% endif %}
<div class="ask-actions">
<button type="submit" class="ask-submit">{% if a.answer %}Update answer{% else %}Submit answer{% endif %}</button>
</div>
</form>
</details>
{% endif %}
</article>
{% endfor %}
</section>
{% endif %}
{% if board %} {% if board %}
{# THE STANDING LINK BOARD. Every agent session on the fleet appends here, so {# THE STANDING LINK BOARD. Every agent session on the fleet appends here, so
this is the one booth where the useful granularity is the ROW, not the this is the one booth where the useful granularity is the ROW, not the
@@ -77,7 +133,7 @@
</form> </form>
{% endif %} {% endif %}
{% if not items and not board %} {% if not items and not board and not asks %}
<div class="empty">This booth is empty.</div> <div class="empty">This booth is empty.</div>
{% elif items %} {% elif items %}
{# `elif items` and not a bare `else`: a board booth has NO gallery items (its {# `elif items` and not a bare `else`: a board booth has NO gallery items (its
@@ -81,6 +81,7 @@
<div class="ph">◆ files</div> <div class="ph">◆ files</div>
{% endif %} {% endif %}
{% if b.uploaded %}<span class="badge">⬆ pickup</span>{% endif %} {% if b.uploaded %}<span class="badge">⬆ pickup</span>{% endif %}
{% if b.asks_open %}<span class="badge badge-ask">? {{ b.asks_open }} ask{{ '' if b.asks_open == 1 else 's' }}</span>{% endif %}
</a> </a>
<div class="meta"> <div class="meta">
<a class="name" href="/b/{{ b.name_url }}/">{{ b.name }}</a> <a class="name" href="/b/{{ b.name_url }}/">{{ b.name }}</a>
+1 -1
View File
@@ -1,6 +1,6 @@
[project] [project]
name = "booth" name = "booth"
version = "0.1.8" version = "0.1.9"
description = "The Booth — a dead-simple standing web server that scans a data dir of drop-folders and renders each as an ephemeral media 'booth' (image/webm/audio auto-gallery, or a folder's own index.html verbatim). Also accepts browser/curl uploads for pickup under a human-readable id. 24h TTL, then the folder is wiped. Fleet tool for CC sessions to surface A/B and smoke results to the operator." description = "The Booth — a dead-simple standing web server that scans a data dir of drop-folders and renders each as an ephemeral media 'booth' (image/webm/audio auto-gallery, or a folder's own index.html verbatim). Also accepts browser/curl uploads for pickup under a human-readable id. 24h TTL, then the folder is wiped. Fleet tool for CC sessions to surface A/B and smoke results to the operator."
requires-python = ">=3.11" requires-python = ">=3.11"
dependencies = [ dependencies = [
+77 -1
View File
@@ -15,6 +15,22 @@
# booth links list the board, numbered, with entry ids # booth links list the board, numbered, with entry ids
# booth unlink <id|index> remove ONE link from the board # booth unlink <id|index> remove ONE link from the board
# #
# booth ask <name> <stem> <prompt> <option>... [--no-notes]
# pose a multiple-choice question in a booth
# booth asks <name> list a booth's asks and whether each is answered
# booth answer <name> <stem> [--wait [SECS]]
# print the answer JSON (exit 1 if unanswered);
# --wait polls until it lands (default 3600 s)
#
# ASKS. A session needs the operator to pick one of N things — which render,
# which plan, go/no-go — and act on the pick. `ask` writes <stem>.ask.json into
# a booth; the page renders it as a radio form with a notes field; submitting
# writes <stem>.answer.json next to it. `answer --wait` blocks until that file
# exists and prints it, so a session can `booth ask … && booth answer --wait …`
# and carry on. Re-answering overwrites: the sidecar is the CURRENT answer.
# Remote sessions: rsync the ask in, then poll
# http://10.100.10.50:8090/b/<name>/<stem>.answer.json (404 until answered).
#
# THE 24h RULE AND ITS ONE EXCEPTION. Every booth is wiped 24h after its last # THE 24h RULE AND ITS ONE EXCEPTION. Every booth is wiped 24h after its last
# activity — that is the contract, and it is why nobody has to clean up after # activity — that is the contract, and it is why nobody has to clean up after
# themselves. `keep` drops a `.forever` sentinel that exempts one booth from the # themselves. `keep` drops a `.forever` sentinel that exempts one booth from the
@@ -47,7 +63,7 @@ KEEP=".forever" # must match KEEP_MARKER in b
LINKS_BOARD="${BOOTH_LINKS_BOARD:-links}" LINKS_BOARD="${BOOTH_LINKS_BOARD:-links}"
usage() { usage() {
echo "usage: booth {new <name>|add <name> <file>...|url <name>|ls|rm <name>|keep <name>|unkeep <name>|link <url> [description]|links|unlink <id|index>}" >&2 echo "usage: booth {new <name>|add <name> <file>...|url <name>|ls|rm <name>|keep <name>|unkeep <name>|link <url> [description]|links|unlink <id|index>|ask <name> <stem> <prompt> <option>... [--no-notes]|asks <name>|answer <name> <stem> [--wait [SECS]]}" >&2
exit 2 exit 2
} }
@@ -175,5 +191,65 @@ if removed is None:
print("removed: %s %s" % (removed["desc"], removed["url"])) print("removed: %s %s" % (removed["desc"], removed["url"]))
' "$board" "$target" ' "$board" "$target"
;; ;;
ask)
# booth ask <name> <stem> <prompt> <opt>... [--no-notes]
[ $# -ge 5 ] || usage
name="$1"; stem="$2"; prompt="$3"; shift 3
notes=1; opts=()
for a in "$@"; do
case "$a" in --no-notes) notes=0 ;; *) opts+=("$a") ;; esac
done
[ "${#opts[@]}" -ge 2 ] || { echo "an ask needs at least 2 options" >&2; exit 1; }
# Validated through the SAME normaliser the page uses, so a session cannot
# post a question the renderer would refuse. stdlib only — no venv needed.
BOOTH_SRC="$(cd "$(dirname -- "$0")/.." && pwd)" ASK_NOTES="$notes" python3 -c '
import os, pathlib, sys
sys.path.insert(0, os.environ["BOOTH_SRC"])
from booth.asks import AskError, write_ask
booth, stem, prompt, *opts = sys.argv[1:]
try:
write_ask(pathlib.Path(booth), stem, prompt, opts, notes=os.environ["ASK_NOTES"] == "1")
except AskError as exc:
sys.exit("bad ask: %s" % exc)
' "$DATA/$name" "$stem" "$prompt" "${opts[@]}"
echo "$URL/b/$name/#ask-$stem"
;;
asks)
[ $# -ge 1 ] || usage
BOOTH_SRC="$(cd "$(dirname -- "$0")/.." && pwd)" python3 -c '
import os, pathlib, sys
sys.path.insert(0, os.environ["BOOTH_SRC"])
from booth.asks import list_asks
asks = list_asks(pathlib.Path(sys.argv[1]))
if not asks:
print("no asks in this booth")
for a in asks:
if a["error"]:
state = "BROKEN " + a["error"]
elif a["answer"]:
state = "answered %s (%s)" % (a["answer"]["label"], a["answer"]["answered_at"])
else:
state = "open"
print("%-24s %s" % (a["stem"], state))
' "$DATA/$1"
;;
answer)
# booth answer <name> <stem> [--wait [SECS]]
[ $# -ge 2 ] || usage
name="$1"; stem="$2"; shift 2
wait_s=0
if [ "${1:-}" = "--wait" ]; then wait_s="${2:-3600}"; fi
f="$DATA/$name/$stem.answer.json"
[ -f "$DATA/$name/$stem.ask.json" ] || { echo "no such ask: $name/$stem" >&2; exit 1; }
# Poll, do not inotify: the answer is written by a different process via
# os.replace, and a 2 s cadence is plenty for a human clicking a radio.
deadline=$(( $(date +%s) + wait_s ))
while [ ! -f "$f" ]; do
if [ "$wait_s" -eq 0 ]; then echo "unanswered: $URL/b/$name/#ask-$stem" >&2; exit 1; fi
if [ "$(date +%s)" -ge "$deadline" ]; then echo "timed out after ${wait_s}s waiting on $name/$stem" >&2; exit 1; fi
sleep 2
done
cat -- "$f"
;;
*) usage ;; *) usage ;;
esac esac
+216
View File
@@ -0,0 +1,216 @@
"""Asks: session poses a multiple-choice question; operator answers in the
browser; the answer lands as a sidecar the session reads."""
import json
import pathlib
import pytest
from fastapi.testclient import TestClient
from booth.app import build_gallery, create_app, list_booths
from booth.asks import (
ANSWER_SUFFIX,
ASK_SUFFIX,
AskError,
list_asks,
load_ask,
normalize_ask,
read_answer,
write_answer,
write_ask,
)
def _ask(booth, stem="winner", **kw):
doc = {"prompt": "Which render wins?", "options": ["A — baseline", "B — async"]}
doc.update(kw)
booth.mkdir(parents=True, exist_ok=True)
(booth / f"{stem}{ASK_SUFFIX}").write_text(json.dumps(doc))
return booth
@pytest.fixture
def client(tmp_path):
app = create_app(tmp_path, ttl_hours=24, start_sweeper=False)
return TestClient(app), tmp_path
# ---- normalisation ----------------------------------------------------------
def test_normalize_string_options():
a = normalize_ask({"prompt": " Pick ", "options": ["x", "y"]}, "s")
assert a["prompt"] == "Pick"
assert a["options"] == [{"id": "x", "label": "x", "detail": ""}, {"id": "y", "label": "y", "detail": ""}]
assert a["notes"] is True and a["notes_label"] == "notes"
def test_normalize_object_options_and_flags():
a = normalize_ask(
{"prompt": "p", "options": [{"id": "a", "label": "A", "detail": "d"}, {"label": "B"}],
"notes": False, "notes_label": "why"},
"s",
)
assert a["options"][0] == {"id": "a", "label": "A", "detail": "d"}
assert a["options"][1] == {"id": "B", "label": "B", "detail": ""}
assert a["notes"] is False and a["notes_label"] == "why"
@pytest.mark.parametrize(
"doc",
[
{"options": ["a", "b"]},
{"prompt": "", "options": ["a", "b"]},
{"prompt": "p", "options": ["only"]},
{"prompt": "p", "options": "a,b"},
{"prompt": "p", "options": ["a", "a"]},
{"prompt": "p", "options": [{"id": "a"}, "b"]},
{"prompt": "p", "options": ["a", "b"], "notes": "yes"},
[],
],
)
def test_normalize_rejects(doc):
with pytest.raises(AskError):
normalize_ask(doc, "s")
# ---- files ------------------------------------------------------------------
def test_load_ask_reports_bad_json(tmp_path):
(tmp_path / f"x{ASK_SUFFIX}").write_text("{not json")
with pytest.raises(AskError):
load_ask(tmp_path, "x")
with pytest.raises(AskError):
load_ask(tmp_path, "missing")
def test_list_asks_folds_answer_and_surfaces_errors(tmp_path):
_ask(tmp_path, "one")
_ask(tmp_path, "two")
(tmp_path / f"broken{ASK_SUFFIX}").write_text("[]")
(tmp_path / ".hidden.ask.json").write_text("{}") # dotfiles never listed
write_answer(tmp_path, "two", "B — async", "less banding", who="10.0.0.1")
asks = list_asks(tmp_path)
by = {a["stem"]: a for a in asks}
assert set(by) == {"one", "two", "broken"}
assert by["one"]["answer"] is None and by["one"]["error"] is None
assert by["two"]["answer"]["choice"] == "B — async"
assert by["two"]["answer"]["choice_index"] == 1
assert by["two"]["answer"]["notes"] == "less banding"
assert by["two"]["answer"]["answered_by"] == "10.0.0.1"
assert by["broken"]["error"] and by["broken"]["options"] == []
def test_write_answer_validates_choice_and_is_atomic(tmp_path):
_ask(tmp_path)
with pytest.raises(AskError):
write_answer(tmp_path, "winner", "C — nope")
with pytest.raises(AskError):
write_answer(tmp_path, "nosuch", "A — baseline")
ans = write_answer(tmp_path, "winner", "A — baseline", " ok \r\n")
assert ans["notes"] == "ok"
assert ans["answered_at"]
assert read_answer(tmp_path, "winner") == ans
assert not (tmp_path / f"winner{ANSWER_SUFFIX}.tmp").exists()
# re-answer overwrites — the sidecar is the CURRENT answer, not a log
write_answer(tmp_path, "winner", "B — async")
assert read_answer(tmp_path, "winner")["choice_index"] == 1
def test_write_answer_drops_notes_when_ask_disables_them(tmp_path):
_ask(tmp_path, notes=False)
assert write_answer(tmp_path, "winner", "A — baseline", "ignored")["notes"] == ""
def test_write_ask_roundtrip_and_stem_guard(tmp_path):
p = write_ask(tmp_path / "b", "pick", "Pick one", ["x", {"id": "y", "label": "Y"}], notes=False)
assert p.name == f"pick{ASK_SUFFIX}"
a = load_ask(tmp_path / "b", "pick")
assert [o["id"] for o in a["options"]] == ["x", "y"] and a["notes"] is False
for bad in ("../x", ".hidden", "a/b", ""):
with pytest.raises(AskError):
write_ask(tmp_path / "b", bad, "p", ["a", "b"])
with pytest.raises(AskError):
write_ask(tmp_path / "b", "ok", "p", ["solo"])
# ---- gallery + index integration -------------------------------------------
def test_gallery_hides_ask_and_answer_files(tmp_path):
b = _ask(tmp_path / "b")
(b / "a.png").write_bytes(b"x")
write_answer(b, "winner", "A — baseline")
names = {it["name"] for it in build_gallery(b)}
assert names == {"a.png"}
def test_list_booths_counts_open_asks(tmp_path):
b = _ask(tmp_path / "b", "one")
_ask(b, "two")
write_answer(b, "two", "A — baseline")
(tmp_path / "plain").mkdir()
by = {x["name"]: x for x in list_booths(tmp_path, 3600)}
assert by["b"]["asks_open"] == 1 and by["b"]["asks_total"] == 2
assert by["plain"]["asks_open"] == 0 and by["plain"]["asks_total"] == 0
assert by["b"]["count"] == 0 # ask/answer files are not "items"
# ---- routes -----------------------------------------------------------------
def test_booth_page_renders_open_ask_as_form(client):
c, data = client
_ask(data / "b")
html = c.get("/b/b/").text
assert "Which render wins?" in html
assert 'type="radio"' in html and 'name="choice"' in html
assert 'value="B — async"' in html
assert 'action="/b/b/answer"' in html
assert "<textarea" in html
assert "1 open ask" in html
def test_answer_route_writes_sidecar_and_page_shows_it(client):
c, data = client
_ask(data / "b")
r = c.post("/b/b/answer", data={"ask": "winner", "choice": "B — async", "notes": "less banding"},
follow_redirects=False)
assert r.status_code == 303 and r.headers["location"] == "/b/b/#ask-winner"
ans = json.loads((data / "b" / f"winner{ANSWER_SUFFIX}").read_text())
assert ans["choice"] == "B — async" and ans["notes"] == "less banding"
assert ans["answered_by"] # TestClient's client addr
html = c.get("/b/b/").text
assert "answered" in html and "less banding" in html
assert "1 open ask" not in html
# the sidecar is fetchable over HTTP for remote sessions
assert c.get("/b/b/winner.answer.json").json()["choice"] == "B — async"
def test_answer_route_rejects_bad_choice_and_unknown_ask(client):
c, data = client
_ask(data / "b")
assert c.post("/b/b/answer", data={"ask": "winner", "choice": "Z"}).status_code == 400
assert c.post("/b/b/answer", data={"ask": "nosuch", "choice": "A — baseline"}).status_code == 404
assert c.post("/b/b/answer", data={"ask": "../x", "choice": "A — baseline"}).status_code == 404
assert not (data / "b" / f"winner{ANSWER_SUFFIX}").exists()
def test_answer_json_404s_until_answered(client):
c, data = client
_ask(data / "b")
assert c.get("/b/b/winner.answer.json").status_code == 404
def test_notes_field_hidden_when_disabled(client):
c, data = client
_ask(data / "b", notes=False)
assert "<textarea" not in c.get("/b/b/").text
def test_index_card_shows_open_ask_badge(client):
c, data = client
_ask(data / "b")
html = c.get("/").text
assert "1 ask" in html