diff --git a/.env.example b/.env.example index 0776805..e00cb81 100644 --- a/.env.example +++ b/.env.example @@ -1,6 +1,9 @@ # --- core --- # Set APP_BASE_URL to the public https URL when behind the reverse proxy. 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. # Empty = trust no proxy headers. TRUSTED_PROXY_CIDRS= diff --git a/DECISIONS.md b/DECISIONS.md index e7103da..291201e 100644 --- a/DECISIONS.md +++ b/DECISIONS.md @@ -104,3 +104,36 @@ Spec-silent choices, recorded as required by the build instructions. /change-password. - **Pages for later phases render an "under construction" placeholder** so 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. diff --git a/PROGRESS.md b/PROGRESS.md index 80b0834..773d723 100644 --- a/PROGRESS.md +++ b/PROGRESS.md @@ -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 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 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. diff --git a/README.md b/README.md index 1933c5c..725bafb 100644 --- a/README.md +++ b/README.md @@ -26,6 +26,10 @@ docker compose --profile mocks up -d --build 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 password change is forced immediately. diff --git a/cmd/app/main.go b/cmd/app/main.go index 40df7df..77d3fc6 100644 --- a/cmd/app/main.go +++ b/cmd/app/main.go @@ -109,7 +109,7 @@ func run() error { // Background job queue + atomization handlers. 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) runner.Register(atomize.JobKindSubdivide, atomSvc.HandleSubdivide) runner.Register(atomize.JobKindExtend, atomSvc.HandleExtend) diff --git a/docker-compose.yml b/docker-compose.yml index c5825e7..373ed87 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -27,6 +27,9 @@ services: env_file: .env environment: 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: - "127.0.0.1:${APP_PORT:-8787}:8787" depends_on: @@ -69,10 +72,11 @@ services: PORT: "8091" WORK_PERFORMER_TOKEN: ${WORK_PERFORMER_TOKEN} 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: - - ${HOME}/.claude:/root/.claude - - ${HOME}/.claude.json:/root/.claude.json + - ${HOME}/.claude:/home/node/.claude + - ${HOME}/.claude.json:/home/node/.claude.json - work-data:/work ports: - "127.0.0.1:8091:8091" diff --git a/internal/config/config.go b/internal/config/config.go index 8fb9331..5b85229 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -36,7 +36,11 @@ func (o OIDC) Enabled() bool { } 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 AppPort int MongoURI string @@ -132,6 +136,7 @@ func Load() (*Config, error) { CookieSecureMode: getenv("COOKIE_SECURE", "auto"), 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.WorkPerformerHTTPTimeout = time.Duration(atoi("WORK_PERFORMER_HTTP_TIMEOUT_SEC", "30")) * time.Second diff --git a/internal/http/consultant.go b/internal/http/consultant.go index 9d6b776..e83c735 100644 --- a/internal/http/consultant.go +++ b/internal/http/consultant.go @@ -140,7 +140,7 @@ func (s *Server) handleAssignAI(w http.ResponseWriter, r *http.Request) { for _, a := range t.Attachments { url := a.URL 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)) } 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, Links: t.Links, Context: req.Context, - CallbackURL: s.cfg.AppBaseURL + "/api/v1/internal/work-results", + CallbackURL: s.cfg.AppInternalURL + "/api/v1/internal/work-results", }) if err != nil { s.log.Error("submit wp job", "task", t.ID, "err", err) diff --git a/scripts/acceptance.sh b/scripts/acceptance.sh new file mode 100755 index 0000000..e91ede3 --- /dev/null +++ b/scripts/acceptance.sh @@ -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":"

done, see notes

"}' > /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" diff --git a/services/work-performer/Dockerfile b/services/work-performer/Dockerfile index 6ac7967..3bc9026 100644 --- a/services/work-performer/Dockerfile +++ b/services/work-performer/Dockerfile @@ -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 WORKDIR /srv 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 CMD ["node", "server.js"]