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:
@@ -1,6 +1,9 @@
|
|||||||
# --- core ---
|
# --- core ---
|
||||||
# Set APP_BASE_URL to the public https URL when behind the reverse proxy.
|
# Set APP_BASE_URL to the public https URL when behind the reverse proxy.
|
||||||
APP_BASE_URL=http://localhost:8787
|
APP_BASE_URL=http://localhost:8787
|
||||||
|
# In-network address external services use to call back into the app.
|
||||||
|
# docker-compose.yml sets this to http://app:8787; defaults to APP_BASE_URL.
|
||||||
|
#APP_INTERNAL_URL=
|
||||||
# Comma-separated CIDRs of trusted reverse proxies, e.g. 172.16.0.0/12,10.0.0.0/8.
|
# Comma-separated CIDRs of trusted reverse proxies, e.g. 172.16.0.0/12,10.0.0.0/8.
|
||||||
# Empty = trust no proxy headers.
|
# Empty = trust no proxy headers.
|
||||||
TRUSTED_PROXY_CIDRS=
|
TRUSTED_PROXY_CIDRS=
|
||||||
|
|||||||
@@ -104,3 +104,36 @@ Spec-silent choices, recorded as required by the build instructions.
|
|||||||
/change-password.
|
/change-password.
|
||||||
- **Pages for later phases render an "under construction" placeholder** so
|
- **Pages for later phases render an "under construction" placeholder** so
|
||||||
role-based navigation is complete and clickable now.
|
role-based navigation is complete and clickable now.
|
||||||
|
|
||||||
|
## Phases 5–12 (selected)
|
||||||
|
|
||||||
|
- **`internal/extsvc`** (not in the §14 list) holds the circuit breaker and
|
||||||
|
retrying JSON client shared by the two service clients — sharing plumbing,
|
||||||
|
not state; each service keeps its own breaker, URL, and token.
|
||||||
|
- **Mock services are separate codebases**: `services/atomizer` has its own
|
||||||
|
`go.mod` (stdlib only); `services/work-performer` is plain Node with zero
|
||||||
|
npm dependencies (plus the globally installed claude CLI in its image).
|
||||||
|
- **AI-approved tasks earn no `bountyAwards` row** — §4.5 awards are a
|
||||||
|
developer performance ledger; the AI submitter has no developer identity.
|
||||||
|
- **Bulk archive/publish are partial-success APIs** returning
|
||||||
|
`{done[], failed{}}` instead of failing the whole batch.
|
||||||
|
- **Extension siblings record `parentId = source task`** (spec literal),
|
||||||
|
so the UI tree shows extensions beneath their source.
|
||||||
|
|
||||||
|
## Phase 13 (deployment)
|
||||||
|
|
||||||
|
- **`APP_INTERNAL_URL` (compose: `http://app:8787`)** is handed to the
|
||||||
|
external services for callback URLs and signed attachment URLs — the §5.2
|
||||||
|
example uses the in-network hostname; `APP_BASE_URL` stays browser-facing.
|
||||||
|
- **The work-performer container runs as the `node` user** (uid 1000) with
|
||||||
|
`${HOME}/.claude` mounted into `/home/node/` instead of `/root/` (§9.2
|
||||||
|
shows /root): the claude CLI refuses `--dangerously-skip-permissions` as
|
||||||
|
root, so the literal spec mount can never execute jobs.
|
||||||
|
- **Run compose with the real user's HOME** — `sudo docker compose` resolves
|
||||||
|
`${HOME}` to `/root` and silently mounts the wrong Claude credentials. Use
|
||||||
|
`sudo --preserve-env=HOME docker compose …` (or run docker unprivileged).
|
||||||
|
- **UFW**: a rule allowing `172.16.0.0/12` (docker networks) to reach the
|
||||||
|
host was added so container→host callbacks work in contract tests.
|
||||||
|
- **`scripts/acceptance.sh`** automates the §13 checklist live (import →
|
||||||
|
subdivide → extend → publish → claim/decline/approve → review → award →
|
||||||
|
AI job via real Claude Code → breaker independence) and is re-run-safe.
|
||||||
|
|||||||
@@ -13,3 +13,4 @@
|
|||||||
- Phase 10 (metrics): aggregation pipelines over bountyAwards + timelines (totals, weekly $dateTrunc buckets, per-developer/per-customer, approval rate, time logged, assign→approve lead time, atomization lead time, open board depth), CSV export, leaderboard w/ opt-out (profile setting), dependency-free SVG line+bar charts, developer/consultant/admin dashboards — integration tests green.
|
- Phase 10 (metrics): aggregation pipelines over bountyAwards + timelines (totals, weekly $dateTrunc buckets, per-developer/per-customer, approval rate, time logged, assign→approve lead time, atomization lead time, open board depth), CSV export, leaderboard w/ opt-out (profile setting), dependency-free SVG line+bar charts, developer/consultant/admin dashboards — integration tests green.
|
||||||
- Phase 11 (mock services): services/atomizer (own Go module, ports 8090, bearer auth, OpenAI-compatible chat completions + native /v1/messages via LLM_API_STYLE, strict-JSON prompt, fence-stripping defensive parse, one retry, deterministic equal-split fallback, exact sum normalization); services/work-performer (Node 20, zero deps, single-concurrency queue, TASK.md + attachment download + optional git clone, claude -p --output-format json --dangerously-skip-permissions, simulated result when CLI unavailable, artifact serving, HMAC-signed callbacks with retry); compose profile mocks w/ healthchecks + ${HOME}/.claude mounts; contract tests pass live (WP ran real Claude Code via mounted host creds). UFW rule added for container→host callbacks.
|
- Phase 11 (mock services): services/atomizer (own Go module, ports 8090, bearer auth, OpenAI-compatible chat completions + native /v1/messages via LLM_API_STYLE, strict-JSON prompt, fence-stripping defensive parse, one retry, deterministic equal-split fallback, exact sum normalization); services/work-performer (Node 20, zero deps, single-concurrency queue, TASK.md + attachment download + optional git clone, claude -p --output-format json --dangerously-skip-permissions, simulated result when CLI unavailable, artifact serving, HMAC-signed callbacks with retry); compose profile mocks w/ healthchecks + ${HOME}/.claude mounts; contract tests pass live (WP ran real Claude Code via mounted host creds). UFW rule added for container→host callbacks.
|
||||||
- Phase 12 (polish): seed script (make seed: demo customer + 1 admin + 2 consultants + 6 devs + pools + conversations, idempotent), forgot/reset password (SMTP-gated, one-shot TTL tokens, anti-enumeration), profile hover cards, keyboard shortcuts (g b / g m / g t / g h / focus search), bulk archive endpoint, hand-written api/openapi.yaml served at /api/docs, make backup/restore via mongodump, README w/ runbook + Caddy & nginx samples (WS + client_max_body_size), extended register→login→board smoke script — all tests green.
|
- Phase 12 (polish): seed script (make seed: demo customer + 1 admin + 2 consultants + 6 devs + pools + conversations, idempotent), forgot/reset password (SMTP-gated, one-shot TTL tokens, anti-enumeration), profile hover cards, keyboard shortcuts (g b / g m / g t / g h / focus search), bulk archive endpoint, hand-written api/openapi.yaml served at /api/docs, make backup/restore via mongodump, README w/ runbook + Caddy & nginx samples (WS + client_max_body_size), extended register→login→board smoke script — all tests green.
|
||||||
|
- Phase 13 (test & deploy): full unit (10 pkgs) + integration (12 pkgs) + contract suites green; scripts/acceptance.sh automates the §13 checklist live and PASSES end-to-end incl. a real Claude Code AI work-performer run; readyz verified 503/200 across a Mongo stop/start; smoke PASS; stack left running with mocks profile. Fixes: APP_INTERNAL_URL for in-network callbacks/signed URLs, work-performer runs as node user (claude refuses root), sudo HOME gotcha documented, UFW rule for container→host callbacks.
|
||||||
|
|||||||
@@ -26,6 +26,10 @@ docker compose --profile mocks up -d --build
|
|||||||
open http://localhost:8787
|
open http://localhost:8787
|
||||||
```
|
```
|
||||||
|
|
||||||
|
> **Note:** if you run compose via `sudo`, use
|
||||||
|
> `sudo --preserve-env=HOME docker compose …` — otherwise `${HOME}` resolves
|
||||||
|
> to `/root` and the work performer mounts the wrong `~/.claude` credentials.
|
||||||
|
|
||||||
First login: `ADMIN_EMAIL` / `ADMIN_INITIAL_PASSWORD` from `.env` — a
|
First login: `ADMIN_EMAIL` / `ADMIN_INITIAL_PASSWORD` from `.env` — a
|
||||||
password change is forced immediately.
|
password change is forced immediately.
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -109,7 +109,7 @@ func run() error {
|
|||||||
|
|
||||||
// Background job queue + atomization handlers.
|
// Background job queue + atomization handlers.
|
||||||
runner := jobs.NewRunner(st, log, reg, 4)
|
runner := jobs.NewRunner(st, log, reg, 4)
|
||||||
atomSvc := atomize.NewService(st, atomClient, log, cfg.AppBaseURL,
|
atomSvc := atomize.NewService(st, atomClient, log, cfg.AppInternalURL,
|
||||||
[]byte(cfg.SessionSecret), srv.Publish)
|
[]byte(cfg.SessionSecret), srv.Publish)
|
||||||
runner.Register(atomize.JobKindSubdivide, atomSvc.HandleSubdivide)
|
runner.Register(atomize.JobKindSubdivide, atomSvc.HandleSubdivide)
|
||||||
runner.Register(atomize.JobKindExtend, atomSvc.HandleExtend)
|
runner.Register(atomize.JobKindExtend, atomSvc.HandleExtend)
|
||||||
|
|||||||
+7
-3
@@ -27,6 +27,9 @@ services:
|
|||||||
env_file: .env
|
env_file: .env
|
||||||
environment:
|
environment:
|
||||||
MONGO_URI: mongodb://mongo:27017
|
MONGO_URI: mongodb://mongo:27017
|
||||||
|
# in-network address handed to the external services for callbacks
|
||||||
|
# and signed attachment URLs (§5.2 example: http://app:8787/…)
|
||||||
|
APP_INTERNAL_URL: http://app:8787
|
||||||
ports:
|
ports:
|
||||||
- "127.0.0.1:${APP_PORT:-8787}:8787"
|
- "127.0.0.1:${APP_PORT:-8787}:8787"
|
||||||
depends_on:
|
depends_on:
|
||||||
@@ -69,10 +72,11 @@ services:
|
|||||||
PORT: "8091"
|
PORT: "8091"
|
||||||
WORK_PERFORMER_TOKEN: ${WORK_PERFORMER_TOKEN}
|
WORK_PERFORMER_TOKEN: ${WORK_PERFORMER_TOKEN}
|
||||||
PUBLIC_BASE_URL: http://work-performer:8091
|
PUBLIC_BASE_URL: http://work-performer:8091
|
||||||
# host Claude Code config/secrets, read-only (§9.2)
|
# host Claude Code config/secrets (§9.2); mounted into the node user's
|
||||||
|
# home because claude refuses --dangerously-skip-permissions as root
|
||||||
volumes:
|
volumes:
|
||||||
- ${HOME}/.claude:/root/.claude
|
- ${HOME}/.claude:/home/node/.claude
|
||||||
- ${HOME}/.claude.json:/root/.claude.json
|
- ${HOME}/.claude.json:/home/node/.claude.json
|
||||||
- work-data:/work
|
- work-data:/work
|
||||||
ports:
|
ports:
|
||||||
- "127.0.0.1:8091:8091"
|
- "127.0.0.1:8091:8091"
|
||||||
|
|||||||
@@ -37,6 +37,10 @@ func (o OIDC) Enabled() bool {
|
|||||||
|
|
||||||
type Config struct {
|
type Config struct {
|
||||||
AppBaseURL string
|
AppBaseURL string
|
||||||
|
// AppInternalURL is how the external services reach the app from inside
|
||||||
|
// the compose network (callback URLs, signed attachment URLs). Defaults
|
||||||
|
// to AppBaseURL.
|
||||||
|
AppInternalURL string
|
||||||
TrustedProxyCIDRs []netip.Prefix
|
TrustedProxyCIDRs []netip.Prefix
|
||||||
AppPort int
|
AppPort int
|
||||||
MongoURI string
|
MongoURI string
|
||||||
@@ -132,6 +136,7 @@ func Load() (*Config, error) {
|
|||||||
CookieSecureMode: getenv("COOKIE_SECURE", "auto"),
|
CookieSecureMode: getenv("COOKIE_SECURE", "auto"),
|
||||||
AtomizeMaxConcurrency: atoi("ATOMIZE_MAX_CONCURRENCY", "3"),
|
AtomizeMaxConcurrency: atoi("ATOMIZE_MAX_CONCURRENCY", "3"),
|
||||||
}
|
}
|
||||||
|
c.AppInternalURL = strings.TrimRight(getenv("APP_INTERNAL_URL", c.AppBaseURL), "/")
|
||||||
c.AtomizerTimeout = time.Duration(atoi("ATOMIZER_TIMEOUT_SEC", "120")) * time.Second
|
c.AtomizerTimeout = time.Duration(atoi("ATOMIZER_TIMEOUT_SEC", "120")) * time.Second
|
||||||
c.WorkPerformerHTTPTimeout = time.Duration(atoi("WORK_PERFORMER_HTTP_TIMEOUT_SEC", "30")) * time.Second
|
c.WorkPerformerHTTPTimeout = time.Duration(atoi("WORK_PERFORMER_HTTP_TIMEOUT_SEC", "30")) * time.Second
|
||||||
|
|
||||||
|
|||||||
@@ -140,7 +140,7 @@ func (s *Server) handleAssignAI(w http.ResponseWriter, r *http.Request) {
|
|||||||
for _, a := range t.Attachments {
|
for _, a := range t.Attachments {
|
||||||
url := a.URL
|
url := a.URL
|
||||||
if a.FileID != "" {
|
if a.FileID != "" {
|
||||||
url = files.SignedURL(s.cfg.AppBaseURL, []byte(s.cfg.SessionSecret), a.FileID,
|
url = files.SignedURL(s.cfg.AppInternalURL, []byte(s.cfg.SessionSecret), a.FileID,
|
||||||
time.Now().Add(files.DefaultTokenTTL))
|
time.Now().Add(files.DefaultTokenTTL))
|
||||||
}
|
}
|
||||||
atts = append(atts, workperform.Attachment{Name: a.Name, URL: url, MimeType: a.MimeType})
|
atts = append(atts, workperform.Attachment{Name: a.Name, URL: url, MimeType: a.MimeType})
|
||||||
@@ -153,7 +153,7 @@ func (s *Server) handleAssignAI(w http.ResponseWriter, r *http.Request) {
|
|||||||
Attachments: atts,
|
Attachments: atts,
|
||||||
Links: t.Links,
|
Links: t.Links,
|
||||||
Context: req.Context,
|
Context: req.Context,
|
||||||
CallbackURL: s.cfg.AppBaseURL + "/api/v1/internal/work-results",
|
CallbackURL: s.cfg.AppInternalURL + "/api/v1/internal/work-results",
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
s.log.Error("submit wp job", "task", t.ID, "err", err)
|
s.log.Error("submit wp job", "task", t.ID, "err", err)
|
||||||
|
|||||||
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"
|
||||||
@@ -4,6 +4,10 @@ RUN apt-get update && apt-get install -y --no-install-recommends git ca-certific
|
|||||||
&& npm install -g @anthropic-ai/claude-code
|
&& npm install -g @anthropic-ai/claude-code
|
||||||
WORKDIR /srv
|
WORKDIR /srv
|
||||||
COPY server.js .
|
COPY server.js .
|
||||||
RUN mkdir -p /work
|
# claude refuses --dangerously-skip-permissions as root: run as the node
|
||||||
|
# user (uid 1000, matches typical host ownership of the mounted ~/.claude)
|
||||||
|
RUN mkdir -p /work && chown node:node /work /srv
|
||||||
|
USER node
|
||||||
|
ENV HOME=/home/node
|
||||||
EXPOSE 8091
|
EXPOSE 8091
|
||||||
CMD ["node", "server.js"]
|
CMD ["node", "server.js"]
|
||||||
|
|||||||
Reference in New Issue
Block a user