264 lines
12 KiB
Bash
Executable File
264 lines
12 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> make an empty booth, print its URL
|
||
# booth add <name> <file>... 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> <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.
|
||
# Several questions in ONE form: write <stem>.ask.json by hand with a
|
||
# `questions` list (see services/booth/README.md § Asks); `asks` and `answer`
|
||
# handle both shapes.
|
||
# 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
|
||
# 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.
|
||
#
|
||
# 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
|
||
LINKS_BOARD="${BOOTH_LINKS_BOARD:-links}"
|
||
|
||
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>|ask <name> <stem> <prompt> <option>... [--no-notes]|asks <name>|answer <name> <stem> [--wait [SECS]]}" >&2
|
||
exit 2
|
||
}
|
||
|
||
cmd="${1:-}"; shift || true
|
||
case "$cmd" in
|
||
new)
|
||
[ $# -ge 1 ] || usage
|
||
mkdir -p -- "$DATA/$1"
|
||
echo "$URL/b/$1/"
|
||
;;
|
||
add)
|
||
[ $# -ge 2 ] || usage
|
||
name="$1"; shift
|
||
mkdir -p -- "$DATA/$name"
|
||
cp -- "$@" "$DATA/$name/"
|
||
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"
|
||
;;
|
||
link)
|
||
[ $# -ge 1 ] || usage
|
||
link_url="$1"; shift
|
||
desc="${*:-}"
|
||
board="$DATA/$LINKS_BOARD"
|
||
mkdir -p -- "$board"
|
||
: > "$board/$KEEP" # the board is durable by definition
|
||
# 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> <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 -- "$(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, 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 -- "$(readlink -f -- "$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"] and a["multi"]:
|
||
picks = ", ".join("%s=%s" % (k, v["label"]) for k, v in a["answer"]["answers"].items())
|
||
state = "answered %s (%s)" % (picks, a["answer"]["answered_at"])
|
||
elif a["answer"]:
|
||
state = "answered %s (%s)" % (a["answer"]["label"], a["answer"]["answered_at"])
|
||
elif a["multi"]:
|
||
state = "open (%d questions)" % len(a["questions"])
|
||
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 ;;
|
||
esac
|