The three places scripts/booth documents itself -- the header block and both usage lines -- still described a bare --apply, which is now refused. A usage string that names a form the script rejects is worse than none.
776 lines
37 KiB
Bash
Executable File
776 lines
37 KiB
Bash
Executable File
#!/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
|
||
# (NOT for "waiting on an answer" — an open
|
||
# pick holds its own booth, see below)
|
||
# booth unkeep <name> hand it back to the sweeper
|
||
# booth link <url> [description] append a link to the standing link board
|
||
# REFUSES a booth URL — a booth announces
|
||
# itself now; use `booth new --why`
|
||
# booth links list the board, numbered, with entry ids
|
||
# booth unlink <id|index> remove ONE link from the board
|
||
#
|
||
# booth bench add <url> <name> register or UPDATE a bench (upsert)
|
||
# booth bench ls list benches, live -> promoted -> retired
|
||
# booth bench state <id|url> <state> live | promoted | retired
|
||
# booth bench rm <id|url> remove one
|
||
# booth bench import classify the board's rows; writes NOTHING
|
||
# booth bench import --apply <id>... register ONLY the ids you name. A bare
|
||
# --apply is REFUSED: a machine cannot tell
|
||
# a bench from a bookmark by its URL, and
|
||
# ~14 of 35 live candidates are bookmarks.
|
||
# links.md is never edited by either form.
|
||
#
|
||
# THREE SURFACES, THREE JOBS. Telling them apart is the whole of U6:
|
||
# a BOOTH is a review surface you post work to. It announces itself and is
|
||
# swept 24h after its last activity. `booth new` / `booth add`.
|
||
# a BENCH is a running thing — jackdaw's bench, talk's bench, the things that
|
||
# get promoted to Homepage. Durable, and identified BY ITS URL, so posting
|
||
# it again updates the row instead of adding a fifth. `booth bench add`.
|
||
# a LINK is a reference bookmark — a repo, a model card, a doc page. The
|
||
# standing board, unchanged and NOT deprecated. `booth link`.
|
||
# The board carried all three because only one of them had a surface: 178 of its
|
||
# 221 rows were booth URLs and 156 of those pointed at booths already swept.
|
||
#
|
||
# 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 THREE STATES. Every booth is wiped 24h after its last
|
||
# activity — that is the contract, and it is why nobody has to clean up after
|
||
# themselves. Two things exempt a booth, and only the first is a button:
|
||
#
|
||
# KEPT `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 costs nothing.
|
||
# HELD a booth with an UNANSWERED pick is never swept, automatically, for as
|
||
# long as the question is open. You do not press anything: `booth ask`
|
||
# is what holds it, and the operator answering is what releases it. A
|
||
# partially-answered pick still counts as open, so a review in flight
|
||
# cannot be swept out from under him.
|
||
#
|
||
# So: DO NOT `keep` a booth just because you are waiting on an answer. That was
|
||
# the old workaround, it is what made 70% of live booths "durable", and it is
|
||
# no longer needed. `keep` means durable. The question holds its own booth.
|
||
#
|
||
# VIEWING IS ACTIVITY TOO. The operator opening a booth page resets its clock —
|
||
# if he is still looking at it, it is still alive. Your polling does NOT: `booth
|
||
# marks --wait` and the `marks.json` endpoint are machine reads and deliberately
|
||
# do not count, so a session cannot hold its own booth open by waiting on it.
|
||
#
|
||
# 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". RELEASING A BOARD IS ACTIVITY — you just
|
||
# touched it — 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. (This was true before U4 as an accident of directory mtime; it is
|
||
# now the stated rule, which is why it no longer needs a warning shaped like a
|
||
# surprise.)
|
||
#
|
||
# `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)}}"
|
||
}
|
||
|
||
# Where booth/*.py lives, for the `python3 -c` calls below. The CLI runs under
|
||
# the SYSTEM python3 with no venv, which is why every module it imports is
|
||
# stdlib-only (CLAUDE.md invariant 1) and why no AST extractor can see these
|
||
# imports — `tests/test_cli.py` runs the real script, and is the only thing that
|
||
# catches a third-party import before a fleet host does.
|
||
booth_src() {
|
||
(cd "$(dirname -- "$(readlink -f -- "$0")")/.." && pwd)
|
||
}
|
||
|
||
# The booth NAME a URL points at, or empty. ONE PREDICATE — this shells out to
|
||
# booth.links.booth_target rather than pattern-matching `:8090/b/` here, because
|
||
# the board's dead-row marker and `bench import` use that same function and a
|
||
# second implementation in the shell would classify the host-agnostic and
|
||
# percent-encoded cases differently (INV-2).
|
||
# Prints `B:<name>` for a booth URL and `N` for anything else.
|
||
#
|
||
# A SENTINEL, NOT AN EMPTY STRING. Command substitution strips trailing
|
||
# newlines, so a predicate that answers with the bare name cannot distinguish
|
||
# "not a booth" from "a booth whose name bash just erased" — and the guard
|
||
# then fails OPEN on that edge, which is the one direction a guard must never
|
||
# fail. The prefix makes the answer unambiguous whatever the name contains.
|
||
booth_target_of() {
|
||
BOOTH_SRC="$(booth_src)" BOOTH_Q="$1" python3 -c '
|
||
import os, sys
|
||
sys.path.insert(0, os.environ["BOOTH_SRC"])
|
||
from booth.links import booth_target # stdlib only — no venv needed
|
||
name = booth_target(os.environ["BOOTH_Q"])
|
||
sys.stdout.write("N" if name is None else "B:" + name)
|
||
'
|
||
}
|
||
|
||
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>|bench add <url> <name>|bench ls|bench state <id|url> <live|promoted|retired>|bench rm <id|url>|bench import [--apply <id>...]}" >&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="${*:-}"
|
||
# THE REFUSAL COMES FIRST, BEFORE ANY WRITE (INV-3). A booth announces
|
||
# itself now (U5), so a booth URL on the board is a row that rots the
|
||
# moment the booth is swept — 156 of the board's 221 rows are exactly
|
||
# that. Refusing AFTER the mkdir/announce below would leave a new booth
|
||
# behind as the side effect of a call that failed.
|
||
# `|| pred_rc=$?` so a BROKEN PREDICATE is handled here rather than aborting
|
||
# the script under `set -e` with a raw Python traceback and nothing else.
|
||
# The direction is FAIL-CLOSED and stays that way: if we cannot tell whether
|
||
# this is a booth, we do not append. A guard that fails open is not a guard,
|
||
# and the cost of being wrong in the other direction is one message telling
|
||
# the poster exactly what broke.
|
||
pred_rc=0
|
||
refused_name="$(booth_target_of "$link_url" 2>/dev/null)" || pred_rc=$?
|
||
if [ "$pred_rc" -ne 0 ]; then
|
||
{
|
||
echo "booth link: could not check whether that URL is a booth, so nothing was posted."
|
||
echo " the check runs booth/links.py under the system python3 with no venv."
|
||
echo " re-run from a checkout where \`python3 -c 'import booth.links'\` works,"
|
||
echo " or post it from a host that has one."
|
||
} >&2
|
||
exit 3
|
||
fi
|
||
case "$refused_name" in
|
||
N) refused_name="" ;;
|
||
B:*) refused_name="${refused_name#B:}" ;;
|
||
*)
|
||
echo "booth link: the booth check answered something unrecognised; nothing was posted." >&2
|
||
exit 3 ;;
|
||
esac
|
||
# CREDENTIALS DO NOT GO ON THE BOARD, through any door. `normalize_bench_url`
|
||
# refuses userinfo for a bench; `booth link` is the door this unit did not
|
||
# touch, and the board renders on an unauthenticated LAN surface. A small,
|
||
# deliberate widening of the unit -- named rather than smuggled.
|
||
case "$link_url" in
|
||
*://*@*)
|
||
{
|
||
echo "booth link: that URL carries credentials (user:pass@host) and the board"
|
||
echo " is readable by anyone who can reach this service. Nothing was posted."
|
||
echo " strip the credentials and post it again."
|
||
} >&2
|
||
exit 2 ;;
|
||
esac
|
||
if [ -n "$refused_name" ]; then
|
||
{
|
||
echo "booth link: that is a booth, and a booth announces itself now."
|
||
echo " booth new $refused_name --why \"${desc:-what the operator is looking at}\""
|
||
echo " (or --why on \`booth add\`; re-announcing keeps the original stamp)"
|
||
echo " the index at $URL/ is the feed."
|
||
} >&2
|
||
exit 2
|
||
fi
|
||
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"
|
||
;;
|
||
bench)
|
||
# SEAM REVIEW SR-6: the first two-word verb in this script. A nested case,
|
||
# and a bare `bench` names the bench verbs rather than falling through to
|
||
# the generic usage, which would hide which of the two words was wrong.
|
||
sub="${1:-}"; shift || true
|
||
case "$sub" in
|
||
add|ls|state|rm|import) ;;
|
||
*)
|
||
echo "usage: booth bench {add <url> <name>|ls|state <id|url> <live|promoted|retired>|rm <id|url>|import [--apply <id>...]}" >&2
|
||
exit 2 ;;
|
||
esac
|
||
BOOTH_SRC="$(booth_src)" BOOTH_DATA="$DATA" BOOTH_SUB="$sub" \
|
||
BOOTH_WHO="$(whoami_handle)" BOOTH_BOARD="$LINKS_BOARD" \
|
||
python3 -c '
|
||
import os, pathlib, sys
|
||
sys.path.insert(0, os.environ["BOOTH_SRC"])
|
||
# stdlib only — no venv needed. benches.py imports no sibling either (INV-9).
|
||
from booth.benches import (normalize_bench_url, order_benches, read_benches,
|
||
remove_bench, set_bench_state, upsert_bench)
|
||
from booth.links import booth_target, parse_link_entries
|
||
|
||
root = pathlib.Path(os.environ["BOOTH_DATA"])
|
||
sub, who = os.environ["BOOTH_SUB"], os.environ["BOOTH_WHO"]
|
||
argv = sys.argv[1:]
|
||
|
||
def die(msg, code=2):
|
||
print("booth bench: %s" % msg, file=sys.stderr)
|
||
raise SystemExit(code)
|
||
|
||
def row(b):
|
||
# ONE LINE PER BENCH, in the rendered order — state first, so a retired
|
||
# bench sinks, then name, then id as a total tie-break (INV-4).
|
||
# THE ID IS PRINTED WHOLE AND UNTRUNCATED, because it is the locator
|
||
# `bench state` and `bench rm` take: a truncated one is not an id, it is a
|
||
# string that looks like one and silently addresses nothing. The name and
|
||
# the added date are the truncatable columns.
|
||
return "%-9s %-10s %-16s %-24s %s" % (
|
||
b.state, b.added[:10], b.owner[:16], b.name[:24], b.id)
|
||
|
||
if sub == "add":
|
||
if len(argv) < 2: die("bench add <url> <name>")
|
||
try:
|
||
bench, created = upsert_bench(root, argv[0], " ".join(argv[1:]), who)
|
||
except ValueError as exc:
|
||
die(exc)
|
||
print("%s: %s" % ("registered" if created else "updated", bench.id))
|
||
elif sub == "ls":
|
||
benches, err = read_benches(root)
|
||
if err:
|
||
die("the registry could not be read: %s" % err, 3)
|
||
if not benches:
|
||
print("no benches registered yet")
|
||
else:
|
||
print("%-9s %-10s %-16s %-24s %s"
|
||
% ("STATE", "ADDED", "OWNER", "NAME", "ID (pass to state|rm)"))
|
||
for b in benches:
|
||
print(row(b))
|
||
elif sub in ("state", "rm"):
|
||
if not argv: die("bench %s <id|url>%s" % (sub, " <state>" if sub == "state" else ""))
|
||
try:
|
||
bench_id = normalize_bench_url(argv[0])
|
||
except ValueError as exc:
|
||
die(exc)
|
||
if sub == "rm":
|
||
gone = remove_bench(root, bench_id)
|
||
if gone is None: die("no such bench: %s" % bench_id, 1)
|
||
print("removed: %s" % gone.url)
|
||
else:
|
||
if len(argv) < 2: die("bench state <id|url> <live|promoted|retired>")
|
||
try:
|
||
moved = set_bench_state(root, bench_id, argv[1])
|
||
except ValueError as exc:
|
||
die(exc)
|
||
if moved is None: die("no such bench: %s" % bench_id, 1)
|
||
print("%s is now %s" % (moved.url, moved.state))
|
||
elif sub == "import":
|
||
apply = "--apply" in argv
|
||
picked = [a for a in argv if a != "--apply"]
|
||
board = root / os.environ["BOOTH_BOARD"] / "links.md"
|
||
if not board.is_file(): die("no link board at %s" % board, 1)
|
||
skipped, candidates, refused = [], [], []
|
||
for e in parse_link_entries(board.read_text()):
|
||
name = booth_target(e["url"])
|
||
if name is not None:
|
||
skipped.append((e, name)); continue
|
||
try:
|
||
candidates.append((normalize_bench_url(e["url"]), e))
|
||
except ValueError as exc:
|
||
refused.append((e, str(exc)))
|
||
print("SKIPPED — booth rows; a booth announces itself now (%d):" % len(skipped))
|
||
for e, name in skipped:
|
||
print(" %-30s %s" % (name, e["url"]))
|
||
print()
|
||
print("CANDIDATES — would be registered (%d rows, %d distinct):"
|
||
% (len(candidates), len({i for i, _ in candidates})))
|
||
for i, e in candidates:
|
||
# THE NORMALIZED ID BESIDE THE RAW URL, which is the whole point of the
|
||
# proposal: five rows of `talk` collapsing to one is only visible if you
|
||
# can see which five raw URLs produced the one id. The description is
|
||
# the thing to drop here, not the URL.
|
||
print(" %-52s %s" % (i, e["url"]))
|
||
if e["desc"]:
|
||
print(" %-52s %s" % ("", e["desc"][:70]))
|
||
print()
|
||
print("REFUSED — normalization said no (%d):" % len(refused))
|
||
for e, why in refused:
|
||
print(" %-52s %s" % (e["url"], why))
|
||
if not apply:
|
||
print()
|
||
print("nothing was written.")
|
||
print(" booth bench import --apply <id>... register ONLY the ids you name")
|
||
print()
|
||
print("A MACHINE CANNOT TELL A BENCH FROM A BOOKMARK BY ITS URL. On the live")
|
||
print("board roughly 14 of 35 candidates are repos, model cards and docs, for")
|
||
print("which the board is the right and only home. So `--apply` takes the ids")
|
||
print("YOU pick from the list above; it will not register the whole set.")
|
||
raise SystemExit(0)
|
||
# SELECTION IS MANDATORY. A bare `--apply` would do exactly the thing this
|
||
# rationale of this very unit says is impossible -- decide bench-vs-bookmark
|
||
# URL -- and it would do it silently, to ~14 rows that belong on the board.
|
||
# The dry-run prints the ids; the operator names the ones that are benches.
|
||
if not picked:
|
||
print()
|
||
print("booth bench import --apply needs the ids to register.", file=sys.stderr)
|
||
print(" nothing was written. copy the ids you want from the list above:",
|
||
file=sys.stderr)
|
||
print(" booth bench import --apply <id> [<id>...]", file=sys.stderr)
|
||
raise SystemExit(2)
|
||
by_id = {i: e for i, e in candidates}
|
||
unknown = [i for i in picked if i not in by_id]
|
||
if unknown:
|
||
print()
|
||
for i in unknown:
|
||
print("not a candidate id: %s" % i, file=sys.stderr)
|
||
print("nothing was written.", file=sys.stderr)
|
||
raise SystemExit(2)
|
||
for i in picked:
|
||
e = by_id[i]
|
||
upsert_bench(root, e["url"], e["desc"], e["who"] or who)
|
||
print()
|
||
print("applied: %d bench(es) registered. links.md was NOT modified." % len(set(picked)))
|
||
' "$@"
|
||
;;
|
||
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
|