Files
booth/scripts/booth
T
Vuong Hoang 95beede3c3 fix(manifest)!: the size cap opened a service-wide hang; close it
The diff-scoped bug-hunt panel, four arms, artifact-only. Its strongest
finding is one I created two hours earlier while hardening the reader.

`stat` reports size 0 for a FIFO and 0 for a symlink to /dev/zero, so both
sail under the byte cap added for the RecursionError round — and then
`read_text` either blocks in read() with no EOF, so the except never runs,
or allocates until the kernel intervenes. `list_booths` reads every booth
on every GET / and /healthz, so ONE such file stalls the front page for the
whole service, with no error and no recovery short of a restart.
Reproduced before believing it (timeout returned 124). S_ISREG is checked
BEFORE the size in both modules now; verified against the live service with
two FIFOs planted, which answered 200 in 36ms.

The shape worth carrying: st_size answers a different question than "can
this be read", and a bound that trusts it inherits everything it does not
mean. A hardening fix opened a worse hole than the one it closed.

THE UPLOAD PATH WROTE ABOVE ITS OWN CLEANUP GUARD (4/4)

A failed manifest write orphaned a .uploaded half-booth with no files in
it — and because the temp name now carries a random suffix, nothing ever
overwrote the leak, and .booth.json.<hex>.tmp is not a .lock, so
_newest_mtime counted it and kept that empty booth past every sweep. The
uniqueness fix from the previous round is what made the leak permanent.
Both writes moved inside the guard; the temp is removed on every exit path.

DAMAGED BYTES ARE KEPT, NOT REPLACED (4/4, INV-6)

Marks made this explicit in v0.2.1 and this write path contradicted it: a
manifest that failed on ONE field lost the others with it, including a why
the re-announcer may never have kept anywhere. It diverges from marks in
HOW it honours the rule — marks refuse and answer 409 because the
operator's judgment is not restatable; a manifest quarantines and proceeds,
because refusing would fail `booth add` and lose the files it was copying.

ONE OPENNESS PREDICATE, AS U2 SAID (2/4)

`booth answer` spelled out `if m.answer is None` while `booth marks` asked
`open_marks`, so a partially-answered pick read as done to one verb and
open to the other — at the same instant, on the same booth. U2's INV-2 put
openness in one function precisely so they could not drift. The mirror case
is fixed too: a pick that hydrates broken is refused by the web route, so
`answer --wait` polled an hour on a form nothing could ever land.

ALSO

- now_stamp was whole-second while the importer had moved to microseconds,
  and '-' sorts before '.', so a later mark came out ahead of an earlier
  import inside the same second. One format; the previous round's ordering
  fix had opened this one.
- `_broken` was the third of three directory-name fallbacks and the one
  still handing a raw name into a card's sub-line.
- An identical re-announce rewrote the file and reset the TTL. `booth link`
  does this on every post to the standing board.
- The importer's return went through the bare _hydrate, not _hydrate_safe.
- A marks document could be written larger than it can be read back, and
  then read as no marks at all. Refused at the write instead.
- `choice` reached the answer builder raw while `notes` beside it did not.

AND ONE FINDING DELIBERATELY NOT FULLY CLOSED

The mtime-restore race is real. The clean fix — ignore a booth directory's
own mtime whenever the booth holds anything — also silently retires the
documented rule that releasing a kept board resets its clock, which the CLI
header, the README and a deliberately-written test all pin. That is a TTL
doctrine change, not a bug fix, and an existing test caught the attempt.
The concrete half is fixed (a failing os.utime escaped and 500'd the
route); the race is stated in the code where the next reader will meet it.

341 tests. Live service restarted, 24/24 booth pages verified.
2026-09-22 02:27:18 -07:00

