6265ffa894
- 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>
193 lines
7.3 KiB
Markdown
193 lines
7.3 KiB
Markdown
# 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.
|