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>
This commit is contained in:
Executable
+168
@@ -0,0 +1,168 @@
|
||||
#!/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"
|
||||
Reference in New Issue
Block a user