Files
BountyBoard/scripts/acceptance.sh
T
etalon 6265ffa894 feat: live deployment with automated acceptance checklist (phase 13)
- APP_INTERNAL_URL: in-network base URL for §5.2 callbacks and signed
  attachment URLs handed to the external services (compose: http://app:8787)
- work-performer image runs as the node user with ~/.claude mounted into
  /home/node — the claude CLI refuses --dangerously-skip-permissions as root
- scripts/acceptance.sh: re-run-safe live verification of the §13 checklist
  (demo import within one poll, subdivide sum=1 + editable, extend sibling,
  publish/bounty math, decline/claim/approve, changes-requested loop,
  approval award in metrics, unassign, AI job through real Claude Code with
  signed idempotent callback, breaker independence between the two services)
- README/DECISIONS: sudo HOME gotcha, internal URL, non-root performer

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-12 21:06:23 +02:00

169 lines
8.8 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
# Live acceptance checklist (§13) against a running, seeded stack with the
# mocks profile. Exercises: demo ticket import → subdivide → extend →
# publish → claim/decline/approve → work → review → award → AI assignment →
# breaker independence.
set -euo pipefail
BASE="${BASE_URL:-http://localhost:8787}"
PASS="demo-pass-123"
TMP=$(mktemp -d)
trap 'rm -rf "$TMP"' EXIT
fail() { echo "ACCEPTANCE FAIL: $*" >&2; exit 1; }
ok() { echo "$*"; }
jqr() { python3 -c "import sys,json;d=json.load(sys.stdin);print(eval(sys.argv[1]))" "$1"; }
login() { # $1 email → jar at $TMP/$1.jar, csrf in $TMP/$1.csrf
local jar="$TMP/$1.jar"
curl -fsS -c "$jar" -H 'Content-Type: application/json' \
-d "{\"email\":\"$1\",\"password\":\"$PASS\"}" "$BASE/api/v1/auth/login" -o /dev/null \
|| fail "login $1"
awk '$6=="bb_csrf" {print $7}' "$jar" > "$TMP/$1.csrf"
}
api() { # $1 user, $2 method, $3 path, [$4 json body] → stdout
local jar="$TMP/$1.jar" csrf
csrf=$(cat "$TMP/$1.csrf")
if [ $# -ge 4 ]; then
curl -fsS -b "$jar" -X "$2" -H "X-CSRF-Token: $csrf" \
-H 'Content-Type: application/json' -d "$4" "$BASE$3"
else
curl -fsS -b "$jar" -X "$2" -H "X-CSRF-Token: $csrf" "$BASE$3"
fi
}
echo "1. consultant login + demo tickets imported within one poll interval"
login clara@example.com
for i in $(seq 1 24); do
COUNT=$(api clara@example.com GET /api/v1/consultant/board | jqr "len(d['tasks'])")
[ "$COUNT" -ge 5 ] && break
sleep 5
done
[ "$COUNT" -ge 5 ] || fail "expected ≥5 imported demo tickets, got $COUNT"
ok "demo sync imported $COUNT tickets"
BOARD=$(api clara@example.com GET /api/v1/consultant/board)
ROOT=$(echo "$BOARD" | jqr "[t for t in d['tasks'] if t['status']=='imported'][0]['id']")
ROOT_BUDGET=$(echo "$BOARD" | jqr "[t for t in d['tasks'] if t['id']=='$ROOT'][0]['budget']")
ok "picked imported root $ROOT (budget $ROOT_BUDGET)"
echo "2. subdivide → N children, coefficients sum to 1, editable"
api clara@example.com POST "/api/v1/tasks/$ROOT/subdivide" \
'{"note":"split it","constraints":{"minTasks":3,"maxTasks":3}}' > /dev/null
for i in $(seq 1 30); do
KIDS=$(api clara@example.com GET "/api/v1/consultant/board" | \
jqr "len([t for t in d['tasks'] if t.get('parentId')=='$ROOT'])")
[ "$KIDS" -ge 3 ] && break
sleep 2
done
[ "$KIDS" -ge 3 ] || fail "subdivision produced $KIDS children"
SUM=$(api clara@example.com GET "/api/v1/consultant/board" | \
jqr "round(sum(t['effortCoefficient'] for t in d['tasks'] if t.get('parentId')=='$ROOT' and t['origin']=='subdivided'),4)")
[ "$SUM" = "1.0" ] || fail "children coefficients sum to $SUM"
ok "3 children, coefficient sum exactly 1.0"
CHILD1=$(api clara@example.com GET "/api/v1/consultant/board" | \
jqr "[t for t in d['tasks'] if t.get('parentId')=='$ROOT'][0]['id']")
CHILD1V=$(api clara@example.com GET "/api/v1/tasks/$CHILD1" | jqr "d['task']['version']")
api clara@example.com PATCH "/api/v1/tasks/$CHILD1" \
"{\"version\":$CHILD1V,\"effortCoefficient\":0.5,\"budget\":2000}" > /dev/null
B=$(api clara@example.com GET "/api/v1/tasks/$CHILD1" | jqr "float(d['task']['bounty'])")
[ "$B" = "1000.0" ] || fail "edited bounty = $B, want 1000 (0.5 × 2000)"
ok "edit recomputed bounty = coefficient × budget"
echo "3. extend creates exactly one sibling"
BEFORE=$(api clara@example.com GET /api/v1/consultant/board | \
jqr "len([t for t in d['tasks'] if t['origin']=='extended'])")
api clara@example.com POST "/api/v1/tasks/$ROOT/extend" '{"note":"add CSV presets"}' > /dev/null
for i in $(seq 1 30); do
AFTER=$(api clara@example.com GET /api/v1/consultant/board | \
jqr "len([t for t in d['tasks'] if t['origin']=='extended'])")
[ "$AFTER" -eq $((BEFORE + 1)) ] && break
sleep 2
done
[ "$AFTER" -eq $((BEFORE + 1)) ] || fail "extension count $AFTER (was $BEFORE)"
ok "extension created exactly one sibling"
echo "4. publish → developer board with bounty = coefficient × budget"
api clara@example.com POST "/api/v1/tasks/$CHILD1/publish" '{}' > /dev/null
login dev1@example.com
DEVB=$(api dev1@example.com GET /api/v1/board)
DB=$(echo "$DEVB" | jqr "float([t for t in d['tasks'] if t['id']=='$CHILD1'][0]['bounty'])")
[ "$DB" = "1000.0" ] || fail "published task not on dev board with bounty 1000 (got $DB)"
ok "developer sees published task, bounty 1000"
echo "5. claim → decline → claim → approve → work → changes → approve → award"
api dev1@example.com POST "/api/v1/tasks/$CHILD1/claim" '{"note":"mine!"}' > /dev/null
api clara@example.com POST "/api/v1/tasks/$CHILD1/decline-claim" "{\"developerId\":\"$(api dev1@example.com GET /api/v1/auth/me | jqr "d['user']['id']")\"}" > /dev/null
ST=$(api clara@example.com GET "/api/v1/tasks/$CHILD1" | jqr "d['task']['status']")
[ "$ST" = "published" ] || fail "after decline: $ST"
ok "decline returned task to published"
TOTAL_BEFORE=$(api dev1@example.com GET /api/v1/developer/metrics | jqr "float(d['totalBounty'])")
api dev1@example.com POST "/api/v1/tasks/$CHILD1/claim" '{"note":"please"}' > /dev/null
DEVID=$(api dev1@example.com GET /api/v1/auth/me | jqr "d['user']['id']")
api clara@example.com POST "/api/v1/tasks/$CHILD1/approve-claim" "{\"developerId\":\"$DEVID\"}" > /dev/null
api dev1@example.com POST "/api/v1/tasks/$CHILD1/start" '{}' > /dev/null
api dev1@example.com POST "/api/v1/tasks/$CHILD1/comments" '{"body":"<p>done, see <b>notes</b></p>"}' > /dev/null
api dev1@example.com POST "/api/v1/tasks/$CHILD1/time" '{"minutes":45,"note":"impl"}' > /dev/null
api dev1@example.com POST "/api/v1/tasks/$CHILD1/submit-review" '{}' > /dev/null
api clara@example.com POST "/api/v1/tasks/$CHILD1/review" '{"decision":"request_changes","note":"edge cases"}' > /dev/null
api dev1@example.com POST "/api/v1/tasks/$CHILD1/start" '{}' > /dev/null
api dev1@example.com POST "/api/v1/tasks/$CHILD1/submit-review" '{}' > /dev/null
api clara@example.com POST "/api/v1/tasks/$CHILD1/review" \
'{"decision":"approve","note":"good","checklist":[{"criterion":"works","ok":true}]}' > /dev/null
ST=$(api clara@example.com GET "/api/v1/tasks/$CHILD1" | jqr "d['task']['status']")
[ "$ST" = "approved" ] || fail "after approve: $ST"
TOTAL=$(api dev1@example.com GET /api/v1/developer/metrics | jqr "float(d['totalBounty'])")
DELTA=$(python3 -c "print($TOTAL - $TOTAL_BEFORE)")
[ "$DELTA" = "1000.0" ] || fail "developer metrics delta $DELTA, want 1000"
ok "full lifecycle approved; bountyAwards +1000 reflected in metrics"
echo "6. unassign returns an assigned task to the board"
CHILD2=$(api clara@example.com GET "/api/v1/consultant/board" | \
jqr "[t for t in d['tasks'] if t.get('parentId')=='$ROOT' and t['status']=='atomized'][0]['id']")
api clara@example.com POST "/api/v1/tasks/$CHILD2/publish" '{}' > /dev/null
api dev1@example.com POST "/api/v1/tasks/$CHILD2/claim" '{}' > /dev/null
api clara@example.com POST "/api/v1/tasks/$CHILD2/approve-claim" "{\"developerId\":\"$DEVID\"}" > /dev/null
api clara@example.com POST "/api/v1/tasks/$CHILD2/unassign" '{}' > /dev/null
ST=$(api clara@example.com GET "/api/v1/tasks/$CHILD2" | jqr "d['task']['status']")
[ "$ST" = "published" ] || fail "after unassign: $ST"
ok "unassign returned task to published"
echo "7. AI assignment → §5.2 job → signed callback → in_review"
api clara@example.com POST "/api/v1/tasks/$CHILD2/assign-ai" \
'{"context":{"instructions":"create SUMMARY.md only; do not write code"}}' > /dev/null
for i in $(seq 1 90); do
ST=$(api clara@example.com GET "/api/v1/tasks/$CHILD2" | jqr "d['task']['status']")
[ "$ST" = "in_review" ] && break
sleep 5
done
[ "$ST" = "in_review" ] || fail "AI task status after wait: $ST"
ok "work performer callback moved the task to in_review"
api clara@example.com POST "/api/v1/tasks/$CHILD2/review" '{"decision":"approve","note":"AI ok"}' > /dev/null
ok "consultant reviewed AI work like a human's"
echo "8. breaker independence: stopping the atomizer must not affect the work performer"
sudo -n docker compose --profile mocks stop atomizer-mock > /dev/null 2>&1 \
|| docker compose --profile mocks stop atomizer-mock > /dev/null
CHILD3=$(api clara@example.com GET "/api/v1/consultant/board" | \
jqr "[t for t in d['tasks'] if t['status']=='atomized'][0]['id']")
for i in 1 2 3; do
api clara@example.com POST "/api/v1/tasks/$CHILD3/subdivide" '{"note":"x","confirmReplace":true}' > /dev/null || true
sleep 16
done
HEALTH=$(api clara@example.com GET /api/v1/service-health)
A_OK=$(echo "$HEALTH" | jqr "d['atomizer']['healthy']")
W_OK=$(echo "$HEALTH" | jqr "d['workPerformer']['healthy']")
[ "$A_OK" = "False" ] || fail "atomizer should be down"
[ "$W_OK" = "True" ] || fail "work performer must be unaffected"
BREAKER=$(echo "$HEALTH" | jqr "d['atomizer']['breaker']")
ok "atomizer down (breaker: $BREAKER), work performer healthy — independent"
sudo -n docker compose --profile mocks start atomizer-mock > /dev/null 2>&1 \
|| docker compose --profile mocks start atomizer-mock > /dev/null
ok "atomizer restarted"
echo
echo "ACCEPTANCE PASS"