feat: scaffold app skeleton with config, ULID, health endpoints, compose stack (phase 1)
- internal .env loader (real env wins) + strictly validated config struct - ULID package: 48-bit ms timestamp + 80-bit crypto randomness, sortable - HTTP server with recovery/request-id/trusted-proxy/access-log middleware - /healthz, /readyz (pluggable checkers), /metricsz (counter registry) - multi-stage Dockerfile (alpine, non-root) + compose with healthchecks, app bound to 127.0.0.1:8787, mongo unpublished, graceful 15s shutdown Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,813 @@
|
||||
# Bounty Board — System Specification
|
||||
|
||||
Version: 1.0 (draft)
|
||||
Status: Ready for implementation
|
||||
|
||||
---
|
||||
|
||||
## 1. Overview
|
||||
|
||||
Bounty Board is a consulting work-management platform. It imports tickets from a customer's
|
||||
existing ticketing system (Jira, Azure DevOps, or YouTrack), lets a **consultant** atomize
|
||||
them into small, well-scoped developer tasks (with AI assistance via an external Atomization
|
||||
Service), publishes those tasks on a **bounty board** where **developers** claim work, and
|
||||
tracks review, approval, and bounty-based performance metrics. Tasks may alternatively be
|
||||
assigned to an **AI work performer** (pluggable interface; Claude Code-based placeholder).
|
||||
|
||||
### 1.1 Core flow
|
||||
|
||||
```
|
||||
Customer ticketing system (Jira / Azure DevOps / YouTrack)
|
||||
│ (ticket assigned to consultant's linked account)
|
||||
▼
|
||||
Sync worker (poll) ──► Imported task on Atomization Board (consultant view)
|
||||
│
|
||||
▼
|
||||
Consultant: Subdivide (optional note) ──► Atomization Service ──► N subtasks
|
||||
Extend (required note) ──► Extension API ──► 1 sibling task
|
||||
│ (consultant edits titles/descriptions/AC/effort coefficients, sets budget)
|
||||
▼
|
||||
Publish ──► Bounty Board (developer view, bounty = coefficient × budget)
|
||||
│
|
||||
├── Developer requests assignment ──► Consultant approves ──► In progress
|
||||
│ ──► Developer marks "In review" ──► Consultant approves/rejects
|
||||
│ ──► Approved: bounty awarded to developer, synced stats
|
||||
│
|
||||
└── Consultant assigns to AI ──► Work Performer Service ──► result ──► review
|
||||
```
|
||||
|
||||
### 1.2 Goals and non-goals
|
||||
|
||||
Goals: reliability, speed (proper Mongo indexes), minimal dependencies, modern square-edged
|
||||
UI with light (beige) and dark themes, OIDC SSO **and** local accounts, instant messaging
|
||||
with files/images/rich text, health checks, full test coverage, Docker Compose deployment.
|
||||
|
||||
Non-goals (v1): real AI work performer implementation (interface + Claude Code placeholder
|
||||
container only), billing/payments (bounty is a performance metric, not money), mobile apps,
|
||||
pushing changes back into the customer's ticketing system (read-only sync in v1; status
|
||||
write-back is listed as a quality-of-life extension).
|
||||
|
||||
---
|
||||
|
||||
## 2. Technology choices (minimal dependencies)
|
||||
|
||||
### 2.1 Backend — Go (≥ 1.22)
|
||||
|
||||
| Concern | Choice | Rationale |
|
||||
|---|---|---|
|
||||
| HTTP server & routing | `net/http` stdlib (Go 1.22 pattern routing: `GET /api/v1/tasks/{id}`) | zero deps, reliable |
|
||||
| MongoDB | `go.mongodb.org/mongo-driver/v2` | official driver, required |
|
||||
| OIDC | `github.com/coreos/go-oidc/v3` + `golang.org/x/oauth2` | de-facto standard, small |
|
||||
| Passwords | `golang.org/x/crypto/argon2` | stdlib-adjacent |
|
||||
| WebSockets (chat, live updates) | `github.com/coder/websocket` (single, maintained, no transitive deps) | gorilla is in maintenance mode; coder/websocket is minimal |
|
||||
| JWT sessions | **none** — opaque session tokens stored in Mongo, httpOnly+Secure+SameSite=Lax cookie | fewer deps, instant revocation |
|
||||
| Config | stdlib `os.Getenv` + tiny internal `.env` loader (~40 lines, no lib) | zero deps |
|
||||
| Logging | `log/slog` stdlib, JSON handler | zero deps |
|
||||
| Testing | stdlib `testing` + `httptest`; `testcontainers` is NOT used — integration tests run against the compose Mongo with a dedicated test database | zero extra deps |
|
||||
|
||||
Everything else (Jira/Azure/YouTrack clients, rate limiting, CSRF, validation, ID
|
||||
generation, AES-GCM encryption of customer credentials) is implemented with the standard
|
||||
library.
|
||||
|
||||
### 2.2 Frontend — no framework, no build step
|
||||
|
||||
- Server-rendered Go `html/template` pages + small vanilla ES-module JavaScript per page.
|
||||
- No npm, no bundler. Static assets served by the Go binary from `embed.FS`.
|
||||
- CSS: hand-written, CSS custom properties for theming (`data-theme="light|dark"` on
|
||||
`<html>`, persisted in `localStorage` + user profile). Square edges: `--radius: 2px`
|
||||
globally. Light theme = beige palette (see §10).
|
||||
- Live updates: one WebSocket per logged-in session multiplexing channels
|
||||
(`chat`, `board`, `notifications`). Graceful fallback to 15 s polling if WS fails.
|
||||
- Rich text in chat: `contenteditable` with a whitelist sanitizer **on the server**
|
||||
(allowed tags: `b i u s a code pre ul ol li br p blockquote`). No editor library.
|
||||
- Drag & drop file/image upload in chat; images previewed inline.
|
||||
|
||||
### 2.3 Services (Docker Compose)
|
||||
|
||||
| Service | Image / build | Purpose |
|
||||
|---|---|---|
|
||||
| `app` | `./` (multi-stage Go build, distroless or alpine) | main Bounty Board app |
|
||||
| `mongo` | `mongo:7` | database, named volume |
|
||||
| `atomizer-mock` | `./services/atomizer` (Go, port 8090, compose profile `mocks`) | standalone placeholder Atomization Service (atomize + extend API) backed by Anthropic's OpenAI-compatible chat-completions endpoint |
|
||||
| `work-performer` | `./services/work-performer` (Node base + `@anthropic-ai/claude-code`, port 8091, compose profile `mocks`) | standalone placeholder Work Performer Service; mounts local Claude Code config/secrets |
|
||||
|
||||
---
|
||||
|
||||
## 3. Roles & permissions
|
||||
|
||||
One account can hold multiple roles (boolean flags on the user document).
|
||||
|
||||
| Capability | Admin | Consultant | Developer |
|
||||
|---|---|---|---|
|
||||
| Application settings (SMTP, OIDC, atomizer URL override, branding) | ✔ | | |
|
||||
| Customer CRUD + ticketing-system connection setup & test | ✔ | | |
|
||||
| User management (roles, disable, reset password, delete) | ✔ | | |
|
||||
| Assign consultants to customers/projects | ✔ | | |
|
||||
| Select developers from global pool into own pool | | ✔ | |
|
||||
| Atomization board (subdivide / extend / edit / set budget / publish) | | ✔ (own customers) | |
|
||||
| Approve developer assignment requests; assign to AI | | ✔ | |
|
||||
| Review / approve / reject submitted work; award bounty | | ✔ | |
|
||||
| Bounty board (view + request assignment for own consultants' tasks) | | | ✔ |
|
||||
| Work tracking on own tasks (status, comments, time log, mark for review) | | | ✔ |
|
||||
| Own metrics dashboard | ✔ (global) | ✔ (per project/dev) | ✔ (own) |
|
||||
| Messaging | ✔ | ✔ | ✔ |
|
||||
| Edit own profile (avatar, name, contacts, bio, arbitrary key/value fields) | ✔ | ✔ | ✔ |
|
||||
|
||||
Registration: open self-registration creates a **developer** account in the global pool.
|
||||
Admin and consultant flags are granted only by an admin. First-run bootstrap: if the users
|
||||
collection is empty, credentials from `ADMIN_EMAIL` / `ADMIN_INITIAL_PASSWORD` env vars
|
||||
create the initial admin (password change forced at first login).
|
||||
|
||||
---
|
||||
|
||||
## 4. Data model (MongoDB, database `bountyboard`)
|
||||
|
||||
Conventions: `_id` is a ULID string (sortable, generated in Go, no dep — ~80-line internal
|
||||
package). All documents carry `createdAt`, `updatedAt` (UTC), and `version` (int,
|
||||
incremented on update; used for optimistic concurrency — updates filter on `version` and
|
||||
return 409 on mismatch).
|
||||
|
||||
### 4.1 `users`
|
||||
|
||||
```jsonc
|
||||
{
|
||||
"_id": "01J...",
|
||||
"email": "a@b.c", // unique, lowercase
|
||||
"name": "Jane Doe",
|
||||
"avatarFileId": "01J...|null", // ref files
|
||||
"bio": "…",
|
||||
"contact": { "phone": "…", "location": "…", "links": ["…"] },
|
||||
"extra": { "anyKey": "anyValue" }, // arbitrary admin/self-defined info
|
||||
"roles": { "admin": false, "consultant": false, "developer": true },
|
||||
"auth": {
|
||||
"local": { "passwordHash": "argon2id$…", "mustChange": false } | null,
|
||||
"oidc": { "issuer": "…", "subject": "…" } | null // at least one of local/oidc
|
||||
},
|
||||
"settings": { "theme": "light", "notifications": { "email": true, "inApp": true } },
|
||||
"disabled": false,
|
||||
"lastSeenAt": ISODate
|
||||
}
|
||||
```
|
||||
|
||||
### 4.2 `customers`
|
||||
|
||||
```jsonc
|
||||
{
|
||||
"_id": "01J...",
|
||||
"name": "ACME Corp",
|
||||
"ticketing": {
|
||||
"type": "jira" | "azure_devops" | "youtrack",
|
||||
"baseUrl": "https://acme.atlassian.net",
|
||||
// encrypted blob, AES-256-GCM with key from CREDENTIALS_ENC_KEY (32B base64):
|
||||
"credentialsEnc": "base64(nonce|ciphertext)",
|
||||
// plaintext shape before encryption, by type:
|
||||
// jira: { "email": "...", "apiToken": "..." }
|
||||
// azure_devops:{ "organization": "...", "project": "...", "pat": "..." }
|
||||
// youtrack: { "permanentToken": "..." }
|
||||
"projectKey": "ACME", // Jira project key / ADO project / YouTrack project
|
||||
"pollIntervalSec": 60,
|
||||
"lastSyncAt": ISODate, "lastSyncStatus": "ok|error", "lastSyncError": "…"
|
||||
},
|
||||
"consultantIds": ["01J..."], // consultants assigned to this customer (≥1 to sync)
|
||||
"archived": false
|
||||
}
|
||||
```
|
||||
|
||||
A "project" in the UI == a customer. Multiple consultants per customer and multiple
|
||||
customers per consultant are both supported by this single array.
|
||||
|
||||
### 4.3 `pools` — developer pool membership
|
||||
|
||||
```jsonc
|
||||
{ "_id": "01J...", "consultantId": "01J...", "developerId": "01J...", "addedAt": ISODate }
|
||||
```
|
||||
|
||||
### 4.4 `tasks`
|
||||
|
||||
```jsonc
|
||||
{
|
||||
"_id": "01J...",
|
||||
"customerId": "01J...",
|
||||
"consultantId": "01J...", // owning consultant (the sync assignee); ALL
|
||||
// consultants assigned to the customer can view and
|
||||
// manage the task — consultantId records provenance
|
||||
// and is the default reviewer/notification target
|
||||
"origin": "imported" | "subdivided" | "extended",
|
||||
"parentId": "01J...|null", // imported root for subdivided; sibling source for extended
|
||||
"rootId": "01J...", // top imported ancestor (== _id for imported)
|
||||
"external": { // only for origin=imported
|
||||
"system": "jira|azure_devops|youtrack",
|
||||
"key": "ACME-123", "url": "…",
|
||||
"type": "epic" | "story" | "task", // shown as icon only; treated uniformly
|
||||
"raw": { … } // original payload snapshot
|
||||
} | null,
|
||||
"title": "…",
|
||||
"description": "…", // markdown-ish plain text
|
||||
"acceptanceCriteria": ["…", "…"],
|
||||
"attachments": [{ "name": "…", "url": "…", "mimeType": "…", "fileId": "01J...|null" }],
|
||||
"links": ["https://…"],
|
||||
"effortCoefficient": 0.25, // 0..1; siblings under one parent sum to 1.0
|
||||
"budget": 1000, // set by consultant on root; inherited, overridable
|
||||
"bounty": 250, // computed = effortCoefficient × budget; denormalized
|
||||
"status": "imported" | "atomizing" | "atomized" | "published"
|
||||
| "claim_requested" | "assigned" | "in_progress"
|
||||
| "in_review" | "changes_requested" | "approved" | "archived",
|
||||
"assignee": { "kind": "human"|"ai", "userId": "01J...|null", "jobId": "…|null" } | null,
|
||||
"claimRequests": [{ "developerId": "01J...", "note": "…", "at": ISODate }],
|
||||
"atomizationNote": "…|null", // last subdivide note
|
||||
"timeline": [{ "at": ISODate, "actorId": "01J...|system", "event": "…", "data": {…} }],
|
||||
"comments": [{ "_id": "01J...", "authorId": "…", "body": "…(sanitized html)", "at": ISODate }],
|
||||
"timeLog": [{ "developerId": "…", "minutes": 90, "note": "…", "at": ISODate }]
|
||||
}
|
||||
```
|
||||
|
||||
Status rules (enforced server-side, single source of truth in `internal/domain/status.go`):
|
||||
|
||||
```
|
||||
imported ──subdivide──► atomizing ──service ok──► (children created: atomized)
|
||||
imported/atomized ──extend──► sibling created (atomized)
|
||||
atomized ──consultant publishes──► published
|
||||
published ──dev requests──► claim_requested ──consultant approves──► assigned
|
||||
published ──consultant assigns AI──► assigned (assignee.kind=ai)
|
||||
assigned ──dev starts──► in_progress ──dev submits──► in_review
|
||||
in_review ──consultant──► approved (bounty awarded) | changes_requested ──► in_progress
|
||||
claim_requested ──developer withdraws / consultant declines──► published (declined devs notified)
|
||||
assigned|in_progress ──consultant unassigns OR developer abandons──► published (timeline entry)
|
||||
any ──consultant/admin──► archived
|
||||
```
|
||||
|
||||
### 4.5 `bountyAwards`
|
||||
|
||||
```jsonc
|
||||
{ "_id": "01J...", "taskId": "…", "developerId": "…", "consultantId": "…",
|
||||
"customerId": "…", "amount": 250, "coefficient": 0.25, "awardedAt": ISODate }
|
||||
```
|
||||
|
||||
Immutable ledger; metrics aggregate from here (never recompute from mutable tasks).
|
||||
|
||||
### 4.6 `conversations` and `messages`
|
||||
|
||||
```jsonc
|
||||
// conversations
|
||||
{ "_id": "01J...", "kind": "dm" | "group" | "project",
|
||||
"customerId": "01J...|null", // for kind=project
|
||||
"title": "…|null",
|
||||
"participantIds": ["…"], // for dm exactly 2; unique key for dm
|
||||
"lastMessageAt": ISODate }
|
||||
|
||||
// messages
|
||||
{ "_id": "01J...", "conversationId": "…", "senderId": "…",
|
||||
"body": "<p>sanitized rich text</p>",
|
||||
"attachments": [{ "fileId": "…", "name": "…", "mimeType": "…", "size": 12345,
|
||||
"isImage": true }],
|
||||
"readBy": [{ "userId": "…", "at": ISODate }],
|
||||
"editedAt": ISODate|null, "deletedAt": ISODate|null }
|
||||
```
|
||||
|
||||
### 4.7 `files` (GridFS)
|
||||
|
||||
Uploads (avatars, chat attachments, imported ticket attachments cached locally) stored in
|
||||
GridFS buckets `fs`. Metadata: `ownerId`, `scope` (`avatar|chat|task`), `mimeType`,
|
||||
`size`, `sha256`. Max upload size: `MAX_UPLOAD_MB` (default 25). Served via
|
||||
`GET /files/{id}` with access control by scope.
|
||||
|
||||
### 4.8 `sessions`, `auditLog`, `notifications`, `settings`
|
||||
|
||||
- `sessions`: `{ _id: token(32B random, base64url), userId, createdAt, expiresAt, ip, ua }`
|
||||
with TTL index on `expiresAt`.
|
||||
- `auditLog`: every privileged mutation `{ at, actorId, action, entity, entityId, diff }`.
|
||||
- `notifications`: `{ userId, kind, title, body, link, readAt|null, createdAt }`.
|
||||
- `settings`: single document `{_id:"app"}` for admin-editable runtime settings
|
||||
(env vars are the defaults; DB overrides where marked overridable).
|
||||
|
||||
### 4.9 Indexes (created idempotently at startup, `internal/store/indexes.go`)
|
||||
|
||||
```
|
||||
users: { email: 1 } unique
|
||||
{ "auth.oidc.issuer": 1, "auth.oidc.subject": 1 } unique sparse
|
||||
customers: { name: 1 } unique; { consultantIds: 1 }
|
||||
pools: { consultantId: 1, developerId: 1 } unique; { developerId: 1 }
|
||||
tasks: { customerId: 1, status: 1, updatedAt: -1 }
|
||||
{ consultantId: 1, status: 1, updatedAt: -1 }
|
||||
{ "assignee.userId": 1, status: 1 }
|
||||
{ rootId: 1 } ; { parentId: 1 }
|
||||
{ "external.system": 1, "external.key": 1, customerId: 1 } unique sparse
|
||||
{ title: "text", description: "text" } // board search
|
||||
bountyAwards: { developerId: 1, awardedAt: -1 }
|
||||
{ consultantId: 1, awardedAt: -1 }
|
||||
{ customerId: 1, awardedAt: -1 }
|
||||
conversations: { participantIds: 1, lastMessageAt: -1 }
|
||||
{ kind: 1, customerId: 1 }
|
||||
messages: { conversationId: 1, _id: 1 } // ULID ⇒ time-ordered
|
||||
notifications: { userId: 1, readAt: 1, createdAt: -1 }
|
||||
sessions: { expiresAt: 1 } expireAfterSeconds: 0; { userId: 1 }
|
||||
auditLog: { at: -1 } ; { entityId: 1, at: -1 }
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 5. External service interfaces (REST)
|
||||
|
||||
The Atomization Service and the Work Performer Service are **two fully independent
|
||||
services**: separate codebases (`services/atomizer`, `services/work-performer`),
|
||||
separate containers, separate base URLs, separate ports, **separate bearer tokens**
|
||||
(`ATOMIZER_TOKEN`, `WORK_PERFORMER_TOKEN`), independent health checks, and independent
|
||||
versioning. The app must never assume they are co-located, share state, or share
|
||||
credentials; each is replaceable on its own by changing only its base URL + token in
|
||||
`.env`. Both are **owned interfaces**: the Bounty Board app is the client; any
|
||||
implementation honoring these contracts can be swapped in. Mock implementations ship in
|
||||
this repo (§9). Common conventions only: JSON, UTF-8, `Authorization: Bearer <token>`,
|
||||
errors as `{ "error": { "code": "string", "message": "string" } }`.
|
||||
|
||||
### 5.1 Atomization Service — base URL `ATOMIZER_BASE_URL`
|
||||
|
||||
#### POST `/v1/atomize`
|
||||
|
||||
Subdivides one task into smaller tasks.
|
||||
|
||||
Request:
|
||||
|
||||
```jsonc
|
||||
{
|
||||
"taskId": "01J...", // for tracing/idempotency
|
||||
"title": "Implement user import",
|
||||
"description": "…full original description…",
|
||||
"acceptanceCriteria": ["…"], // original AC (may be empty)
|
||||
// attachment URLs carry a short-lived signed token (?st=hmac, 1 h TTL) so external
|
||||
// services can fetch them WITHOUT a session cookie — /files/{id} accepts session OR valid st
|
||||
"attachments": [{ "name": "spec.pdf", "url": "https://app/files/01J...?st=…", "mimeType": "application/pdf" }],
|
||||
"links": ["https://acme.atlassian.net/browse/ACME-123"],
|
||||
"subdivisionNote": "split backend/frontend, keep DB migration separate", // optional
|
||||
"constraints": { "minTasks": 2, "maxTasks": 8 } // optional
|
||||
}
|
||||
```
|
||||
|
||||
Response `200`:
|
||||
|
||||
```jsonc
|
||||
{
|
||||
"tasks": [
|
||||
{
|
||||
"title": "DB migration for user import",
|
||||
"description": "…",
|
||||
"acceptanceCriteria": ["migration is reversible", "…"],
|
||||
"effortCoefficient": 0.2 // 0 < c ≤ 1
|
||||
},
|
||||
{ "title": "…", "description": "…", "acceptanceCriteria": ["…"], "effortCoefficient": 0.5 },
|
||||
{ "title": "…", "description": "…", "acceptanceCriteria": ["…"], "effortCoefficient": 0.3 }
|
||||
],
|
||||
"model": "claude-sonnet-4-6", // optional metadata
|
||||
"notes": "…" // optional, shown to consultant
|
||||
}
|
||||
```
|
||||
|
||||
Contract: `sum(effortCoefficient) == 1.0 ± 0.001`. The **client** (app) re-normalizes
|
||||
coefficients defensively if the sum deviates ≤ 0.05, otherwise treats it as a 502-class
|
||||
error. Timeout: `ATOMIZER_TIMEOUT_SEC` (default 120). The app sets the task to
|
||||
`atomizing` and processes the call in a background job; UI updates over WebSocket.
|
||||
|
||||
#### POST `/v1/extend`
|
||||
|
||||
Creates exactly one parallel (sibling) task extending the source task's functionality.
|
||||
|
||||
Request: same shape as `/v1/atomize` but with required `"extensionNote"` instead of
|
||||
`subdivisionNote`, and no `constraints`.
|
||||
|
||||
Response `200`:
|
||||
|
||||
```jsonc
|
||||
{
|
||||
"task": {
|
||||
"title": "User import — CSV mapping presets",
|
||||
"description": "…",
|
||||
"acceptanceCriteria": ["…"],
|
||||
"effortCoefficient": 0.4 // suggested effort RELATIVE TO THE SOURCE task (0..1+ allowed up to 2.0)
|
||||
},
|
||||
"model": "…", "notes": "…"
|
||||
}
|
||||
```
|
||||
|
||||
The new sibling's bounty = its coefficient × the same `budget` as the source. Because an
|
||||
extension adds scope, sibling coefficients under a parent are allowed to sum to > 1 once
|
||||
extensions exist; the UI shows the per-parent sum and lets the consultant rebalance or
|
||||
raise the budget.
|
||||
|
||||
#### GET `/healthz` → `200 {"status":"ok"}`
|
||||
|
||||
### 5.2 Work Performer Service — base URL `WORK_PERFORMER_BASE_URL`
|
||||
|
||||
Asynchronous job API. v1 ships a placeholder implementation; the contract is final.
|
||||
|
||||
- `POST /v1/jobs` — request body:
|
||||
|
||||
```jsonc
|
||||
{
|
||||
"taskId": "01J...",
|
||||
"title": "…", "description": "…", "acceptanceCriteria": ["…"],
|
||||
"attachments": [{ "name": "…", "url": "…", "mimeType": "…" }],
|
||||
"links": ["…"],
|
||||
"context": { "repositoryUrl": "…|null", "branch": "…|null", "instructions": "…|null" },
|
||||
"callbackUrl": "http://app:8787/api/v1/internal/work-results" // signed callback
|
||||
}
|
||||
```
|
||||
|
||||
→ `202 { "jobId": "wp_…", "status": "queued" }`
|
||||
|
||||
- `GET /v1/jobs/{jobId}` → `{ "jobId": "…", "status": "queued|running|succeeded|failed",
|
||||
"startedAt": "…", "finishedAt": "…|null" }`
|
||||
- `DELETE /v1/jobs/{jobId}` → cancel (best effort).
|
||||
- Callback (service → app): `POST {callbackUrl}` with header
|
||||
`X-Signature: hex(hmac-sha256(body, WORK_PERFORMER_TOKEN))`:
|
||||
|
||||
```jsonc
|
||||
{ "jobId": "wp_…", "taskId": "01J...", "status": "succeeded|failed",
|
||||
"summary": "what was done", "artifacts": [{ "name": "patch.diff", "url": "…" }],
|
||||
"log": "…tail of execution log…" }
|
||||
```
|
||||
|
||||
On `succeeded`, the app verifies the signature, treats the callback as **idempotent by
|
||||
`jobId`** (duplicates ignored), downloads all artifact URLs into GridFS (so review
|
||||
survives the performer container), and moves the task to `in_review` with the AI as
|
||||
submitter; the consultant reviews exactly as for a human. On `failed`, status returns to
|
||||
`assigned` with a timeline entry and a consultant notification.
|
||||
|
||||
- `GET /healthz` → `200`.
|
||||
|
||||
### 5.3 Inbound ticketing sync (Jira / Azure DevOps / YouTrack)
|
||||
|
||||
v1 uses **polling** (reliable, no inbound firewall requirements). A per-customer worker
|
||||
runs every `pollIntervalSec`, querying for tickets **assigned to the consultant's linked
|
||||
account** in that system, updated since `lastSyncAt − 5 min` (overlap window):
|
||||
|
||||
| System | Query mechanism |
|
||||
|---|---|
|
||||
| Jira Cloud | `GET /rest/api/3/search?jql=assignee="<email>" AND project=<key> AND updated >= "<ts>"` (Basic auth: email+API token) |
|
||||
| Azure DevOps | `POST …/_apis/wit/wiql?api-version=7.1` with WIQL `[System.AssignedTo] = '<email>' AND [System.TeamProject] = '<project>' AND [System.ChangedDate] >= '<ts>'`, then batch `GET workitems` (PAT auth) |
|
||||
| YouTrack | `GET /api/issues?query=assignee: <login> project: <key> updated: <ts> .. *` (Bearer permanent token) |
|
||||
|
||||
Mapping → task `external.type`: Jira Epic→epic, Story→story, else task; ADO Epic→epic,
|
||||
User Story/PBI→story, else task; YouTrack by issue type name, default task. Each
|
||||
consultant stores per-system identity in `users.extra.ticketingIdentities`
|
||||
(`{ jira: "email", azure_devops: "email", youtrack: "login" }`).
|
||||
|
||||
Upsert key: `(external.system, external.key, customerId)`. New tickets → status
|
||||
`imported`, notification to consultant. Updated tickets → refresh `external.raw`, title,
|
||||
description, attachments **only while status is `imported`** (after atomization begins,
|
||||
changes only add a timeline entry "upstream ticket changed" + notification). Attachments
|
||||
are downloaded into GridFS so the atomizer can fetch them from the app. If a previously
|
||||
imported ticket is no longer returned for the consultant (reassigned or deleted
|
||||
upstream), the task is **not** deleted: it gets `external.orphaned: true`, a timeline
|
||||
entry, a consultant notification, and a visible badge; the consultant decides whether to
|
||||
archive it.
|
||||
|
||||
A "Test connection" button on the customer form calls a cheap authenticated endpoint
|
||||
(`/myself`, `/_apis/projects`, `/api/users/me`) and reports success/failure inline.
|
||||
|
||||
---
|
||||
|
||||
## 6. Application HTTP API (selected, `/api/v1`)
|
||||
|
||||
All responses JSON. Auth: session cookie; CSRF: double-submit token header `X-CSRF-Token`
|
||||
required on mutations. Pagination: `?limit=50&cursor=<ulid>`. Full OpenAPI 3.1 document
|
||||
is generated by hand into `api/openapi.yaml` and served at `/api/docs` (rendered with
|
||||
embedded Swagger-UI-free minimal HTML viewer or plain YAML download).
|
||||
|
||||
```
|
||||
Auth
|
||||
POST /auth/register {email,name,password} → developer account
|
||||
POST /auth/login {email,password}
|
||||
POST /auth/logout
|
||||
GET /auth/oidc/login → redirect to provider (PKCE)
|
||||
GET /auth/oidc/callback
|
||||
GET /auth/me
|
||||
|
||||
Admin
|
||||
GET/POST/PATCH /admin/users… roles, disable, force-reset
|
||||
GET/POST/PATCH/DELETE /admin/customers…
|
||||
POST /admin/customers/{id}/test-connection
|
||||
POST /admin/customers/{id}/sync-now
|
||||
GET/PATCH /admin/settings
|
||||
GET /admin/audit-log
|
||||
|
||||
Consultant
|
||||
GET /consultant/board?customerId=&status= atomization board
|
||||
POST /tasks/{id}/subdivide { note?: string, constraints? } → 202 (async)
|
||||
POST /tasks/{id}/extend { note: string } → 202 (async)
|
||||
PATCH /tasks/{id} edit title/description/AC/coefficient/budget (version-checked)
|
||||
POST /tasks/{id}/publish single or POST /tasks/publish {ids:[…]}
|
||||
POST /tasks/{id}/approve-claim { developerId }
|
||||
POST /tasks/{id}/assign-ai { context?: {...} }
|
||||
POST /tasks/{id}/review { decision: "approve"|"request_changes", note }
|
||||
GET/POST/DELETE /consultant/pool… manage developer pool
|
||||
GET /consultant/metrics?customerId=&developerId=&from=&to=
|
||||
|
||||
Developer
|
||||
GET /board bounty board (published tasks of dev's consultants)
|
||||
filters: customer, search(q), minBounty, sort
|
||||
POST /tasks/{id}/claim { note? }
|
||||
POST /tasks/{id}/start | /submit-review
|
||||
POST /tasks/{id}/comments { body }
|
||||
POST /tasks/{id}/time { minutes, note }
|
||||
GET /developer/metrics?from=&to=
|
||||
|
||||
Shared
|
||||
GET /tasks/{id} detail incl. timeline (role-scoped)
|
||||
GET/POST /conversations… GET/POST /conversations/{id}/messages…
|
||||
POST /files (multipart) GET /files/{id}
|
||||
GET/PATCH /profile GET /users/{id}/card public profile card
|
||||
GET /notifications POST /notifications/read
|
||||
WS /ws multiplexed live channel
|
||||
|
||||
Health & ops
|
||||
GET /healthz liveness: always 200 if process up
|
||||
GET /readyz readiness: Mongo ping + (non-fatal, reported) atomizer
|
||||
and work-performer /healthz status
|
||||
GET /metricsz JSON counters (requests, sync runs, queue depths)
|
||||
```
|
||||
|
||||
### 6.1 Bounty calculation
|
||||
|
||||
`bounty = round(effortCoefficient × budget, 2)`; recomputed and persisted whenever either
|
||||
input changes and task is not yet `approved`. Once `approved`, the awarded amount in
|
||||
`bountyAwards` is frozen forever.
|
||||
|
||||
### 6.2 Metrics definitions
|
||||
|
||||
- Developer: total bounty earned, tasks completed, approval rate
|
||||
(approved / (approved + changes_requested submissions)), average time
|
||||
assigned→approved, time logged, earnings-over-time series (weekly buckets), per-customer
|
||||
breakdown.
|
||||
- Consultant: same aggregations grouped per developer and per customer + atomization
|
||||
throughput (imported→published lead time), open board depth.
|
||||
- All computed via Mongo aggregation pipelines over `bountyAwards` + `tasks.timeline`;
|
||||
rendered as SVG charts generated by ~150 lines of vanilla JS (no chart library).
|
||||
|
||||
---
|
||||
|
||||
## 7. Authentication
|
||||
|
||||
1. **Local**: email + password (argon2id, parameters: t=3, m=64MiB, p=2). Rate limit:
|
||||
10 attempts / 15 min / IP+email (in-memory token bucket). Optional email verification
|
||||
when SMTP is configured (QoL, off by default).
|
||||
2. **OIDC SSO**: standard code flow with PKCE against `OIDC_ISSUER_URL` /
|
||||
`OIDC_CLIENT_ID` / `OIDC_CLIENT_SECRET`. On first OIDC login: if a local user with the
|
||||
same verified email exists, link; otherwise create a developer account. Buttons appear
|
||||
only when OIDC env vars are set.
|
||||
3. Sessions: 32-byte random opaque token, httpOnly Secure SameSite=Lax cookie, 30-day
|
||||
sliding expiry, server-side revocation (logout-all in profile).
|
||||
|
||||
---
|
||||
|
||||
## 8. Configuration (`.env`, loaded by app and referenced in `docker-compose.yml`)
|
||||
|
||||
`.env.example` (committed; real `.env` gitignored):
|
||||
|
||||
```dotenv
|
||||
# --- core ---
|
||||
APP_BASE_URL=http://localhost:8787 # set to the public https URL when behind the proxy
|
||||
TRUSTED_PROXY_CIDRS= # e.g. 172.16.0.0/12,10.0.0.0/8 — empty = trust no proxy headers
|
||||
APP_PORT=8787
|
||||
MONGO_URI=mongodb://mongo:27017
|
||||
MONGO_DB=bountyboard
|
||||
SESSION_SECRET=change-me-32-bytes-random
|
||||
CREDENTIALS_ENC_KEY= # base64 32 bytes; `openssl rand -base64 32`
|
||||
MAX_UPLOAD_MB=25
|
||||
|
||||
# --- bootstrap admin ---
|
||||
ADMIN_EMAIL=admin@example.com
|
||||
ADMIN_INITIAL_PASSWORD=change-me-now
|
||||
|
||||
# --- external services (REQUIRED to be configurable here) ---
|
||||
# two independent services — separate URLs, ports, and tokens
|
||||
ATOMIZER_BASE_URL=http://atomizer-mock:8090
|
||||
ATOMIZER_TOKEN=change-me-atomizer
|
||||
ATOMIZER_TIMEOUT_SEC=120
|
||||
WORK_PERFORMER_BASE_URL=http://work-performer:8091
|
||||
WORK_PERFORMER_TOKEN=change-me-performer
|
||||
WORK_PERFORMER_HTTP_TIMEOUT_SEC=30 # job submission/polling only; jobs are async
|
||||
|
||||
# --- SMTP (optional; enables forgot-password + email notifications when set) ---
|
||||
SMTP_HOST=
|
||||
SMTP_PORT=587
|
||||
SMTP_USER=
|
||||
SMTP_PASSWORD=
|
||||
SMTP_FROM=bountyboard@example.com
|
||||
|
||||
# --- hardening / tuning ---
|
||||
COOKIE_SECURE=auto # auto: Secure flag only when APP_BASE_URL is https
|
||||
ATOMIZE_MAX_CONCURRENCY=3 # cap parallel LLM calls (cost + rate-limit control)
|
||||
MONGO_USERNAME= # optional; enable Mongo auth for non-local deploys
|
||||
MONGO_PASSWORD=
|
||||
|
||||
# --- OIDC (optional; SSO hidden if empty) ---
|
||||
OIDC_ISSUER_URL=
|
||||
OIDC_CLIENT_ID=
|
||||
OIDC_CLIENT_SECRET=
|
||||
|
||||
# --- mock atomizer / work performer ---
|
||||
ANTHROPIC_API_KEY= # paste your key here; NEVER commit
|
||||
ANTHROPIC_OPENAI_BASE_URL=https://api.anthropic.com/v1
|
||||
ANTHROPIC_MODEL=claude-sonnet-4-6
|
||||
LLM_API_STYLE=openai # openai | anthropic (native /v1/messages fallback)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 9. Placeholder service containers
|
||||
|
||||
### 9.1 `atomizer-mock` (Go, same repo, `services/atomizer`)
|
||||
|
||||
Implements §5.1 exactly. Internally calls Anthropic through its **OpenAI-compatible**
|
||||
chat-completions endpoint (`POST {ANTHROPIC_OPENAI_BASE_URL}/chat/completions`, header
|
||||
`Authorization: Bearer $ANTHROPIC_API_KEY`, model `$ANTHROPIC_MODEL`). Because the
|
||||
compatibility layer covers core chat completions only, the service also implements the
|
||||
native Anthropic Messages API (`/v1/messages`, headers `x-api-key`,
|
||||
`anthropic-version: 2023-06-01`) selected by `LLM_API_STYLE=anthropic` — flip this if the
|
||||
compatibility endpoint misbehaves. Plain `net/http`; no SDK.
|
||||
|
||||
Prompting: system prompt instructs strict-JSON output matching the response schema;
|
||||
response is parsed defensively (strip code fences), validated (coefficient sum
|
||||
normalization), retried once on parse failure, and falls back to a deterministic
|
||||
"split into N equal parts" stub when no API key is configured (so the full system remains
|
||||
testable offline).
|
||||
|
||||
### 9.2 `work-performer` (Node 20 + Claude Code, `services/work-performer`)
|
||||
|
||||
Implements §5.2. `Dockerfile`: `FROM node:20-bookworm`, `npm install -g
|
||||
@anthropic-ai/claude-code`, plus a ~200-line Express-free Node `http` server. The compose
|
||||
file mounts the host's local Claude Code configuration/secrets read-only:
|
||||
|
||||
```yaml
|
||||
volumes:
|
||||
- ${HOME}/.claude:/root/.claude
|
||||
- ${HOME}/.claude.json:/root/.claude.json
|
||||
```
|
||||
|
||||
For each job it prepares `/work/{jobId}/TASK.md` (title, description, AC, links,
|
||||
downloaded attachments) and runs `claude -p "$(cat TASK.md)" --output-format json
|
||||
--dangerously-skip-permissions` inside that directory, then posts the HMAC-signed
|
||||
callback with the summary and any produced files as artifacts. This is explicitly a
|
||||
placeholder: single-job concurrency, no repo cloning unless `context.repositoryUrl` is
|
||||
given (then `git clone` first). The interface, not the implementation, is the deliverable.
|
||||
|
||||
---
|
||||
|
||||
## 10. UI / UX
|
||||
|
||||
Pages: Login/Register · Admin → Settings, Customers (list + wizard with system-specific
|
||||
credential fields + Test connection), Users · Consultant → Atomization Board, Pool,
|
||||
Reviews queue, Metrics · Developer → Bounty Board, My tasks (kanban: Assigned / In
|
||||
progress / In review / Done), Metrics · Shared → Task detail, Messages, Profile,
|
||||
Notifications.
|
||||
|
||||
Atomization board: column or tree layout grouped by root ticket; each card shows source-type
|
||||
icon (epic ◆ / story ▣ / task ▢), title, coefficient slider (0–1, step 0.01, live
|
||||
per-parent sum indicator that turns red when ≠ 1.00 for non-extended sets), bounty
|
||||
preview, and the two primary buttons **Subdivide** (modal with optional note +
|
||||
min/max task count) and **Extend** (modal with required note). While atomizing, the card
|
||||
shows a progress shimmer; results stream in via WebSocket.
|
||||
|
||||
Bounty board: responsive card grid; bounty badge prominent; filters (customer, text
|
||||
search via Mongo text index, min bounty, newest/highest sort); "Request assignment"
|
||||
button with optional pitch note; "requested" state visible to other developers
|
||||
(configurable: admins may hide competing requests).
|
||||
|
||||
Design tokens (light, beige default):
|
||||
|
||||
```css
|
||||
:root[data-theme=light] {
|
||||
--bg:#f3ead9; --surface:#faf5ea; --surface2:#efe5d0; --border:#d8cbb0;
|
||||
--text:#2b2620; --muted:#6f6353; --accent:#8a5a2b; --accent-contrast:#fff;
|
||||
--ok:#3c6e47; --warn:#a06a1f; --err:#9c3a2e; --radius:2px;
|
||||
}
|
||||
:root[data-theme=dark] {
|
||||
--bg:#191714; --surface:#221f1b; --surface2:#2b2722; --border:#3a342c;
|
||||
--text:#ece5d8; --muted:#a59a87; --accent:#caa15e; --accent-contrast:#1a160f;
|
||||
--ok:#7fb78a; --warn:#d9a44a; --err:#d97b6c; --radius:2px;
|
||||
}
|
||||
```
|
||||
|
||||
System font stack; 8-px spacing grid; visible focus rings; keyboard shortcuts
|
||||
(`g b` board, `g m` messages, `/` search); WCAG AA contrast in both themes.
|
||||
|
||||
---
|
||||
|
||||
## 11. Quality-of-life features (in scope v1 unless marked later)
|
||||
|
||||
1. In-app notification center + WS toasts (claim requests, approvals, review results,
|
||||
sync errors, mentions in chat).
|
||||
2. Audit log with admin UI (filter by user/entity).
|
||||
3. Task comments + @mentions (mention → notification).
|
||||
4. Time logging per task; surfaced in metrics.
|
||||
5. Optimistic-concurrency conflict toasts ("reload — consultant edited this task").
|
||||
6. Coefficient rebalancer: "distribute remaining evenly" / lock-and-normalize controls.
|
||||
7. Re-run subdivision (replaces previous unpublished children after confirm dialog).
|
||||
8. Saved board filters per user; leaderboard (top developers by bounty, opt-out flag).
|
||||
9. Bulk publish; bulk archive.
|
||||
10. Search everywhere (tasks text index; users by name/email).
|
||||
11. Seed script `make seed` — demo customer (mock ticketing type `demo` that fabricates
|
||||
tickets locally, so the whole flow is demonstrable without real Jira), 1 admin,
|
||||
2 consultants, 6 developers, sample conversations.
|
||||
12. Export metrics as CSV.
|
||||
13. Profile cards on hover (avatar, roles, bio, contact).
|
||||
14. Unread badges in messaging; typing indicator; image lightbox.
|
||||
15. Per-user theme persistence (profile + localStorage).
|
||||
16. Graceful degradation: if the Atomization Service is down, Subdivide/Extend buttons
|
||||
are disabled with the last health-check status; a simple circuit breaker (open after
|
||||
3 consecutive failures, half-open probe every 60 s) stops hammering either external
|
||||
service. Independent breaker per service.
|
||||
17. Admin **service status panel**: live health, latency, breaker state, and queue depth
|
||||
for Atomization Service, Work Performer Service, and each customer sync worker.
|
||||
18. **Review checklist**: in the review dialog each acceptance criterion renders as a
|
||||
checkbox; the consultant's per-AC verdict is stored on the timeline — this is the
|
||||
quality-control audit trail.
|
||||
19. **Stale-task aging**: bounty-board cards show subtle age badges (>7 d, >14 d
|
||||
published without assignment) so the consultant spots unattractive tasks and can
|
||||
raise the budget or re-atomize.
|
||||
20. **Per-customer default budget**: set on the customer; pre-fills the root-task budget
|
||||
so consultants don't retype it.
|
||||
21. Compose **profile `mocks`** for the two placeholder services: `docker compose
|
||||
--profile mocks up` runs everything; omitting the profile runs only app+mongo against
|
||||
real external services configured in `.env`.
|
||||
22. Self-service "Forgot password" (token email; active only when SMTP configured).
|
||||
23. `make backup` / `make restore` — `mongodump`/`mongorestore` into ./backups via the
|
||||
mongo container; README documents a cron example.
|
||||
24. Later (documented stubs only): webhook ingestion instead of polling; status
|
||||
write-back to customer systems; email digests; AI work performer production
|
||||
implementation; localization.
|
||||
|
||||
---
|
||||
|
||||
## 12. Reliability, security, operations
|
||||
|
||||
- Health checks: `/healthz`, `/readyz` (§6) + Docker Compose `healthcheck` blocks for all
|
||||
four services; `app` `depends_on` mongo `condition: service_healthy`.
|
||||
- Graceful shutdown (SIGTERM): stop accepting, drain WS, flush sync workers, 15 s budget.
|
||||
- Background jobs (sync, atomize calls, callbacks) run through a small internal worker
|
||||
pool with per-job panic recovery and exponential-backoff retry (max 3) persisted in a
|
||||
`jobs` collection so restarts don't lose work.
|
||||
- All external HTTP calls: contexts with timeouts, retry on 5xx/network ×2 with jitter.
|
||||
- Security: argon2id; AES-256-GCM for stored credentials; CSRF double-submit; strict
|
||||
cookie flags; HTML sanitizer for all rich text; upload MIME sniffing + size limits;
|
||||
per-IP rate limits on auth and file upload; security headers (CSP without
|
||||
unsafe-inline for scripts, X-Frame-Options DENY); RBAC middleware asserting both role
|
||||
and resource ownership (consultant ↔ customer ↔ task chains).
|
||||
- Deployment posture: the app is designed to run **behind a TLS-terminating reverse
|
||||
proxy** (Caddy/nginx). Reverse-proxy readiness checklist, all implemented in v1:
|
||||
- app listens on `APP_PORT` (default **8787**), bound to localhost in compose; Mongo
|
||||
port is never published to the host;
|
||||
- `X-Forwarded-For` / `X-Forwarded-Proto` / `X-Forwarded-Host` are honored **only**
|
||||
when the direct peer is within `TRUSTED_PROXY_CIDRS` (otherwise ignored) — the
|
||||
resolved client IP feeds rate limiting, sessions, and the audit log;
|
||||
- `APP_BASE_URL` set to the public https URL drives all absolute URLs (OIDC redirect
|
||||
URI, signed file URLs, email links) and, with `COOKIE_SECURE=auto`, enables Secure
|
||||
cookies behind https even though app↔proxy traffic is plain http;
|
||||
- WebSocket endpoint works through proxies (Upgrade/Connection pass-through; 30 s
|
||||
server ping/pong heartbeats so idle proxy timeouts don't kill connections); Origin
|
||||
is checked against `APP_BASE_URL`;
|
||||
- request body limits enforced in-app (don't rely on proxy limits); no HTTP redirects
|
||||
to absolute http:// URLs anywhere;
|
||||
- README must include working sample configs for **Caddy** and **nginx** (incl. WS
|
||||
location block and `client_max_body_size` matching `MAX_UPLOAD_MB`).
|
||||
- Backups: named volume + `make backup`/`make restore` (mongodump); restore drill is part
|
||||
of the acceptance checklist for production use (not required for this test deploy).
|
||||
- Logging: slog JSON to stdout; request ID middleware; sync/atomizer failures logged at
|
||||
ERROR and surfaced as admin notifications.
|
||||
|
||||
## 13. Testing & acceptance
|
||||
|
||||
- Unit tests: status machine, bounty math, coefficient normalization, sanitizer,
|
||||
crypto round-trip, ULID, RBAC matrix.
|
||||
- Integration tests (`go test -tags=integration`, runs against compose Mongo with a
|
||||
per-run database name): auth flows, full task lifecycle imported→approved with the
|
||||
fallback (stubbed) atomizer, messaging, sync upsert idempotency, optimistic locking.
|
||||
- Contract tests for §5.1/§5.2 run against the mock services.
|
||||
- A Playwright-free smoke script (`scripts/smoke.sh`, curl-based) exercises
|
||||
register→login→healthz→board after `docker compose up`.
|
||||
- Target: `go vet`, `gofmt -l` clean; CI-able via `make test`.
|
||||
|
||||
Acceptance checklist: admin can add a Jira customer and test the connection · ticket
|
||||
assigned to consultant appears on atomization board within one poll interval · subdivide
|
||||
returns N tasks whose coefficients sum to 1 and are editable · publish puts tasks on the
|
||||
bounty board with bounty = coefficient × budget · developer claim → consultant approve →
|
||||
submit → review approve → bountyAwards row + metrics update · extend creates one sibling ·
|
||||
AI assignment creates a work-performer job and the callback moves the task to in_review ·
|
||||
consultant can decline a claim and unassign an assigned task back to the board ·
|
||||
signed attachment URL fetch works without a session (and expires) · OIDC and local login both work · light/dark theme toggle persists · `/readyz` reflects a
|
||||
stopped Mongo · all compose health checks green · app reachable through a sample reverse-proxy config
|
||||
on 8787 with correct client IPs in the audit log · stopping the atomizer container opens
|
||||
its breaker and disables Subdivide/Extend without affecting Work Performer assignments
|
||||
(and vice versa — proving full service independence).
|
||||
|
||||
## 14. Repository layout
|
||||
|
||||
```
|
||||
bountyboard/
|
||||
├─ cmd/app/main.go
|
||||
├─ internal/{config,store,domain,http,auth,sync,atomize,workperform,chat,ws,metrics,files,jobs,crypto,ulid}
|
||||
├─ web/{templates,static/{css,js,icons}}
|
||||
├─ services/atomizer/ services/work-performer/
|
||||
├─ api/openapi.yaml
|
||||
├─ scripts/{seed.go,smoke.sh}
|
||||
├─ docker-compose.yml Dockerfile Makefile .env.example specification.md README.md
|
||||
```
|
||||
Reference in New Issue
Block a user