507 lines
23 KiB
Bash
Executable File
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.
#!/usr/bin/env bash
# booth — post media and links to The Booth (dead simple). A booth is just a
# folder under $BOOTH_DATA_DIR; this is sugar over mkdir/cp so you get the URL
# back.
#
# booth new <name> [--why W] [--title T]
# make an empty booth, print its URL
# booth add <name> <file>... [--why W] [--title T]
# copy files into a booth (creates it), print URL
# booth url <name> print a booth's URL
# booth ls list booths (kept ones marked ★)
# booth rm <name> wipe a booth now (TTL would eventually anyway)
#
# booth keep <name> exempt a booth from the 24h sweep, forever
# booth unkeep <name> hand it back to the sweeper
# booth link <url> [description] append a link to the standing link board
# booth links list the board, numbered, with entry ids
# booth unlink <id|index> remove ONE link from the board
#
# booth ask <name> <id> <prompt> <option>... [--no-notes]
# pose a multiple-choice question in a booth
# booth marks <name> [--wait [SECS]] print every mark in a booth as JSON;
# --wait blocks while any pick is still open
# booth answer <name> <id> [--wait [SECS]]
# print ONE pick's answer (exit 1 if unanswered);
# --wait polls until it lands (default 3600 s)
#
# EXIT CODES for the two reading verbs. A read that FAILED gets its own code so
# a caller can tell "not yet" from "the file is damaged" — conflating them is
# how a broken `.marks.json` used to look like an unanswered question and wait
# out the full hour.
# marks 0 read ok · 1 --wait timed out with picks open · 3 unreadable
# answer 0 answered · 1 unanswered · 2 no such pick · 3 unreadable ·
# 4 the pick hydrated broken and can never be answered
#
# `answer` and `marks` use the SAME openness predicate. A partially-answered
# pick is still open to both; a broken one is closed to both.
# booth marks-import <name> import legacy *.ask.json into .marks.json
# booth asks <name> alias for `marks` (deprecated)
#
# MARKS. One primitive for operator judgment attached to an artifact:
# pick — one of N options a session declared in advance (this is `ask`)
# note — free text the operator volunteered
# flag — the operator pointing at one item
# All three are written by the OPERATOR IN THE BROWSER and read by the session.
# There are no `note` / `flag` verbs here on purpose: this CLI is the session's
# side of the loop, and a session does not author the operator's judgment.
#
# A session needs the operator to pick one of N things — which render, which
# plan, go/no-go — and act on the pick. `ask` declares it; the page renders a
# radio form with a notes field; submitting records the judgment. `answer --wait`
# blocks until it lands and prints it, so a session can
# `booth ask … && booth answer --wait …` and carry on. Re-answering overwrites:
# a mark is the CURRENT judgment, not a log. Several questions in ONE form: pass
# a `questions` list (see README § Asks); every verb handles both shapes.
#
# Marks live in ONE file per booth, `<booth>/.marks.json`, so "does this booth
# still owe an answer?" is a single read. Remote sessions have no filesystem
# access, so they poll the HTTP mirror instead:
# http://10.100.10.50:8090/b/<name>/marks.json
#
# 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
# themselves. `keep` drops a `.forever` sentinel that exempts one booth from the
# sweep and moves it into its own lane at the top of the index. Use it for
# durable operator-facing boards, not for run output. `unkeep` is just `rm` of
# the sentinel, so putting a board back under the sweeper costs nothing.
#
# DELETING A KEPT BOARD: `booth rm <name>` works on kept boards too and deletes
# NOW — it announces that the board was kept, so wiping something durable is
# never silent. In the web UI it is two deliberate steps: `release` on the kept
# card drops the sentinel, the card moves to the ephemeral lane, and the × wipes
# it from there.
#
# 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 24h. Unkeep-and-wait
# is a delay, not a delete. Use `rm` (or the UI ×) when you mean now.
#
# `link` is the reason the exception exists: agent sessions hand the operator
# URLs that then drown in terminal scrollback. They go on a standing kept board
# instead, with provenance, so they outlive the session that produced them.
#
# ANNOUNCE YOUR BOOTH. `--why` is one line saying what the operator is looking
# at and why he should care; it lands on the index card and on the booth page
# beside your handle, taken from $ALTHING_HANDLE. It is optional and nothing
# breaks without it — but a booth that cannot say what it is has no way to ask
# for attention except by posting its URL somewhere, which is exactly how the
# link board came to be 69% dead rows. The booth is the place to say it.
#
# booth add r18-ab out/*.png --why "pick the denoiser, left column is v3"
#
# Re-announcing (a second `new` or `add` on the same booth) updates the why and
# KEEPS the original creation stamp: the booth appeared once.
#
# On a host that is NOT nh3-dev, rsync into the data dir instead, e.g.:
# rsync -a ./out/ nh3-dev:booth-data/my-run/
set -euo pipefail
DATA="${BOOTH_DATA_DIR:-$HOME/booth-data}"
URL="${BOOTH_URL:-http://10.100.10.50:8090}"
KEEP=".forever" # must match KEEP_MARKER in booth/app.py
BLUR=".blurred" # one booth-relative item path per line; see `blur` below
LINKS_BOARD="${BOOTH_LINKS_BOARD:-links}"
# `--why` / `--title` for `new` and `add`. Pulled out of "$@" wherever they
# appear, so `booth add b *.png --why "..."` and `booth add b --why "..." *.png`
# both work — a glob is usually last and a flag usually after it, but nothing
# enforces that and a session should not have to care.
# OMITTED IS NOT EMPTY. `booth new x --why "..."` then `booth add x out/*.png`
# is the ordinary sequence, and while an omitted flag meant "" the second
# command silently erased the sentence the first one existed to record. So the
# shell tracks WHETHER the flag was given, and only passes it on when it was —
# an explicit `--why ""` still clears, which is a different intention.
WHY=""; TITLE=""; WHY_SET=0; TITLE_SET=0; ARGS=()
strip_announce_flags() {
ARGS=(); WHY_SET=0; TITLE_SET=0
while [ $# -gt 0 ]; do
case "$1" in
--why) [ $# -ge 2 ] || usage; WHY="$2"; WHY_SET=1; shift 2 ;;
--title) [ $# -ge 2 ] || usage; TITLE="$2"; TITLE_SET=1; shift 2 ;;
--why=*) WHY="${1#--why=}"; WHY_SET=1; shift ;;
--title=*) TITLE="${1#--title=}"; TITLE_SET=1; shift ;;
*) ARGS+=("$1"); shift ;;
esac
done
}
# Announce a booth. Goes through booth/manifest.py rather than printf-ing JSON
# from the shell, because a why containing a quote, a backslash or a newline is
# not an edge case — it is a sentence somebody wrote.
# announce <dir> <handle> [title] [why] — the trailing two are passed as
# environment variables that are UNSET when the flag was not given, because
# that is the only way the shell can say "leave it alone" rather than "".
announce() {
local -a envs
envs=( "BOOTH_SRC=$(cd "$(dirname -- "$(readlink -f -- "$0")")/.." && pwd)"
"BOOTH_ANN_DIR=$1" "BOOTH_ANN_HANDLE=$2" )
[ "${TITLE_SET:-0}" = 1 ] && envs+=( "BOOTH_ANN_TITLE=${3:-}" )
[ "${WHY_SET:-0}" = 1 ] && envs+=( "BOOTH_ANN_WHY=${4:-}" )
env "${envs[@]}" python3 -c '
import os, pathlib, sys
sys.path.insert(0, os.environ["BOOTH_SRC"])
try:
from booth.manifest import write_manifest
kw = {}
# Absent means the flag was omitted; present-and-empty means it was given
# as "" and the poster meant to take the line back.
if "BOOTH_ANN_TITLE" in os.environ: kw["title"] = os.environ["BOOTH_ANN_TITLE"]
if "BOOTH_ANN_WHY" in os.environ: kw["why"] = os.environ["BOOTH_ANN_WHY"]
write_manifest(pathlib.Path(os.environ["BOOTH_ANN_DIR"]),
os.environ["BOOTH_ANN_HANDLE"], **kw)
except Exception as exc:
# A booth that could not announce itself is still a booth. Say so on stderr
# and carry on: failing `booth add` over its metadata would lose the files
# the session just copied, which is a far worse trade.
print(f"booth: could not write the announcement: {exc}", file=sys.stderr)
'
}
# Who is posting. The same chain `link` uses for its rows, so provenance means
# the same thing on the board and on the card.
whoami_handle() {
echo "${ALTHING_HANDLE:-${BOOTH_SOURCE:-$(hostname -s 2>/dev/null || echo unknown)}}"
}
usage() {
echo "usage: booth {new <name> [--why W] [--title T]|add <name> <file>... [--why W] [--title T]|url <name>|ls|rm <name>|keep <name>|unkeep <name>|blur <name> <file>...|unblur <name> <file>...|link <url> [description]|links|unlink <id|index>|ask <name> <id> <prompt> <option>... [--no-notes]|marks <name> [--wait [SECS]]|asks <name> (deprecated alias for marks)|answer <name> <id> [--wait [SECS]]|marks-import <name>}" >&2
exit 2
}
cmd="${1:-}"; shift || true
case "$cmd" in
new)
strip_announce_flags "$@"
set -- ${ARGS+"${ARGS[@]}"}
[ $# -ge 1 ] || usage
mkdir -p -- "$DATA/$1"
announce "$DATA/$1" "$(whoami_handle)" "$TITLE" "$WHY"
echo "$URL/b/$1/"
;;
add)
strip_announce_flags "$@"
set -- ${ARGS+"${ARGS[@]}"}
[ $# -ge 2 ] || usage
name="$1"; shift
mkdir -p -- "$DATA/$name"
cp -- "$@" "$DATA/$name/"
announce "$DATA/$name" "$(whoami_handle)" "$TITLE" "$WHY"
echo "$URL/b/$name/"
;;
url)
[ $# -ge 1 ] || usage
echo "$URL/b/$1/"
;;
ls)
[ -d "$DATA" ] || exit 0
for d in "$DATA"/*/; do
[ -d "$d" ] || continue
n="$(basename -- "$d")"
if [ -e "$d$KEEP" ]; then echo "★ $n"; else echo " $n"; fi
done
;;
rm)
[ $# -ge 1 ] || usage
# Say so when the thing destroyed was durable. Not a block — a CLI user
# naming a booth is being explicit — but a kept board disappearing must not
# look identical to run output disappearing.
was_kept=""
[ -e "$DATA/$1/$KEEP" ] && was_kept=" (was KEPT — durable board)"
rm -rf -- "${DATA:?}/$1"
echo "wiped $1$was_kept"
;;
keep)
[ $# -ge 1 ] || usage
[ -d "$DATA/$1" ] || { echo "no such booth: $1" >&2; exit 1; }
: > "$DATA/$1/$KEEP"
echo "kept (exempt from the sweep): $URL/b/$1/"
;;
unkeep)
[ $# -ge 1 ] || usage
rm -f -- "$DATA/$1/$KEEP"
echo "unkept — $1 rejoins the 24h sweep"
;;
blur|unblur)
# ⚠ COSMETIC ONLY. A blurred item is still served at its own URL, still in
# the zip, still on disk. This hides it from a glance — a shoulder, a
# screen-share, a scroll past something you did not want full-size. The
# Booth has no auth by design: if a thing must not be SEEN, it must not be
# in a booth.
[ $# -ge 2 ] || usage
b="$1"; shift
[ -d "$DATA/$b" ] || { echo "no such booth: $b" >&2; exit 1; }
f="$DATA/$b/$BLUR"
for item in "$@"; do
item="${item#"$DATA/$b/"}"; item="${item#/}"
case "$item" in
*..*) echo "refusing path with '..': $item" >&2; exit 2 ;;
esac
[ -e "$DATA/$b/$item" ] || echo "warning: no such item in $b: $item" >&2
touch "$f"
if [ "$cmd" = blur ]; then
grep -qxF -- "$item" "$f" || printf '%s\n' "$item" >> "$f"
else
grep -vxF -- "$item" "$f" > "$f.tmp" || true
mv -- "$f.tmp" "$f"
fi
done
# An empty marker is a lie by omission — `ls -a` should say whether
# anything here is blurred at all.
[ -s "$f" ] || rm -f -- "$f"
if [ "$cmd" = blur ]; then
echo "blurred (cosmetic — still served): $URL/b/$b/"
else
echo "un-blurred: $URL/b/$b/"
fi
;;
link)
[ $# -ge 1 ] || usage
link_url="$1"; shift
desc="${*:-}"
board="$DATA/$LINKS_BOARD"
mkdir -p -- "$board"
: > "$board/$KEEP" # the board is durable by definition
# The board announces itself as the SERVICE's, not as any one agent's:
# seventeen handles post to it, so no handle owns it. Idempotent — a second
# link keeps the original creation stamp.
TITLE_SET=1 WHY_SET=1 announce "$board" "booth" "$LINKS_BOARD" \
"the standing link board — every agent session posts here"
# Provenance, because a bare URL is unreadable three days later: who posted
# it, from where, and when.
who="${ALTHING_HANDLE:-${BOOTH_SOURCE:-$(hostname -s 2>/dev/null || echo unknown)}}"
when="$(date '+%Y-%m-%d %H:%M')"
# ONE printf of ONE line. A single write under PIPE_BUF to an O_APPEND fd is
# atomic on POSIX, so concurrent sessions cannot interleave a line — which
# matters here precisely because many agents post to one board.
# flock on the same sidecar the Python remover uses. The append is
# atomic by itself, but `unlink` does read-modify-write, and without a
# shared lock this line could land inside that window and be rewritten
# away by the prune.
touch -- "$board/.links.lock"
flock "$board/.links.lock" \
printf -- '- [%s](%s) <sub>· %s · %s</sub>\n' \
"${desc:-$link_url}" "$link_url" "$who" "$when" >> "$board/links.md"
echo "$URL/b/$LINKS_BOARD/"
;;
links)
board="$DATA/$LINKS_BOARD/links.md"
[ -f "$board" ] || { echo "no link board yet"; exit 0; }
# The id is the same content hash the web UI and `unlink` use, so a row can
# be named unambiguously even while other sessions are appending to the board.
n=0
while IFS= read -r line; do
case "$line" in "- ["*) ;; *) continue ;; esac
n=$((n+1))
id="$(printf '%s' "$line" | sed 's/^[[:space:]]*//;s/[[:space:]]*$//' | sha1sum | cut -c1-8)"
printf '%3d %s %s\n' "$n" "$id" "$line"
done < "$board"
# `if`, NOT `[ ... ] && echo`: as the LAST statement of the branch that
# idiom returns 1 whenever the board is non-empty, so `booth links` exits
# non-zero on success — and `unlink`'s index lookup, which calls it inside
# $( ) under `set -e`, then dies silently.
if [ "$n" -eq 0 ]; then echo "board has no link rows"; fi
;;
unlink)
[ $# -ge 1 ] || usage
board="$DATA/$LINKS_BOARD"
[ -f "$board/links.md" ] || { echo "no link board" >&2; exit 1; }
target="$1"
# A bare number is accepted for convenience but resolved to the row's
# CONTENT ID before anything is deleted: between `booth links` and
# `booth unlink` another session may have appended, and deleting by POSITION
# would then take the wrong row. An id either matches the row you saw or
# matches nothing.
# DISAMBIGUATE BY SHAPE, not by "is it numeric". A content id is exactly 8
# hex chars, and roughly one id in forty is all digits — those were being
# read as row numbers and silently resolving to nothing. Match the id's
# actual shape first; anything else numeric is an index.
case "$target" in
[0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f])
;; # already a content id
''|*[!0-9]*)
echo "not an entry id (8 hex chars) or a row number: $target" >&2; exit 1 ;;
*)
target="$("$0" links | awk -v n="$target" '$1==n{print $2}')"
[ -n "$target" ] || { echo "no row $1 on the board" >&2; exit 1; } ;;
esac
# `|| exit 1` so a failure is reported rather than swallowed; `set -e` inside
# a command substitution elsewhere in this script has bitten us already.
BOOTH_SRC="$(cd "$(dirname -- "$(readlink -f -- "$0")")/.." && pwd)" python3 -c '
import os, pathlib, sys
sys.path.insert(0, os.environ["BOOTH_SRC"])
from booth.links import remove_link_entry # stdlib only — no venv needed
removed = remove_link_entry(pathlib.Path(sys.argv[1]), sys.argv[2])
if removed is None:
sys.exit("no such entry: %s (already removed?)" % sys.argv[2])
print("removed: %s %s" % (removed["desc"], removed["url"]))
' "$board" "$target"
;;
ask)
# booth ask <name> <id> <prompt> <opt>... [--no-notes]
[ $# -ge 5 ] || usage
name="$1"; mid="$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 "a pick needs at least 2 options" >&2; exit 1; }
# Validated through the SAME normaliser the page uses, so a session cannot
# declare a question the renderer would refuse. stdlib only — no venv needed.
BOOTH_SRC="$(cd "$(dirname -- "$(readlink -f -- "$0")")/.." && pwd)" ASK_NOTES="$notes" python3 -c '
import os, pathlib, sys
sys.path.insert(0, os.environ["BOOTH_SRC"])
from booth.asks import AskError
from booth.marks import MarksCorrupt, declare_pick
booth, mid, prompt, *opts = sys.argv[1:]
try:
declare_pick(pathlib.Path(booth), mid,
{"prompt": prompt, "options": opts,
"notes": os.environ["ASK_NOTES"] == "1"})
except AskError as exc:
sys.exit("bad pick: %s" % exc)
except MarksCorrupt as exc:
sys.exit("this booth'"'"'s .marks.json is damaged, so nothing was written: %s" % exc)
' "$DATA/$name" "$mid" "$prompt" "${opts[@]}"
echo "$URL/b/$name/#mark-$mid"
;;
marks|asks)
# booth marks <name> [--wait [SECS]] (`asks` is the deprecated alias)
#
# EXIT CODES. 0 = the read succeeded and the document is on stdout; 1 =
# --wait gave up with picks still open (the document is still printed); 3 =
# the marks could not be read at all. A reader that CRASHED must never look
# like an answer — the old shape printed a traceback and exited 0, so a
# caller piping to `jq` saw success and got nothing.
#
# Whether anything is still open is in the payload's `open` list. The read
# verb does not encode it in its status: a successful read is a success.
[ $# -ge 1 ] || usage
name="$1"; shift
wait_s=0
if [ "${1:-}" = "--wait" ]; then wait_s="${2:-3600}"; fi
# Poll, do not inotify: the judgment 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 :; do
# CAPTURED, not streamed. Printing inside the loop wrote one whole JSON
# document per poll, so `booth marks b --wait | jq` got several values
# concatenated and could parse none of them. The wait is a wait; the
# print is the result, and it happens once.
rc=0
out="$(BOOTH_SRC="$(cd "$(dirname -- "$(readlink -f -- "$0")")/.." && pwd)" python3 -c '
import json, os, pathlib, sys
sys.path.insert(0, os.environ["BOOTH_SRC"])
try:
from booth.marks import as_dict, marks_for, open_marks, read_error
booth = pathlib.Path(sys.argv[1])
# Ask FIRST whether the file is readable. `marks_for` answers "no marks"
# for a damaged file, which is the right answer for a page and the wrong
# one for a session that wants to know whether its question survived.
broken = read_error(booth)
if broken:
print(f"booth: {broken}", file=sys.stderr)
sys.exit(3)
marks = marks_for(booth)
doc = json.dumps({"marks": [as_dict(m) for m in marks],
"open": [m.id for m in open_marks(marks)]},
ensure_ascii=False, indent=2)
except Exception as exc:
print(f"booth: cannot read marks: {exc}", file=sys.stderr)
sys.exit(3)
print(doc)
sys.exit(2 if open_marks(marks) else 0)
' "$DATA/$name")" || rc=$?
case "$rc" in
0) printf '%s\n' "$out"; exit 0 ;; # read ok, nothing open
2) if [ "$wait_s" -eq 0 ]; then printf '%s\n' "$out"; exit 0; fi ;;
*) echo "cannot read marks in $name" >&2; exit 3 ;;
esac
if [ "$(date +%s)" -ge "$deadline" ]; then
printf '%s\n' "$out"
echo "timed out after ${wait_s}s with marks still open in $name" >&2; exit 1
fi
sleep 2
done
;;
answer)
# booth answer <name> <id> [--wait [SECS]]
[ $# -ge 2 ] || usage
name="$1"; mid="$2"; shift 2
wait_s=0
if [ "${1:-}" = "--wait" ]; then wait_s="${2:-3600}"; fi
deadline=$(( $(date +%s) + wait_s ))
while :; do
rc=0
out="$(BOOTH_SRC="$(cd "$(dirname -- "$(readlink -f -- "$0")")/.." && pwd)" python3 -c '
import json, os, pathlib, sys
sys.path.insert(0, os.environ["BOOTH_SRC"])
try:
from booth.marks import marks_for, open_marks, read_error
booth, mid = sys.argv[1:3]
broken = read_error(pathlib.Path(booth))
if broken:
print(f"booth: {broken}", file=sys.stderr)
sys.exit(3)
# id AND shape, matching the web route. Matching on id alone reported a
# note id as "unanswered" and then polled it for an hour — a question that
# could never be answered because it was never a question.
marks = marks_for(pathlib.Path(booth))
m = next((x for x in marks if x.id == mid and x.shape == "pick"), None)
# THE openness predicate, not a second spelling of it. `answer is None` is
# what this read used to test, and it disagreed with `marks --wait` on a
# PARTIALLY answered pick: one verb returned the half-filled form while the
# other blocked on the same booth at the same instant. U2 put openness in
# one function precisely so the two could not drift.
still_open = m is not None and m in open_marks(marks)
except Exception as exc:
print(f"booth: cannot read marks: {exc}", file=sys.stderr)
sys.exit(3)
if m is None:
sys.exit(2)
if m.error:
# Not open, and never going to be: the web route refuses this form with a
# 400, so waiting on it is waiting on nothing. `marks --wait` already
# returns immediately here; this is the other half of that agreement.
print(f"booth: pick is broken and cannot be answered: {m.error}",
file=sys.stderr)
sys.exit(4)
if still_open:
sys.exit(1)
print(json.dumps(m.answer, ensure_ascii=False, indent=2))
' "$DATA/$name" "$mid")" || rc=$?
case "$rc" in
0) printf '%s\n' "$out"; exit 0 ;;
2) echo "no such pick: $name/$mid" >&2; exit 2 ;;
# A read that FAILED is not "not yet". Conflating them sent --wait
# spinning for the full hour on a broken file and then blamed the
# operator for not answering.
3) echo "cannot read marks in $name" >&2; exit 3 ;;
# A pick that hydrated broken is refused by the web route, so no answer
# can ever land. Waiting on it is waiting on nothing.
4) exit 4 ;;
esac
if [ "$wait_s" -eq 0 ]; then echo "unanswered: $URL/b/$name/#mark-$mid" >&2; exit 1; fi
if [ "$(date +%s)" -ge "$deadline" ]; then
echo "timed out after ${wait_s}s waiting on $name/$mid" >&2; exit 1
fi
sleep 2
done
;;
marks-import)
# booth marks-import <name> — idempotent, and it deletes nothing
[ $# -ge 1 ] || usage
BOOTH_SRC="$(cd "$(dirname -- "$(readlink -f -- "$0")")/.." && pwd)" python3 -c '
import os, pathlib, sys
sys.path.insert(0, os.environ["BOOTH_SRC"])
from booth.marks import import_legacy_asks
made = import_legacy_asks(pathlib.Path(sys.argv[1]))
if not made:
print("nothing to import (or already imported)")
for m in made:
print("imported %-24s %s" % (m.id, m.error or ("answered" if m.answer else "open")))
' "$DATA/$1"
;;
*) usage ;;
esac