Files
BountyBoard/README.md
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

193 lines
7.3 KiB
Markdown
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.
# Bounty Board
A consulting work-management platform: imports tickets from Jira / Azure
DevOps / YouTrack (or an offline `demo` source), lets **consultants** atomize
them into small developer tasks with AI assistance, publishes them on a
**bounty board** where **developers** claim work (or an **AI work performer**
does it), and tracks review, approval, and bounty-based metrics.
Built per [specification.md](specification.md): Go ≥ 1.22 + stdlib `net/http`,
MongoDB, server-rendered templates + vanilla ES modules (no build step),
Docker Compose deployment. Architectural decisions are logged in
[DECISIONS.md](DECISIONS.md); build history in [PROGRESS.md](PROGRESS.md).
## Quick start
```bash
cp .env.example .env
# then edit .env:
# SESSION_SECRET=$(openssl rand -base64 32)
# CREDENTIALS_ENC_KEY=$(openssl rand -base64 32)
# ADMIN_INITIAL_PASSWORD=<something strong>
# ATOMIZER_TOKEN / WORK_PERFORMER_TOKEN = random strings
# ANTHROPIC_API_KEY=<your key> # optional; offline fallback without it
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.
Without `--profile mocks` only `app` + `mongo` start and the external
service URLs in `.env` must point at real implementations of the §5
contracts.
### Demo data
```bash
docker compose -f docker-compose.yml -f docker-compose.test.yml up -d # publishes mongo on loopback
make seed
```
Seeds a demo customer (offline `demo` ticketing — tickets appear within one
poll), consultants `clara@example.com` / `carlos@example.com`, developers
`dev1@example.com``dev6@example.com` (password `demo-pass-123` for all),
pools, and sample conversations.
## Services
| Service | Port (loopback) | What |
|---|---|---|
| `app` | 8787 | Bounty Board (UI + API + WS) |
| `mongo` | — (never published; test overlay adds 27017) | database |
| `atomizer-mock` | 8090 (profile `mocks`) | §5.1 Atomization Service — Anthropic chat completions (or native `/v1/messages` via `LLM_API_STYLE=anthropic`), deterministic fallback without an API key |
| `work-performer` | 8091 (profile `mocks`) | §5.2 Work Performer — runs Claude Code with the host's `~/.claude` mounted read-only; simulated result when the CLI is unavailable |
The two external services are intentionally independent: separate codebases
(`services/atomizer`, `services/work-performer`), containers, ports, and
bearer tokens. Swap either by changing its base URL + token in `.env`.
## Development
```bash
make build # app + atomizer mock
make test # unit tests (no Mongo needed)
make test-integration # needs mongo on loopback (test overlay)
make test-contract # needs the mocks profile running
make lint # gofmt + go vet
make run # run the app locally (expects Mongo + .env)
make smoke # curl smoke test against a running stack
```
API reference: `GET /api/docs` (rendered) or [api/openapi.yaml](api/openapi.yaml).
## Operations runbook
**Logs** — JSON to stdout: `docker compose logs -f app` (request ids, sync
runs, job retries). `docker compose logs -f atomizer-mock work-performer`
for the mocks.
**Health**`GET /healthz` (liveness), `GET /readyz` (Mongo required;
atomizer/work-performer reported non-fatally), `GET /metricsz` (JSON
counters: requests, sync runs, job queue depths). The admin UI →
*Service status* shows live health, latency, circuit-breaker state, sync
workers, and queue depths.
**Backups** (§11.23):
```bash
make backup # mongodump → ./backups/bountyboard-<ts>.archive
make restore FILE=backups/<archive> # mongorestore --drop
```
Cron example: `0 3 * * * cd /opt/bountyboard && make backup >/dev/null`.
**Circuit breakers** — after 3 consecutive failures calls to an external
service stop for 60 s (one half-open probe per minute afterwards). The
consultant board disables Subdivide/Extend while the atomizer is down; AI
assignment fails fast while the work performer is down. Each service has an
independent breaker.
**Stuck jobs** — background jobs (`jobs` collection) retry 3× with backoff;
jobs stuck `running` after a crash are re-queued automatically within
5 minutes.
## Reverse proxy
The app expects a TLS-terminating proxy (§12). Set in `.env`:
```dotenv
APP_BASE_URL=https://bounty.example.com
TRUSTED_PROXY_CIDRS=172.16.0.0/12 # the proxy's source range as seen by the app
COOKIE_SECURE=auto
```
`X-Forwarded-*` headers are honored only from `TRUSTED_PROXY_CIDRS`; the
resolved client IP feeds rate limiting and the audit log. WebSocket
heartbeats (30 s) keep idle proxy timeouts from killing `/ws`.
### Caddy
```caddyfile
bounty.example.com {
encode gzip
reverse_proxy 127.0.0.1:8787
# WebSockets are proxied automatically; raise the body limit to match MAX_UPLOAD_MB
request_body {
max_size 25MB
}
}
```
### nginx
```nginx
server {
listen 443 ssl http2;
server_name bounty.example.com;
ssl_certificate /etc/letsencrypt/live/bounty.example.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/bounty.example.com/privkey.pem;
client_max_body_size 25m; # match MAX_UPLOAD_MB
location /ws {
proxy_pass http://127.0.0.1:8787;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_set_header Host $host;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_read_timeout 120s; # > the 30s WS heartbeat
}
location / {
proxy_pass http://127.0.0.1:8787;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Forwarded-Host $host;
}
}
```
## Configuration
All configuration comes from `.env` (see `.env.example` for every knob with
comments): Mongo, session/credential secrets, bootstrap admin, the two
external service URLs + tokens + timeouts, SMTP (enables forgot-password
emails when set), OIDC SSO (buttons appear when set), upload limit, rate
limits, and the mock services' Anthropic settings.
Consultants map their ticketing identities in their profile via
`users.extra.ticketingIdentities` (e.g. set by an admin):
`{"jira": "consultant@corp.com", "azure_devops": "consultant@corp.com", "youtrack": "consultant.login"}`.
The `demo` ticketing type needs no identity.
## Deliberately out of scope (documented stubs, §11.24)
- **Webhook ingestion** instead of polling — the sync layer is keyed on an
idempotent `(system, key, customerId)` upsert, so a webhook receiver can
reuse `UpsertImportedTask` unchanged.
- **Status write-back** to customer systems — v1 sync is read-only.
- **Email digests** — notifications are in-app + WS; the mailer exists and
is used for password resets.
- **Production AI work performer** — the §5.2 HTTP contract is final; the
shipped container is a placeholder.
- **Localization** — UI strings are English-only.