feat: atomization pipeline with job queue, atomizer client, WS hub, and board UI (phase 7)

- internal/extsvc: circuit breaker (§11.16) + retrying bearer JSON client
- atomizer client: §5.1 contract incl. defensive coefficient normalization
  and extension coefficient range (0,2]
- persisted jobs collection: atomic claim, panic recovery, exponential
  backoff (max 3), stale-running requeue, shutdown-safe bookkeeping
- subdivide (async 202, atomizing status, re-run replaces unpublished
  children after confirm) and extend (sibling task, source budget)
- failure path reverts status and notifies the consultant on final attempt
- PATCH task editing with bounty recompute, budget cascade to descendants
- publish single + bulk; task detail endpoint role-scoped
- coder/websocket hub: multiplexed channels, origin check, 30s heartbeats;
  client ws.js with reconnect + 15s polling fallback
- consultant atomization board: tree by root, coefficient sliders with live
  per-parent sum indicator, bounty preview, modals, shimmer, atomizer-down
  gating via /api/v1/service-health
- fix: tasks external-ref unique index is partial, not sparse (compound
  sparse indexed every task through customerId and broke child inserts)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
etalon
2026-06-12 19:17:09 +02:00
parent 4e01b64bbd
commit f87b954f27
23 changed files with 2628 additions and 7 deletions
+1
View File
@@ -7,3 +7,4 @@
- Phase 4 (base UI shell): embedded templates + static assets (no build step), §10 beige/dark tokens with radius 2px, theme toggle persisted to localStorage + profile, login/register/change-password pages, profile page (avatar upload w/ MIME check, bio, contacts, arbitrary extra fields, version-conflict 409), role-based navigation, /files/{id} serving (session or signed token), CSP without inline scripts + security headers — unit + integration tests green, stack verified live. - Phase 4 (base UI shell): embedded templates + static assets (no build step), §10 beige/dark tokens with radius 2px, theme toggle persisted to localStorage + profile, login/register/change-password pages, profile page (avatar upload w/ MIME check, bio, contacts, arbitrary extra fields, version-conflict 409), role-based navigation, /files/{id} serving (session or signed token), CSP without inline scripts + security headers — unit + integration tests green, stack verified live.
- Phase 5 (admin): customer CRUD wizard with per-system encrypted credentials + test connection (jira/ado/youtrack/demo connectors), user management (roles, disable w/ session revoke, force-reset, delete, self-lockout guards), runtime settings doc, audit log on every privileged mutation + list API, service-status panel (mongo/atomizer/WP health + latency, late-bound breaker/sync/jobs providers), tabbed admin UI - tests green. Fixed mongod WT_PANIC: container nofile ulimit was 1024, raised to 64000 in compose. - Phase 5 (admin): customer CRUD wizard with per-system encrypted credentials + test connection (jira/ado/youtrack/demo connectors), user management (roles, disable w/ session revoke, force-reset, delete, self-lockout guards), runtime settings doc, audit log on every privileged mutation + list API, service-status panel (mongo/atomizer/WP health + latency, late-bound breaker/sync/jobs providers), tabbed admin UI - tests green. Fixed mongod WT_PANIC: container nofile ulimit was 1024, raised to 64000 in compose.
- Phase 6 (sync workers): per-customer pollers with reconcile loop (start/stop/interval changes), §5.3 idempotent upsert keyed (system,key,customerId) w/ content-hash change detection, upstream edits refresh only while imported (timeline+notification after), attachment caching to GridFS, orphan flagging (never deletes), admin sync-now trigger + worker statuses, default budget prefill — demo-type integration tests green. - Phase 6 (sync workers): per-customer pollers with reconcile loop (start/stop/interval changes), §5.3 idempotent upsert keyed (system,key,customerId) w/ content-hash change detection, upstream edits refresh only while imported (timeline+notification after), attachment caching to GridFS, orphan flagging (never deletes), admin sync-now trigger + worker statuses, default budget prefill — demo-type integration tests green.
- Phase 7 (atomization): circuit breaker (3 fails→open, 60s half-open probe) + retrying bearer JSON client shared by both external services, atomizer client w/ §5.1 coefficient normalization (±0.001 ok, ≤0.05 renormalized, else 502-class), persisted jobs queue (panic-safe, backoff ×3, stale requeue, restart-safe), subdivide/extend endpoints (202 async, re-run replaces unpublished children after confirm), task editing w/ bounty recompute + optional budget cascade, publish single/bulk, WS hub (origin check, 30s heartbeats) + live board, consultant atomization board UI (tree, sliders, sum indicator, modals, shimmer, health gating) — unit + integration green. Fixed tasks unique index: sparse→partial (sparse compound matched every task via customerId).
+39
View File
@@ -6,20 +6,25 @@ import (
"encoding/json" "encoding/json"
"fmt" "fmt"
"log/slog" "log/slog"
"net/http"
"os" "os"
"os/signal" "os/signal"
"strings"
"syscall" "syscall"
"time" "time"
"bountyboard/internal/atomize"
"bountyboard/internal/auth" "bountyboard/internal/auth"
"bountyboard/internal/config" "bountyboard/internal/config"
"bountyboard/internal/crypto" "bountyboard/internal/crypto"
"bountyboard/internal/domain" "bountyboard/internal/domain"
"bountyboard/internal/files" "bountyboard/internal/files"
httpx "bountyboard/internal/http" httpx "bountyboard/internal/http"
"bountyboard/internal/jobs"
"bountyboard/internal/metrics" "bountyboard/internal/metrics"
"bountyboard/internal/store" "bountyboard/internal/store"
syncpkg "bountyboard/internal/sync" syncpkg "bountyboard/internal/sync"
"bountyboard/internal/ws"
) )
func main() { func main() {
@@ -69,6 +74,40 @@ func run() error {
Probe: st.Ping, Probe: st.Ping,
}) })
// WebSocket hub: one multiplexed live channel per session (§2.2).
hub := ws.NewHub(log, func(r *http.Request) bool {
origin := r.Header.Get("Origin")
if origin == "" {
return true // non-browser client (no CSRF surface on a read feed)
}
return strings.HasPrefix(origin, cfg.AppBaseURL) ||
strings.HasPrefix(origin, "http://"+r.Host) || strings.HasPrefix(origin, "https://"+r.Host)
})
srv.SetWSHandler(hub.Handle)
srv.SetPublishFn(hub.Broadcast)
// Atomizer client honoring the admin base-URL override per call.
atomClient := atomize.New(func() string {
sctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
defer cancel()
if settings, err := st.GetSettings(sctx); err == nil && settings.AtomizerBaseURLOverride != "" {
return settings.AtomizerBaseURLOverride
}
return cfg.AtomizerBaseURL
}, cfg.AtomizerToken, cfg.AtomizerTimeout)
srv.SetAtomizerInfo(atomClient)
srv.AddReadinessCheck(httpx.ReadinessCheck{Name: "atomizer", Probe: atomClient.Healthy})
// Background job queue + atomization handlers.
runner := jobs.NewRunner(st, log, reg, 4)
atomSvc := atomize.NewService(st, atomClient, log, cfg.AppBaseURL,
[]byte(cfg.SessionSecret), srv.Publish)
runner.Register(atomize.JobKindSubdivide, atomSvc.HandleSubdivide)
runner.Register(atomize.JobKindExtend, atomSvc.HandleExtend)
srv.SetJobsStatusProvider(runner)
srv.SetEnqueue(runner.Enqueue)
go runner.Run(ctx)
syncMgr := syncpkg.NewManager(st, fileStore, log, reg, syncMgr := syncpkg.NewManager(st, fileStore, log, reg,
func(c *domain.Customer) (syncpkg.Credentials, error) { func(c *domain.Customer) (syncpkg.Credentials, error) {
if c.Ticketing.CredentialsEnc == "" { if c.Ticketing.CredentialsEnc == "" {
+1
View File
@@ -3,6 +3,7 @@ module bountyboard
go 1.26 go 1.26
require ( require (
github.com/coder/websocket v1.8.14
github.com/coreos/go-oidc/v3 v3.18.0 github.com/coreos/go-oidc/v3 v3.18.0
go.mongodb.org/mongo-driver/v2 v2.6.0 go.mongodb.org/mongo-driver/v2 v2.6.0
golang.org/x/crypto v0.53.0 golang.org/x/crypto v0.53.0
+2
View File
@@ -1,3 +1,5 @@
github.com/coder/websocket v1.8.14 h1:9L0p0iKiNOibykf283eHkKUHHrpG7f65OE3BhhO7v9g=
github.com/coder/websocket v1.8.14/go.mod h1:NX3SzP+inril6yawo5CQXx8+fk145lPDC6pumgx0mVg=
github.com/coreos/go-oidc/v3 v3.18.0 h1:V9orjXynvu5wiC9SemFTWnG4F45v403aIcjWo0d41+A= github.com/coreos/go-oidc/v3 v3.18.0 h1:V9orjXynvu5wiC9SemFTWnG4F45v403aIcjWo0d41+A=
github.com/coreos/go-oidc/v3 v3.18.0/go.mod h1:DYCf24+ncYi+XkIH97GY1+dqoRlbaSI26KVTCI9SrY4= github.com/coreos/go-oidc/v3 v3.18.0/go.mod h1:DYCf24+ncYi+XkIH97GY1+dqoRlbaSI26KVTCI9SrY4=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
+153
View File
@@ -0,0 +1,153 @@
// Package atomize is the app-side client of the Atomization Service (§5.1)
// plus the coefficient validation/normalization rules.
package atomize
import (
"context"
"errors"
"fmt"
"math"
"net/http"
"time"
"bountyboard/internal/extsvc"
)
type Attachment struct {
Name string `json:"name"`
URL string `json:"url"`
MimeType string `json:"mimeType"`
}
type Constraints struct {
MinTasks int `json:"minTasks,omitempty"`
MaxTasks int `json:"maxTasks,omitempty"`
}
type AtomizeRequest struct {
TaskID string `json:"taskId"`
Title string `json:"title"`
Description string `json:"description"`
AcceptanceCriteria []string `json:"acceptanceCriteria"`
Attachments []Attachment `json:"attachments"`
Links []string `json:"links"`
SubdivisionNote string `json:"subdivisionNote,omitempty"`
Constraints *Constraints `json:"constraints,omitempty"`
}
type ExtendRequest struct {
TaskID string `json:"taskId"`
Title string `json:"title"`
Description string `json:"description"`
AcceptanceCriteria []string `json:"acceptanceCriteria"`
Attachments []Attachment `json:"attachments"`
Links []string `json:"links"`
ExtensionNote string `json:"extensionNote"`
}
type SubTask struct {
Title string `json:"title"`
Description string `json:"description"`
AcceptanceCriteria []string `json:"acceptanceCriteria"`
EffortCoefficient float64 `json:"effortCoefficient"`
}
type AtomizeResponse struct {
Tasks []SubTask `json:"tasks"`
Model string `json:"model,omitempty"`
Notes string `json:"notes,omitempty"`
}
type ExtendResponse struct {
Task SubTask `json:"task"`
Model string `json:"model,omitempty"`
Notes string `json:"notes,omitempty"`
}
// ErrBadCoefficients is the 502-class rejection when the service returns
// coefficients we are not willing to repair (§5.1).
var ErrBadCoefficients = errors.New("atomizer returned invalid effort coefficients")
type Client struct {
c *extsvc.Client
}
// New builds the client. baseURL is resolved per call so the admin settings
// override applies without restart.
func New(baseURL func() string, token string, timeout time.Duration) *Client {
return &Client{c: extsvc.NewClient("atomizer", baseURL, token, timeout)}
}
func (a *Client) BreakerState() string { return a.c.BreakerState() }
func (a *Client) Healthy(ctx context.Context) error { return a.c.Healthy(ctx) }
// Atomize calls POST /v1/atomize and applies the §5.1 contract: coefficients
// must sum to 1 ± 0.001; a deviation ≤ 0.05 is defensively renormalized,
// anything worse is rejected as a service error.
func (a *Client) Atomize(ctx context.Context, req AtomizeRequest) (*AtomizeResponse, error) {
var resp AtomizeResponse
if err := a.c.Call(ctx, http.MethodPost, "/v1/atomize", req, &resp); err != nil {
return nil, err
}
if len(resp.Tasks) == 0 {
return nil, fmt.Errorf("%w: empty task list", ErrBadCoefficients)
}
coeffs := make([]float64, len(resp.Tasks))
for i, t := range resp.Tasks {
coeffs[i] = t.EffortCoefficient
}
normalized, err := NormalizeCoefficients(coeffs)
if err != nil {
return nil, err
}
for i := range resp.Tasks {
resp.Tasks[i].EffortCoefficient = normalized[i]
}
return &resp, nil
}
// Extend calls POST /v1/extend. The sibling coefficient is relative to the
// source task and may exceed 1 up to 2.0 (§5.1).
func (a *Client) Extend(ctx context.Context, req ExtendRequest) (*ExtendResponse, error) {
var resp ExtendResponse
if err := a.c.Call(ctx, http.MethodPost, "/v1/extend", req, &resp); err != nil {
return nil, err
}
c := resp.Task.EffortCoefficient
if c <= 0 || c > 2.0 || math.IsNaN(c) {
return nil, fmt.Errorf("%w: extension coefficient %v outside (0, 2]", ErrBadCoefficients, c)
}
return &resp, nil
}
// NormalizeCoefficients enforces the §5.1 sum contract. Each coefficient
// must be in (0, 1]; the sum must be 1 ± 0.001 (accepted as-is) or within
// ± 0.05 (renormalized proportionally, then rounded to 4 decimals with the
// remainder folded into the largest entry so the sum is exactly 1).
func NormalizeCoefficients(coeffs []float64) ([]float64, error) {
if len(coeffs) == 0 {
return nil, fmt.Errorf("%w: empty", ErrBadCoefficients)
}
sum := 0.0
for _, c := range coeffs {
if math.IsNaN(c) || c <= 0 || c > 1 {
return nil, fmt.Errorf("%w: coefficient %v outside (0, 1]", ErrBadCoefficients, c)
}
sum += c
}
if math.Abs(sum-1) > 0.05 {
return nil, fmt.Errorf("%w: sum %.4f deviates more than 0.05 from 1", ErrBadCoefficients, sum)
}
out := make([]float64, len(coeffs))
largest := 0
total := 0.0
for i, c := range coeffs {
out[i] = math.Round(c/sum*10000) / 10000
total += out[i]
if out[i] > out[largest] {
largest = i
}
}
out[largest] = math.Round((out[largest]+1-total)*10000) / 10000
return out, nil
}
+143
View File
@@ -0,0 +1,143 @@
package atomize
import (
"context"
"encoding/json"
"errors"
"math"
"net/http"
"net/http/httptest"
"testing"
"time"
"bountyboard/internal/extsvc"
)
func TestNormalizeCoefficients(t *testing.T) {
tests := []struct {
name string
in []float64
wantErr bool
wantSum float64
}{
{"exact sum kept", []float64{0.2, 0.5, 0.3}, false, 1},
{"tiny deviation accepted", []float64{0.2, 0.5, 0.3001}, false, 1},
{"small deviation renormalized", []float64{0.21, 0.52, 0.31}, false, 1}, // sum 1.04
{"under-sum renormalized", []float64{0.3, 0.3, 0.36}, false, 1}, // sum 0.96
{"large deviation rejected", []float64{0.5, 0.5, 0.5}, true, 0},
{"zero coefficient rejected", []float64{0, 0.5, 0.5}, true, 0},
{"negative rejected", []float64{-0.1, 0.6, 0.5}, true, 0},
{"above one rejected", []float64{1.2}, true, 0},
{"empty rejected", nil, true, 0},
{"single exact", []float64{1.0}, false, 1},
{"NaN rejected", []float64{math.NaN(), 0.5}, true, 0},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
out, err := NormalizeCoefficients(tt.in)
if tt.wantErr {
if !errors.Is(err, ErrBadCoefficients) {
t.Fatalf("want ErrBadCoefficients, got %v", err)
}
return
}
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
sum := 0.0
for _, c := range out {
if c <= 0 || c > 1 {
t.Errorf("normalized coefficient %v out of range", c)
}
sum += c
}
if math.Abs(sum-tt.wantSum) > 1e-9 {
t.Errorf("sum = %.6f, want exactly %v (coeffs %v)", sum, tt.wantSum, out)
}
})
}
}
func fakeAtomizer(t *testing.T, handler http.HandlerFunc) *Client {
t.Helper()
srv := httptest.NewServer(handler)
t.Cleanup(srv.Close)
return New(func() string { return srv.URL }, "test-token", 5*time.Second)
}
func TestAtomizeAppliesNormalization(t *testing.T) {
client := fakeAtomizer(t, func(w http.ResponseWriter, r *http.Request) {
if r.Header.Get("Authorization") != "Bearer test-token" {
t.Errorf("missing bearer token")
}
var req AtomizeRequest
json.NewDecoder(r.Body).Decode(&req)
if req.TaskID != "01JTASK" {
t.Errorf("taskId = %q", req.TaskID)
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(AtomizeResponse{Tasks: []SubTask{
{Title: "A", EffortCoefficient: 0.31}, // sum 1.03 → renormalize
{Title: "B", EffortCoefficient: 0.41},
{Title: "C", EffortCoefficient: 0.31},
}})
})
resp, err := client.Atomize(context.Background(), AtomizeRequest{TaskID: "01JTASK"})
if err != nil {
t.Fatal(err)
}
sum := 0.0
for _, st := range resp.Tasks {
sum += st.EffortCoefficient
}
if math.Abs(sum-1) > 1e-9 {
t.Fatalf("normalized sum = %v", sum)
}
}
func TestAtomizeRejectsBadSum(t *testing.T) {
client := fakeAtomizer(t, func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(AtomizeResponse{Tasks: []SubTask{
{Title: "A", EffortCoefficient: 0.9},
{Title: "B", EffortCoefficient: 0.9},
}})
})
_, err := client.Atomize(context.Background(), AtomizeRequest{})
if !errors.Is(err, ErrBadCoefficients) {
t.Fatalf("want ErrBadCoefficients, got %v", err)
}
}
func TestExtendValidatesCoefficient(t *testing.T) {
coeff := 2.5
client := fakeAtomizer(t, func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(ExtendResponse{Task: SubTask{Title: "X", EffortCoefficient: coeff}})
})
if _, err := client.Extend(context.Background(), ExtendRequest{}); !errors.Is(err, ErrBadCoefficients) {
t.Fatalf("coefficient 2.5: want ErrBadCoefficients, got %v", err)
}
coeff = 1.4 // legal for extensions
if _, err := client.Extend(context.Background(), ExtendRequest{}); err != nil {
t.Fatalf("coefficient 1.4 should pass: %v", err)
}
}
func TestBreakerOpensAfterRepeatedFailures(t *testing.T) {
client := fakeAtomizer(t, func(w http.ResponseWriter, r *http.Request) {
http.Error(w, "boom", http.StatusInternalServerError)
})
for i := 0; i < 3; i++ {
if _, err := client.Atomize(context.Background(), AtomizeRequest{}); err == nil {
t.Fatal("expected failure")
}
}
if client.BreakerState() != "open" {
t.Fatalf("breaker = %s, want open", client.BreakerState())
}
_, err := client.Atomize(context.Background(), AtomizeRequest{})
if !errors.Is(err, extsvc.ErrBreakerOpen) {
t.Fatalf("want ErrBreakerOpen, got %v", err)
}
}
+263
View File
@@ -0,0 +1,263 @@
package atomize
import (
"context"
"fmt"
"log/slog"
"time"
"bountyboard/internal/domain"
"bountyboard/internal/files"
"bountyboard/internal/jobs"
"bountyboard/internal/store"
)
// JobKindSubdivide / JobKindExtend are the queue kinds this service handles.
const (
JobKindSubdivide = "atomize.subdivide"
JobKindExtend = "atomize.extend"
)
type SubdividePayload struct {
TaskID string `bson:"taskId"`
Note string `bson:"note"`
MinTasks int `bson:"minTasks"`
MaxTasks int `bson:"maxTasks"`
ActorID string `bson:"actorId"`
FromStatus string `bson:"fromStatus"` // revert target on terminal failure
}
type ExtendPayload struct {
TaskID string `bson:"taskId"`
Note string `bson:"note"`
ActorID string `bson:"actorId"`
}
// Service executes atomization jobs: it owns the §5.1 request building,
// child/sibling task creation, status transitions, notifications, and live
// board updates.
type Service struct {
st *store.Store
client *Client
log *slog.Logger
appBaseURL string
secret []byte
publish func(channel, event string, payload any)
}
func NewService(st *store.Store, client *Client, log *slog.Logger,
appBaseURL string, secret []byte, publish func(string, string, any)) *Service {
if publish == nil {
publish = func(string, string, any) {}
}
return &Service{st: st, client: client, log: log,
appBaseURL: appBaseURL, secret: secret, publish: publish}
}
// requestAttachments builds §5.1 attachment refs: cached files get signed
// app URLs (fetchable without a session); uncached ones keep the upstream
// URL.
func (s *Service) requestAttachments(t *domain.Task) []Attachment {
out := make([]Attachment, 0, len(t.Attachments))
for _, a := range t.Attachments {
url := a.URL
if a.FileID != "" {
url = files.SignedURL(s.appBaseURL, s.secret, a.FileID, time.Now().Add(files.DefaultTokenTTL))
}
out = append(out, Attachment{Name: a.Name, URL: url, MimeType: a.MimeType})
}
return out
}
func taskLinks(t *domain.Task) []string {
links := append([]string{}, t.Links...)
if t.External != nil && t.External.URL != "" {
links = append(links, t.External.URL)
}
return links
}
func (s *Service) HandleSubdivide(ctx context.Context, job *jobs.Job) error {
var p SubdividePayload
if err := jobs.DecodePayload(job, &p); err != nil {
return err
}
t, err := s.st.TaskByID(ctx, p.TaskID)
if err != nil {
return fmt.Errorf("load task: %w", err)
}
if t.Status != domain.StatusAtomizing {
s.log.Warn("subdivide job for non-atomizing task, skipping", "task", t.ID, "status", t.Status)
return nil
}
req := AtomizeRequest{
TaskID: t.ID,
Title: t.Title,
Description: t.Description,
AcceptanceCriteria: t.AcceptanceCriteria,
Attachments: s.requestAttachments(t),
Links: taskLinks(t),
SubdivisionNote: p.Note,
}
if p.MinTasks > 0 || p.MaxTasks > 0 {
req.Constraints = &Constraints{MinTasks: p.MinTasks, MaxTasks: p.MaxTasks}
}
resp, err := s.client.Atomize(ctx, req)
if err != nil {
if jobs.IsFinalAttempt(job) {
s.revertSubdivide(ctx, t, &p, err)
}
return fmt.Errorf("atomize call: %w", err)
}
// Create children, all atomized (§4.4).
childIDs := make([]string, 0, len(resp.Tasks))
for _, sub := range resp.Tasks {
child := &domain.Task{
CustomerID: t.CustomerID,
ConsultantID: t.ConsultantID,
Origin: domain.OriginSubdivided,
ParentID: t.ID,
RootID: t.RootID,
Title: sub.Title,
Description: sub.Description,
AcceptanceCriteria: sub.AcceptanceCriteria,
EffortCoefficient: sub.EffortCoefficient,
Budget: t.Budget,
Bounty: domain.ComputeBounty(sub.EffortCoefficient, t.Budget),
Status: domain.StatusAtomized,
Timeline: []domain.TimelineEntry{{
At: time.Now().UTC(), ActorID: "system", Event: "created_by_subdivision",
Data: map[string]any{"parentId": t.ID, "model": resp.Model},
}},
}
if err := s.st.InsertTask(ctx, child); err != nil {
return fmt.Errorf("insert child: %w", err)
}
childIDs = append(childIDs, child.ID)
}
err = s.st.TransitionTask(ctx, t, domain.StatusAtomized, p.ActorID, "subdivided",
map[string]any{"children": len(childIDs), "model": resp.Model, "notes": resp.Notes},
map[string]any{"atomizationNote": p.Note})
if err != nil {
// children exist; a concurrent edit raced us — reload once and retry
if fresh, ferr := s.st.TaskByID(ctx, t.ID); ferr == nil && fresh.Status == domain.StatusAtomizing {
err = s.st.TransitionTask(ctx, fresh, domain.StatusAtomized, p.ActorID, "subdivided",
map[string]any{"children": len(childIDs)}, map[string]any{"atomizationNote": p.Note})
}
if err != nil {
return fmt.Errorf("finish subdivision: %w", err)
}
}
s.notify(ctx, t.ConsultantID, "subdivision_ready",
fmt.Sprintf("Subdivision ready: %d subtasks", len(childIDs)),
t.Title, "/consultant/board?customerId="+t.CustomerID)
s.publish("board", "task.subdivided", map[string]any{
"taskId": t.ID, "customerId": t.CustomerID, "childIds": childIDs,
})
return nil
}
func (s *Service) revertSubdivide(ctx context.Context, t *domain.Task, p *SubdividePayload, cause error) {
target := domain.TaskStatus(p.FromStatus)
if !target.Valid() {
target = domain.StatusImported
}
fresh, err := s.st.TaskByID(ctx, t.ID)
if err != nil || fresh.Status != domain.StatusAtomizing {
return
}
if err := s.st.TransitionTask(ctx, fresh, target, "system", "atomization_failed",
map[string]any{"error": cause.Error()}, nil); err != nil {
s.log.Error("revert failed subdivision", "task", t.ID, "err", err)
return
}
s.notify(ctx, t.ConsultantID, "atomization_failed",
"Subdivision failed: "+t.Title, cause.Error(),
"/consultant/board?customerId="+t.CustomerID)
s.publish("board", "task.atomization_failed", map[string]any{
"taskId": t.ID, "customerId": t.CustomerID,
})
}
func (s *Service) HandleExtend(ctx context.Context, job *jobs.Job) error {
var p ExtendPayload
if err := jobs.DecodePayload(job, &p); err != nil {
return err
}
t, err := s.st.TaskByID(ctx, p.TaskID)
if err != nil {
return fmt.Errorf("load task: %w", err)
}
if t.Status != domain.StatusImported && t.Status != domain.StatusAtomized {
s.log.Warn("extend job for wrong-status task, skipping", "task", t.ID, "status", t.Status)
return nil
}
resp, err := s.client.Extend(ctx, ExtendRequest{
TaskID: t.ID,
Title: t.Title,
Description: t.Description,
AcceptanceCriteria: t.AcceptanceCriteria,
Attachments: s.requestAttachments(t),
Links: taskLinks(t),
ExtensionNote: p.Note,
})
if err != nil {
if jobs.IsFinalAttempt(job) {
s.notify(ctx, t.ConsultantID, "atomization_failed",
"Extension failed: "+t.Title, err.Error(),
"/consultant/board?customerId="+t.CustomerID)
}
return fmt.Errorf("extend call: %w", err)
}
sibling := &domain.Task{
CustomerID: t.CustomerID,
ConsultantID: t.ConsultantID,
Origin: domain.OriginExtended,
ParentID: t.ID, // §4.4: parentId records the sibling source
RootID: t.RootID,
Title: resp.Task.Title,
Description: resp.Task.Description,
AcceptanceCriteria: resp.Task.AcceptanceCriteria,
EffortCoefficient: resp.Task.EffortCoefficient,
Budget: t.Budget, // §5.1: same budget as the source
Bounty: domain.ComputeBounty(resp.Task.EffortCoefficient, t.Budget),
Status: domain.StatusAtomized,
Timeline: []domain.TimelineEntry{{
At: time.Now().UTC(), ActorID: "system", Event: "created_by_extension",
Data: map[string]any{"sourceId": t.ID, "model": resp.Model, "note": p.Note},
}},
}
if err := s.st.InsertTask(ctx, sibling); err != nil {
return fmt.Errorf("insert sibling: %w", err)
}
if err := s.st.AppendTimeline(ctx, t.ID, domain.TimelineEntry{
ActorID: p.ActorID, Event: "extended",
Data: map[string]any{"siblingId": sibling.ID, "note": p.Note},
}); err != nil {
s.log.Warn("append extend timeline", "err", err)
}
s.notify(ctx, t.ConsultantID, "extension_ready",
"Extension ready: "+sibling.Title, t.Title,
"/consultant/board?customerId="+t.CustomerID)
s.publish("board", "task.extended", map[string]any{
"taskId": t.ID, "siblingId": sibling.ID, "customerId": t.CustomerID,
})
return nil
}
func (s *Service) notify(ctx context.Context, userID, kind, title, body, link string) {
n := &store.Notification{UserID: userID, Kind: kind, Title: title, Body: body, Link: link}
if err := s.st.InsertNotification(ctx, n); err != nil {
s.log.Warn("insert notification", "err", err)
return
}
s.publish("notifications", "notification", n)
}
@@ -0,0 +1,255 @@
//go:build integration
package atomize
import (
"context"
"encoding/json"
"io"
"log/slog"
"net/http"
"net/http/httptest"
"os"
"sync/atomic"
"testing"
"time"
"bountyboard/internal/domain"
"bountyboard/internal/jobs"
"bountyboard/internal/metrics"
"bountyboard/internal/store"
"bountyboard/internal/testutil"
"bountyboard/internal/ulid"
)
type stack struct {
st *store.Store
svc *Service
runner *jobs.Runner
task *domain.Task
}
func newStack(t *testing.T, atomizerHandler http.HandlerFunc) *stack {
t.Helper()
db := testutil.MongoDB(t)
if err := store.EnsureIndexes(t.Context(), db); err != nil {
t.Fatal(err)
}
st := &store.Store{Client: db.Client(), DB: db}
srv := httptest.NewServer(atomizerHandler)
t.Cleanup(srv.Close)
client := New(func() string { return srv.URL }, "tok", 10*time.Second)
logOut := io.Writer(io.Discard)
if os.Getenv("TEST_LOG") == "1" {
logOut = os.Stderr
}
log := slog.New(slog.NewTextHandler(logOut, nil))
svc := NewService(st, client, log, "http://localhost:8787",
[]byte("0123456789abcdef0123456789abcdef"), nil)
runner := jobs.NewRunner(st, log, metrics.NewRegistry(), 2)
runner.Register(JobKindSubdivide, svc.HandleSubdivide)
runner.Register(JobKindExtend, svc.HandleExtend)
task := &domain.Task{
CustomerID: ulid.New(), ConsultantID: ulid.New(),
Origin: domain.OriginImported, Status: domain.StatusImported,
Title: "Implement user import", Description: "Full description",
AcceptanceCriteria: []string{"works"},
Budget: 1000, EffortCoefficient: 1, Bounty: 1000,
External: &domain.External{System: "demo", Key: "DEMO-1", URL: "https://demo.invalid/1"},
}
if err := st.InsertTask(t.Context(), task); err != nil {
t.Fatal(err)
}
return &stack{st: st, svc: svc, runner: runner, task: task}
}
// runJob synchronously claims + executes pending jobs until idle.
func runJobs(t *testing.T, s *stack, deadline time.Duration) {
t.Helper()
ctx, cancel := context.WithTimeout(context.Background(), deadline)
defer cancel()
go s.runner.Run(ctx)
<-ctx.Done()
}
func goodAtomizer(calls *atomic.Int64) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
if calls != nil {
calls.Add(1)
}
w.Header().Set("Content-Type", "application/json")
switch r.URL.Path {
case "/v1/atomize":
json.NewEncoder(w).Encode(AtomizeResponse{
Model: "claude-sonnet-4-6",
Tasks: []SubTask{
{Title: "Backend", Description: "API part", AcceptanceCriteria: []string{"a"}, EffortCoefficient: 0.5},
{Title: "Frontend", Description: "UI part", AcceptanceCriteria: []string{"b"}, EffortCoefficient: 0.3},
{Title: "Migration", Description: "DB part", AcceptanceCriteria: []string{"c"}, EffortCoefficient: 0.2},
},
})
case "/v1/extend":
json.NewEncoder(w).Encode(ExtendResponse{
Task: SubTask{Title: "CSV mapping presets", Description: "extension",
AcceptanceCriteria: []string{"x"}, EffortCoefficient: 0.4},
})
default:
http.NotFound(w, r)
}
}
}
func TestSubdivideEndToEnd(t *testing.T) {
s := newStack(t, goodAtomizer(nil))
ctx := context.Background()
// consultant moved it to atomizing (handler does this before enqueue)
if err := s.st.TransitionTask(ctx, s.task, domain.StatusAtomizing, "cons", "subdivide_requested", nil, nil); err != nil {
t.Fatal(err)
}
if _, err := s.runner.Enqueue(ctx, JobKindSubdivide, SubdividePayload{
TaskID: s.task.ID, Note: "split it", ActorID: "cons", FromStatus: "imported",
}); err != nil {
t.Fatal(err)
}
runJobs(t, s, 4*time.Second)
parent, err := s.st.TaskByID(ctx, s.task.ID)
if err != nil {
t.Fatal(err)
}
if parent.Status != domain.StatusAtomized {
t.Fatalf("parent status = %s, want atomized", parent.Status)
}
if parent.AtomizationNote != "split it" {
t.Errorf("atomizationNote = %q", parent.AtomizationNote)
}
children, err := s.st.ListTasks(ctx, store.TaskFilter{ParentID: s.task.ID})
if err != nil {
t.Fatal(err)
}
if len(children) != 3 {
t.Fatalf("children = %d, want 3", len(children))
}
sum := 0.0
for _, c := range children {
if c.Status != domain.StatusAtomized || c.Origin != domain.OriginSubdivided {
t.Errorf("child %s: status=%s origin=%s", c.ID, c.Status, c.Origin)
}
if c.RootID != s.task.ID || c.Budget != 1000 {
t.Errorf("child %s: root=%s budget=%v", c.ID, c.RootID, c.Budget)
}
if c.Bounty != domain.ComputeBounty(c.EffortCoefficient, c.Budget) {
t.Errorf("child %s: bounty %v != coeff %v × budget %v", c.ID, c.Bounty, c.EffortCoefficient, c.Budget)
}
sum += c.EffortCoefficient
}
if sum < 0.999 || sum > 1.001 {
t.Errorf("children coefficients sum %v, want 1±0.001", sum)
}
// consultant got the "ready" notification
notifs, _ := s.st.ListNotifications(ctx, s.task.ConsultantID, false, "", 10)
found := false
for _, n := range notifs {
if n.Kind == "subdivision_ready" {
found = true
}
}
if !found {
t.Error("missing subdivision_ready notification")
}
}
func TestExtendEndToEnd(t *testing.T) {
s := newStack(t, goodAtomizer(nil))
ctx := context.Background()
if _, err := s.runner.Enqueue(ctx, JobKindExtend, ExtendPayload{
TaskID: s.task.ID, Note: "add CSV presets", ActorID: "cons",
}); err != nil {
t.Fatal(err)
}
runJobs(t, s, 4*time.Second)
siblings, err := s.st.ListTasks(ctx, store.TaskFilter{ParentID: s.task.ID})
if err != nil {
t.Fatal(err)
}
if len(siblings) != 1 {
t.Fatalf("siblings = %d, want exactly 1", len(siblings))
}
sib := siblings[0]
if sib.Origin != domain.OriginExtended || sib.Status != domain.StatusAtomized {
t.Fatalf("sibling: %s/%s", sib.Origin, sib.Status)
}
if sib.EffortCoefficient != 0.4 || sib.Bounty != 400 {
t.Fatalf("sibling coeff=%v bounty=%v, want 0.4/400", sib.EffortCoefficient, sib.Bounty)
}
// source status unchanged, timeline notes the extension
src, _ := s.st.TaskByID(ctx, s.task.ID)
if src.Status != domain.StatusImported {
t.Fatalf("source status = %s, want imported", src.Status)
}
}
func TestSubdivideFailureRevertsAfterRetries(t *testing.T) {
var calls atomic.Int64
s := newStack(t, func(w http.ResponseWriter, r *http.Request) {
calls.Add(1)
http.Error(w, `{"error":{"code":"llm_unavailable","message":"model down"}}`, http.StatusBadGateway)
})
ctx := context.Background()
if err := s.st.TransitionTask(ctx, s.task, domain.StatusAtomizing, "cons", "subdivide_requested", nil, nil); err != nil {
t.Fatal(err)
}
if _, err := s.runner.Enqueue(ctx, JobKindSubdivide, SubdividePayload{
TaskID: s.task.ID, ActorID: "cons", FromStatus: "imported",
}); err != nil {
t.Fatal(err)
}
// job retries with backoff (10s, 20s) — run one attempt per window (the
// HTTP client's own 5xx retries take ~4s), forcing queued retries due
// immediately between windows to keep the test fast
runJobs(t, s, 6*time.Second)
for i := 0; i < 2; i++ {
if _, err := s.st.DB.Collection("jobs").UpdateMany(ctx,
map[string]any{"status": jobs.StatusQueued},
map[string]any{"$set": map[string]any{"runAfter": time.Now().UTC().Add(-time.Second)}}); err != nil {
t.Fatal(err)
}
runJobs(t, s, 6*time.Second)
}
parent, err := s.st.TaskByID(ctx, s.task.ID)
if err != nil {
t.Fatal(err)
}
if parent.Status != domain.StatusImported {
t.Fatalf("status after exhausted retries = %s, want imported", parent.Status)
}
foundFailure := false
for _, e := range parent.Timeline {
if e.Event == "atomization_failed" {
foundFailure = true
}
}
if !foundFailure {
t.Error("timeline missing atomization_failed")
}
notifs, _ := s.st.ListNotifications(ctx, s.task.ConsultantID, false, "", 10)
failNotif := false
for _, n := range notifs {
if n.Kind == "atomization_failed" {
failNotif = true
}
}
if !failNotif {
t.Error("missing atomization_failed notification")
}
}
+73
View File
@@ -0,0 +1,73 @@
// Package extsvc holds shared plumbing for the two independent external
// services (Atomization, Work Performer): a small circuit breaker and a
// retrying JSON HTTP helper. The services themselves stay fully independent
// (§5) — each gets its own breaker instance, base URL, and token.
package extsvc
import (
"sync"
"time"
)
// Breaker implements §11.16: open after 3 consecutive failures, half-open
// probe every 60 s.
type Breaker struct {
mu sync.Mutex
fails int
openedAt time.Time
probing bool
now func() time.Time
threshold int
cooldown time.Duration
}
func NewBreaker() *Breaker {
return &Breaker{now: time.Now, threshold: 3, cooldown: 60 * time.Second}
}
// Allow reports whether a call may proceed right now. In the open state one
// probe is allowed per cooldown window (half-open).
func (b *Breaker) Allow() bool {
b.mu.Lock()
defer b.mu.Unlock()
if b.fails < b.threshold {
return true
}
if b.probing {
return false
}
if b.now().Sub(b.openedAt) >= b.cooldown {
b.probing = true // half-open: exactly one probe in flight
return true
}
return false
}
// Record reports a call outcome. Success closes the breaker; failure counts
// toward (or re-arms) the open state.
func (b *Breaker) Record(err error) {
b.mu.Lock()
defer b.mu.Unlock()
b.probing = false
if err == nil {
b.fails = 0
return
}
b.fails++
if b.fails >= b.threshold {
b.openedAt = b.now()
}
}
// State returns closed | open | half-open for status panels.
func (b *Breaker) State() string {
b.mu.Lock()
defer b.mu.Unlock()
if b.fails < b.threshold {
return "closed"
}
if b.probing || b.now().Sub(b.openedAt) >= b.cooldown {
return "half-open"
}
return "open"
}
+75
View File
@@ -0,0 +1,75 @@
package extsvc
import (
"errors"
"testing"
"time"
)
func TestBreakerLifecycle(t *testing.T) {
b := NewBreaker()
now := time.Date(2026, 6, 12, 12, 0, 0, 0, time.UTC)
b.now = func() time.Time { return now }
boom := errors.New("boom")
if b.State() != "closed" || !b.Allow() {
t.Fatal("fresh breaker must be closed")
}
// two failures: still closed
b.Record(boom)
b.Record(boom)
if b.State() != "closed" || !b.Allow() {
t.Fatal("breaker must stay closed below threshold")
}
// third consecutive failure opens it
b.Record(boom)
if b.State() != "open" {
t.Fatalf("state = %s, want open", b.State())
}
if b.Allow() {
t.Fatal("open breaker must reject calls")
}
// after the cooldown one probe is allowed (half-open)
now = now.Add(61 * time.Second)
if !b.Allow() {
t.Fatal("half-open must allow one probe")
}
if b.Allow() {
t.Fatal("only one probe may be in flight")
}
if b.State() != "half-open" {
t.Fatalf("state = %s, want half-open", b.State())
}
// failed probe re-opens for another cooldown
b.Record(boom)
if b.Allow() {
t.Fatal("failed probe must re-open the breaker")
}
now = now.Add(61 * time.Second)
if !b.Allow() {
t.Fatal("next probe window")
}
// successful probe closes it fully
b.Record(nil)
if b.State() != "closed" || !b.Allow() || !b.Allow() {
t.Fatal("successful probe must close the breaker")
}
}
func TestBreakerSuccessResetsCount(t *testing.T) {
b := NewBreaker()
boom := errors.New("x")
b.Record(boom)
b.Record(boom)
b.Record(nil) // non-consecutive failures never open
b.Record(boom)
b.Record(boom)
if b.State() != "closed" {
t.Fatal("interleaved success must reset the failure count")
}
}
+150
View File
@@ -0,0 +1,150 @@
package extsvc
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"io"
"math/rand/v2"
"net/http"
"time"
)
// ErrBreakerOpen is returned without touching the network when the circuit
// is open.
var ErrBreakerOpen = errors.New("circuit breaker open")
// ServiceError is a non-2xx response from the external service, carrying
// its §5 error envelope when present.
type ServiceError struct {
Status int
Code string
Message string
}
func (e *ServiceError) Error() string {
return fmt.Sprintf("service responded %d: %s %s", e.Status, e.Code, e.Message)
}
// Client is a bearer-token JSON client with retry (×2 on 5xx/network with
// jitter, §12) and a circuit breaker, shared by the two service clients.
type Client struct {
Name string
BaseURL func() string // resolved per call (admin override support)
Token string
HTTP *http.Client
Breaker *Breaker
}
func NewClient(name string, baseURL func() string, token string, timeout time.Duration) *Client {
return &Client{
Name: name,
BaseURL: baseURL,
Token: token,
HTTP: &http.Client{Timeout: timeout},
Breaker: NewBreaker(),
}
}
func (c *Client) BreakerState() string { return c.Breaker.State() }
// Healthy probes /healthz without breaker accounting (used by readyz and the
// admin panel).
func (c *Client) Healthy(ctx context.Context) error {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, c.BaseURL()+"/healthz", nil)
if err != nil {
return err
}
resp, err := c.HTTP.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return fmt.Errorf("%s healthz: status %d", c.Name, resp.StatusCode)
}
return nil
}
// Call POSTs/GETs JSON through the breaker with retries. A nil out skips
// decoding.
func (c *Client) Call(ctx context.Context, method, path string, in, out any) error {
if !c.Breaker.Allow() {
return fmt.Errorf("%s: %w", c.Name, ErrBreakerOpen)
}
err := c.callOnce(ctx, method, path, in, out)
c.Breaker.Record(err)
return err
}
func (c *Client) callOnce(ctx context.Context, method, path string, in, out any) error {
var body []byte
if in != nil {
var err error
if body, err = json.Marshal(in); err != nil {
return fmt.Errorf("encode request: %w", err)
}
}
var lastErr error
for attempt := 0; attempt < 3; attempt++ {
if attempt > 0 {
select {
case <-ctx.Done():
return ctx.Err()
case <-time.After(time.Duration(attempt)*time.Second + time.Duration(rand.IntN(500))*time.Millisecond):
}
}
req, err := http.NewRequestWithContext(ctx, method, c.BaseURL()+path, bytes.NewReader(body))
if err != nil {
return err
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", "Bearer "+c.Token)
resp, err := c.HTTP.Do(req)
if err != nil {
lastErr = fmt.Errorf("%s %s: %w", c.Name, path, err)
continue // network error → retry
}
data, err := io.ReadAll(io.LimitReader(resp.Body, 10<<20))
resp.Body.Close()
if err != nil {
lastErr = fmt.Errorf("%s %s: read body: %w", c.Name, path, err)
continue
}
if resp.StatusCode >= 500 {
lastErr = &ServiceError{Status: resp.StatusCode, Message: string(truncate(data, 200))}
continue // 5xx → retry
}
if resp.StatusCode >= 400 {
se := &ServiceError{Status: resp.StatusCode}
var env struct {
Error struct {
Code string `json:"code"`
Message string `json:"message"`
} `json:"error"`
}
if json.Unmarshal(data, &env) == nil {
se.Code, se.Message = env.Error.Code, env.Error.Message
}
return se // 4xx is not retryable
}
if out == nil {
return nil
}
if err := json.Unmarshal(data, out); err != nil {
return fmt.Errorf("%s %s: decode response: %w", c.Name, path, err)
}
return nil
}
return lastErr
}
func truncate(b []byte, n int) []byte {
if len(b) <= n {
return b
}
return b[:n]
}
+36 -1
View File
@@ -1,6 +1,10 @@
package httpx package httpx
import "context" import (
"context"
"errors"
"net/http"
)
// Late-bound providers: these are wired by later subsystems (atomizer // Late-bound providers: these are wired by later subsystems (atomizer
// client, work-performer client, sync manager, job runner) after the server // client, work-performer client, sync manager, job runner) after the server
@@ -35,6 +39,37 @@ func (s *Server) Publish(channel, event string, payload any) {
// SetPublishFn wires the actual hub broadcast. // SetPublishFn wires the actual hub broadcast.
func (s *Server) SetPublishFn(f func(channel, event string, payload any)) { s.publishFn = f } func (s *Server) SetPublishFn(f func(channel, event string, payload any)) { s.publishFn = f }
// SetEnqueue wires the background job queue.
func (s *Server) SetEnqueue(f func(ctx context.Context, kind string, payload any) (string, error)) {
s.enqueueFn = f
}
func (s *Server) enqueue(ctx context.Context, kind string, payload any) (string, error) {
if s.enqueueFn == nil {
return "", errors.New("job queue is not running")
}
return s.enqueueFn(ctx, kind, payload)
}
// SetWSHandler wires the WebSocket hub's connection handler.
func (s *Server) SetWSHandler(f func(w http.ResponseWriter, r *http.Request, userID string)) {
s.wsHandler = f
}
// handleWS authenticates the session and hands the connection to the hub.
func (s *Server) handleWS(w http.ResponseWriter, r *http.Request) {
u := s.pageUser(r)
if u == nil {
writeError(w, http.StatusUnauthorized, "unauthenticated", "session required")
return
}
if s.wsHandler == nil {
writeError(w, http.StatusServiceUnavailable, "ws_unavailable", "live updates are not running")
return
}
s.wsHandler(w, r, u.ID)
}
func (s *Server) SetSyncStatusProvider(p StatusProvider) { func (s *Server) SetSyncStatusProvider(p StatusProvider) {
s.syncProvider = p s.syncProvider = p
} }
+4
View File
@@ -39,6 +39,8 @@ type Server struct {
jobsProvider StatusProvider jobsProvider StatusProvider
syncTrigger func(customerID string) syncTrigger func(customerID string)
publishFn func(channel, event string, payload any) publishFn func(channel, event string, payload any)
enqueueFn func(ctx context.Context, kind string, payload any) (string, error)
wsHandler func(w http.ResponseWriter, r *http.Request, userID string)
} }
func New(cfg *config.Config, log *slog.Logger, reg *metrics.Registry, st *store.Store, fs *files.Store) *Server { func New(cfg *config.Config, log *slog.Logger, reg *metrics.Registry, st *store.Store, fs *files.Store) *Server {
@@ -67,6 +69,8 @@ func New(cfg *config.Config, log *slog.Logger, reg *metrics.Registry, st *store.
s.routesAuth(mux) s.routesAuth(mux)
s.routesProfile(mux) s.routesProfile(mux)
s.routesAdmin(mux) s.routesAdmin(mux)
s.routesTasks(mux)
mux.HandleFunc("GET /ws", s.handleWS)
s.routesWeb(mux) s.routesWeb(mux)
s.httpSrv = &http.Server{ s.httpSrv = &http.Server{
+467
View File
@@ -0,0 +1,467 @@
package httpx
import (
"context"
"errors"
"net/http"
"strings"
"time"
"go.mongodb.org/mongo-driver/v2/bson"
"bountyboard/internal/atomize"
"bountyboard/internal/domain"
"bountyboard/internal/store"
)
func (s *Server) routesTasks(mux *http.ServeMux) {
mux.Handle("GET /api/v1/consultant/board", s.authedRole("consultant", s.handleConsultantBoard))
mux.Handle("POST /api/v1/tasks/{id}/subdivide", s.authedRole("consultant", s.handleSubdivide))
mux.Handle("POST /api/v1/tasks/{id}/extend", s.authedRole("consultant", s.handleExtend))
mux.Handle("PATCH /api/v1/tasks/{id}", s.authedRole("consultant", s.handleEditTask))
mux.Handle("POST /api/v1/tasks/{id}/publish", s.authedRole("consultant", s.handlePublishOne))
mux.Handle("POST /api/v1/tasks/publish", s.authedRole("consultant", s.handlePublishBulk))
mux.Handle("POST /api/v1/tasks/{id}/archive", s.authed(s.handleArchiveTask))
mux.Handle("GET /api/v1/tasks/{id}", s.requireAuth(http.HandlerFunc(s.handleTaskDetail)))
mux.Handle("GET /api/v1/service-health", s.requireAuth(http.HandlerFunc(s.handleServiceHealth)))
}
// consultantTask loads a task and authorizes the current user: admins and
// every consultant assigned to the task's customer may manage it (§4.4).
func (s *Server) consultantTask(w http.ResponseWriter, r *http.Request, id string) (*domain.Task, *domain.Customer, bool) {
t, err := s.store.TaskByID(r.Context(), id)
if err != nil {
if errors.Is(err, store.ErrNotFound) {
writeError(w, http.StatusNotFound, "not_found", "task not found")
} else {
s.internalError(w, r, "load task", err)
}
return nil, nil, false
}
c, err := s.store.CustomerByID(r.Context(), t.CustomerID)
if err != nil {
s.internalError(w, r, "load customer", err)
return nil, nil, false
}
u := CurrentUser(r.Context())
if !u.Roles.Admin && !(u.Roles.Consultant && c.HasConsultant(u.ID)) {
writeError(w, http.StatusForbidden, "forbidden", "you are not assigned to this customer")
return nil, nil, false
}
return t, c, true
}
func (s *Server) handleConsultantBoard(w http.ResponseWriter, r *http.Request) {
u := CurrentUser(r.Context())
consultantID := u.ID
if u.Roles.Admin {
consultantID = "" // admins see all customers
}
customers, err := s.store.ListCustomers(r.Context(), consultantID, false)
if err != nil {
s.internalError(w, r, "list customers", err)
return
}
customerID := r.URL.Query().Get("customerId")
ids := []string{}
for _, c := range customers {
if customerID == "" || c.ID == customerID {
ids = append(ids, c.ID)
}
}
filter := store.TaskFilter{
CustomerIDs: ids,
Statuses: []domain.TaskStatus{
domain.StatusImported, domain.StatusAtomizing, domain.StatusAtomized,
domain.StatusPublished, domain.StatusClaimRequested,
},
Limit: 500,
Sort: "updated",
}
if st := r.URL.Query().Get("status"); st != "" {
filter.Statuses = []domain.TaskStatus{domain.TaskStatus(st)}
}
tasks := []domain.Task{}
if len(ids) > 0 {
if tasks, err = s.store.ListTasks(r.Context(), filter); err != nil {
s.internalError(w, r, "list tasks", err)
return
}
}
out := make([]map[string]any, len(customers))
for i := range customers {
out[i] = customerJSON(&customers[i])
}
writeJSON(w, http.StatusOK, map[string]any{"customers": out, "tasks": tasks})
}
func (s *Server) handleSubdivide(w http.ResponseWriter, r *http.Request) {
var req struct {
Note string `json:"note"`
Constraints *struct {
MinTasks int `json:"minTasks"`
MaxTasks int `json:"maxTasks"`
} `json:"constraints"`
ConfirmReplace bool `json:"confirmReplace"`
}
if !decodeJSON(w, r, &req) {
return
}
t, _, ok := s.consultantTask(w, r, r.PathValue("id"))
if !ok {
return
}
if t.Status != domain.StatusImported && t.Status != domain.StatusAtomized {
writeError(w, http.StatusConflict, "bad_status",
"only imported or atomized tasks can be subdivided (current: "+string(t.Status)+")")
return
}
minT, maxT := 0, 0
if req.Constraints != nil {
minT, maxT = req.Constraints.MinTasks, req.Constraints.MaxTasks
if minT < 0 || maxT < 0 || (maxT > 0 && minT > maxT) || maxT > 20 {
writeError(w, http.StatusBadRequest, "bad_constraints", "constraints must satisfy 0 <= min <= max <= 20")
return
}
}
// Re-run subdivision replaces unpublished children after confirmation
// (§11.7).
children, err := s.store.ListTasks(r.Context(), store.TaskFilter{ParentID: t.ID, Limit: 500})
if err != nil {
s.internalError(w, r, "list children", err)
return
}
var unpublished []string
for _, c := range children {
if c.Status == domain.StatusAtomized && c.Origin == domain.OriginSubdivided {
unpublished = append(unpublished, c.ID)
}
}
if len(children) > 0 && !req.ConfirmReplace {
writeError(w, http.StatusConflict, "confirm_replace",
"this task already has subtasks; re-running replaces the unpublished ones — confirm to proceed")
return
}
if len(unpublished) > 0 {
if _, err := s.store.DeleteTasks(r.Context(), unpublished); err != nil {
s.internalError(w, r, "delete unpublished children", err)
return
}
}
fromStatus := string(t.Status)
if err := s.store.TransitionTask(r.Context(), t, domain.StatusAtomizing,
CurrentUser(r.Context()).ID, "subdivide_requested",
map[string]any{"note": req.Note}, nil); err != nil {
s.taskTransitionError(w, err)
return
}
jobID, err := s.enqueue(r.Context(), atomize.JobKindSubdivide, atomize.SubdividePayload{
TaskID: t.ID, Note: req.Note, MinTasks: minT, MaxTasks: maxT,
ActorID: CurrentUser(r.Context()).ID, FromStatus: fromStatus,
})
if err != nil {
s.internalError(w, r, "enqueue subdivide", err)
return
}
s.Publish("board", "task.atomizing", map[string]any{"taskId": t.ID, "customerId": t.CustomerID})
writeJSON(w, http.StatusAccepted, map[string]string{"jobId": jobID, "status": "queued"})
}
func (s *Server) handleExtend(w http.ResponseWriter, r *http.Request) {
var req struct {
Note string `json:"note"`
}
if !decodeJSON(w, r, &req) {
return
}
if strings.TrimSpace(req.Note) == "" {
writeError(w, http.StatusBadRequest, "note_required", "extension requires a note (§1.1)")
return
}
t, _, ok := s.consultantTask(w, r, r.PathValue("id"))
if !ok {
return
}
if t.Status != domain.StatusImported && t.Status != domain.StatusAtomized {
writeError(w, http.StatusConflict, "bad_status",
"only imported or atomized tasks can be extended (current: "+string(t.Status)+")")
return
}
jobID, err := s.enqueue(r.Context(), atomize.JobKindExtend, atomize.ExtendPayload{
TaskID: t.ID, Note: req.Note, ActorID: CurrentUser(r.Context()).ID,
})
if err != nil {
s.internalError(w, r, "enqueue extend", err)
return
}
writeJSON(w, http.StatusAccepted, map[string]string{"jobId": jobID, "status": "queued"})
}
func (s *Server) handleEditTask(w http.ResponseWriter, r *http.Request) {
var req struct {
Version *int64 `json:"version"`
Title *string `json:"title"`
Description *string `json:"description"`
AcceptanceCriteria *[]string `json:"acceptanceCriteria"`
Links *[]string `json:"links"`
EffortCoefficient *float64 `json:"effortCoefficient"`
Budget *float64 `json:"budget"`
CascadeBudget bool `json:"cascadeBudget"`
}
if !decodeJSON(w, r, &req) {
return
}
t, _, ok := s.consultantTask(w, r, r.PathValue("id"))
if !ok {
return
}
// §6.1: bounty is frozen once approved; archived tasks are immutable.
if t.Status == domain.StatusApproved || t.Status == domain.StatusArchived {
writeError(w, http.StatusConflict, "immutable", "approved/archived tasks cannot be edited")
return
}
set := bson.M{}
coeff, budget := t.EffortCoefficient, t.Budget
if req.Title != nil && strings.TrimSpace(*req.Title) != "" {
set["title"] = strings.TrimSpace(*req.Title)
}
if req.Description != nil {
set["description"] = *req.Description
}
if req.AcceptanceCriteria != nil {
set["acceptanceCriteria"] = *req.AcceptanceCriteria
}
if req.Links != nil {
set["links"] = *req.Links
}
if req.EffortCoefficient != nil {
coeff = *req.EffortCoefficient
maxCoeff := 1.0
if t.Origin == domain.OriginExtended {
maxCoeff = 2.0 // §5.1: extensions may exceed 1
}
if coeff <= 0 || coeff > maxCoeff {
writeError(w, http.StatusBadRequest, "bad_coefficient",
"effort coefficient out of range")
return
}
set["effortCoefficient"] = coeff
}
if req.Budget != nil {
budget = *req.Budget
if budget < 0 {
writeError(w, http.StatusBadRequest, "bad_budget", "budget must be >= 0")
return
}
set["budget"] = budget
}
if len(set) == 0 {
writeError(w, http.StatusBadRequest, "empty_update", "no recognized fields")
return
}
set["bounty"] = domain.ComputeBounty(coeff, budget)
version := t.Version
if req.Version != nil {
version = *req.Version
}
err := store.UpdateVersioned(r.Context(), s.store.DB.Collection("tasks"), t.ID, version, bson.M{
"$set": set,
"$push": bson.M{"timeline": domain.TimelineEntry{
At: time.Now().UTC(), ActorID: CurrentUser(r.Context()).ID, Event: "edited",
}},
})
if err != nil {
if errors.Is(err, store.ErrVersionConflict) {
writeError(w, http.StatusConflict, "conflict", "task was modified concurrently — reload")
return
}
s.internalError(w, r, "edit task", err)
return
}
// Optional budget cascade to descendants whose bounty is not yet frozen.
if req.Budget != nil && req.CascadeBudget {
if err := s.cascadeBudget(r.Context(), t.ID, budget); err != nil {
s.log.Warn("cascade budget", "task", t.ID, "err", err)
}
}
fresh, err := s.store.TaskByID(r.Context(), t.ID)
if err != nil {
s.internalError(w, r, "reload task", err)
return
}
s.Publish("board", "task.updated", map[string]any{"taskId": t.ID, "customerId": t.CustomerID})
writeJSON(w, http.StatusOK, map[string]any{"task": fresh})
}
// cascadeBudget walks descendants breadth-first updating budget + bounty for
// tasks that are not approved/archived.
func (s *Server) cascadeBudget(ctx context.Context, rootID string, budget float64) error {
frontier := []string{rootID}
for len(frontier) > 0 {
children, err := s.store.ListTasks(ctx, store.TaskFilter{ParentID: frontier[0], Limit: 500})
if err != nil {
return err
}
frontier = frontier[1:]
for _, c := range children {
if c.Status == domain.StatusApproved || c.Status == domain.StatusArchived {
continue
}
_, err := s.store.DB.Collection("tasks").UpdateOne(ctx,
bson.M{"_id": c.ID},
bson.M{"$set": bson.M{
"budget": budget,
"bounty": domain.ComputeBounty(c.EffortCoefficient, budget),
"updatedAt": time.Now().UTC(),
}, "$inc": bson.M{"version": 1}})
if err != nil {
return err
}
frontier = append(frontier, c.ID)
}
}
return nil
}
func (s *Server) publishTask(ctx context.Context, t *domain.Task, actorID string) error {
return s.store.TransitionTask(ctx, t, domain.StatusPublished, actorID, "published", nil,
map[string]any{"publishedAt": time.Now().UTC()})
}
func (s *Server) handlePublishOne(w http.ResponseWriter, r *http.Request) {
t, _, ok := s.consultantTask(w, r, r.PathValue("id"))
if !ok {
return
}
if err := s.publishTask(r.Context(), t, CurrentUser(r.Context()).ID); err != nil {
s.taskTransitionError(w, err)
return
}
s.Publish("board", "task.published", map[string]any{"taskId": t.ID, "customerId": t.CustomerID})
fresh, _ := s.store.TaskByID(r.Context(), t.ID)
writeJSON(w, http.StatusOK, map[string]any{"task": fresh})
}
func (s *Server) handlePublishBulk(w http.ResponseWriter, r *http.Request) {
var req struct {
IDs []string `json:"ids"`
}
if !decodeJSON(w, r, &req) {
return
}
if len(req.IDs) == 0 || len(req.IDs) > 100 {
writeError(w, http.StatusBadRequest, "bad_request", "ids must contain 1..100 entries")
return
}
published := []string{}
failed := map[string]string{}
for _, id := range req.IDs {
t, err := s.store.TaskByID(r.Context(), id)
if err != nil {
failed[id] = "not found"
continue
}
c, err := s.store.CustomerByID(r.Context(), t.CustomerID)
if err != nil {
failed[id] = "customer missing"
continue
}
u := CurrentUser(r.Context())
if !u.Roles.Admin && !c.HasConsultant(u.ID) {
failed[id] = "not your customer"
continue
}
if err := s.publishTask(r.Context(), t, u.ID); err != nil {
failed[id] = err.Error()
continue
}
published = append(published, id)
s.Publish("board", "task.published", map[string]any{"taskId": id, "customerId": t.CustomerID})
}
writeJSON(w, http.StatusOK, map[string]any{"published": published, "failed": failed})
}
func (s *Server) handleArchiveTask(w http.ResponseWriter, r *http.Request) {
u := CurrentUser(r.Context())
if !u.Roles.Admin && !u.Roles.Consultant {
writeError(w, http.StatusForbidden, "forbidden", "requires consultant or admin role")
return
}
t, _, ok := s.consultantTask(w, r, r.PathValue("id"))
if !ok {
return
}
if err := s.store.TransitionTask(r.Context(), t, domain.StatusArchived, u.ID, "archived", nil, nil); err != nil {
s.taskTransitionError(w, err)
return
}
s.Publish("board", "task.archived", map[string]any{"taskId": t.ID, "customerId": t.CustomerID})
w.WriteHeader(http.StatusNoContent)
}
// handleTaskDetail is role-scoped: admins and customer consultants always;
// developers when they are the assignee, have a claim, or the task is on the
// public board (published/claim_requested).
func (s *Server) handleTaskDetail(w http.ResponseWriter, r *http.Request) {
t, err := s.store.TaskByID(r.Context(), r.PathValue("id"))
if err != nil {
if errors.Is(err, store.ErrNotFound) {
writeError(w, http.StatusNotFound, "not_found", "task not found")
} else {
s.internalError(w, r, "load task", err)
}
return
}
u := CurrentUser(r.Context())
allowed := u.Roles.Admin
if !allowed && u.Roles.Consultant {
if c, err := s.store.CustomerByID(r.Context(), t.CustomerID); err == nil && c.HasConsultant(u.ID) {
allowed = true
}
}
if !allowed && u.Roles.Developer {
allowed = t.IsAssignedTo(u.ID) || t.HasClaimFrom(u.ID) ||
t.Status == domain.StatusPublished || t.Status == domain.StatusClaimRequested
}
if !allowed {
writeError(w, http.StatusForbidden, "forbidden", "no access to this task")
return
}
writeJSON(w, http.StatusOK, map[string]any{"task": t})
}
// handleServiceHealth powers UI gating (§11.16): disable Subdivide/Extend
// when the atomizer is down, independently of the work performer.
func (s *Server) handleServiceHealth(w http.ResponseWriter, r *http.Request) {
ctx, cancel := context.WithTimeout(r.Context(), 4*time.Second)
defer cancel()
atom := s.probeExternal(ctx, "atomizer", s.effectiveAtomizerBaseURL(ctx))
perf := s.probeExternal(ctx, "work-performer", s.cfg.WorkPerformerBaseURL)
if s.atomizer != nil {
atom.Breaker = s.atomizer.BreakerState()
}
if s.performer != nil {
perf.Breaker = s.performer.BreakerState()
}
writeJSON(w, http.StatusOK, map[string]any{"atomizer": atom, "workPerformer": perf})
}
func (s *Server) taskTransitionError(w http.ResponseWriter, err error) {
var bad *domain.ErrBadTransition
switch {
case errors.As(err, &bad):
writeError(w, http.StatusConflict, "bad_status", bad.Error())
case errors.Is(err, store.ErrVersionConflict):
writeError(w, http.StatusConflict, "conflict", "task was modified concurrently — reload")
case errors.Is(err, store.ErrNotFound):
writeError(w, http.StatusNotFound, "not_found", "task not found")
default:
writeError(w, http.StatusInternalServerError, "internal", "internal server error")
}
}
+11 -1
View File
@@ -181,11 +181,21 @@ func (s *Server) routesWeb(mux *http.ServeMux) {
}) })
})) }))
mux.HandleFunc("GET /consultant/board", s.page(func(w http.ResponseWriter, r *http.Request, u *domain.User) {
if !u.Roles.Consultant && !u.Roles.Admin {
s.render(w, r, "placeholder.html", &pageData{Title: "Not authorized", User: u})
return
}
s.render(w, r, "atomization.html", &pageData{
Title: "Atomization Board", Active: "atomization", User: u,
Scripts: []string{"/static/js/atomization.js"},
})
}))
// Placeholders for areas built in later phases; replaced as they land. // Placeholders for areas built in later phases; replaced as they land.
placeholders := map[string]string{ placeholders := map[string]string{
"/board": "Bounty Board", "/board": "Bounty Board",
"/my-tasks": "My Tasks", "/my-tasks": "My Tasks",
"/consultant/board": "Atomization Board",
"/consultant/reviews": "Review Queue", "/consultant/reviews": "Review Queue",
"/consultant/pool": "Developer Pool", "/consultant/pool": "Developer Pool",
"/messages": "Messages", "/messages": "Messages",
+267
View File
@@ -0,0 +1,267 @@
// Package jobs is a small persisted background job queue (§12): jobs live in
// the `jobs` collection so restarts don't lose work; workers recover from
// panics and retry with exponential backoff up to 3 attempts.
package jobs
import (
"context"
"errors"
"fmt"
"log/slog"
"runtime/debug"
"time"
"go.mongodb.org/mongo-driver/v2/bson"
"go.mongodb.org/mongo-driver/v2/mongo"
"bountyboard/internal/metrics"
"bountyboard/internal/store"
"bountyboard/internal/ulid"
)
const (
StatusQueued = "queued"
StatusRunning = "running"
StatusSucceeded = "succeeded"
StatusFailed = "failed"
maxAttempts = 3
// a "running" job untouched this long is presumed lost to a crash and
// re-queued
staleAfter = 5 * time.Minute
)
type Job struct {
ID string `bson:"_id" json:"id"`
Kind string `bson:"kind" json:"kind"`
Payload bson.Raw `bson:"payload" json:"-"`
Status string `bson:"status" json:"status"`
Attempts int `bson:"attempts" json:"attempts"`
RunAfter time.Time `bson:"runAfter" json:"runAfter"`
LastError string `bson:"lastError,omitempty" json:"lastError,omitempty"`
CreatedAt time.Time `bson:"createdAt" json:"createdAt"`
UpdatedAt time.Time `bson:"updatedAt" json:"updatedAt"`
}
// Handler processes one job. err == nil marks the job succeeded; an error
// schedules a retry (with backoff) until attempts run out — the final
// failure is reported via OnExhausted if the handler set one.
type Handler func(ctx context.Context, job *Job) error
type Runner struct {
st *store.Store
log *slog.Logger
reg *metrics.Registry
handlers map[string]Handler
workers int
}
func NewRunner(st *store.Store, log *slog.Logger, reg *metrics.Registry, workers int) *Runner {
if workers < 1 {
workers = 2
}
return &Runner{
st: st, log: log, reg: reg,
handlers: map[string]Handler{}, workers: workers,
}
}
func (r *Runner) coll() *mongo.Collection { return r.st.DB.Collection("jobs") }
// Register installs the handler for a job kind (call before Run).
func (r *Runner) Register(kind string, h Handler) { r.handlers[kind] = h }
// Enqueue persists a job for asynchronous execution.
func (r *Runner) Enqueue(ctx context.Context, kind string, payload any) (string, error) {
raw, err := bson.Marshal(payload)
if err != nil {
return "", fmt.Errorf("encode job payload: %w", err)
}
now := time.Now().UTC()
job := Job{
ID: ulid.New(), Kind: kind, Payload: raw,
Status: StatusQueued, RunAfter: now, CreatedAt: now, UpdatedAt: now,
}
if _, err := r.coll().InsertOne(ctx, job); err != nil {
return "", fmt.Errorf("enqueue %s: %w", kind, err)
}
r.reg.Inc("jobs_enqueued_total", 1)
return job.ID, nil
}
// DecodePayload unmarshals the job payload into dst.
func DecodePayload(job *Job, dst any) error {
if err := bson.Unmarshal(job.Payload, dst); err != nil {
return fmt.Errorf("decode payload of job %s (%s): %w", job.ID, job.Kind, err)
}
return nil
}
// Run starts the worker pool and blocks until ctx is canceled.
func (r *Runner) Run(ctx context.Context) {
for i := 0; i < r.workers; i++ {
go r.workLoop(ctx)
}
// stale-job janitor
ticker := time.NewTicker(time.Minute)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
return
case <-ticker.C:
r.requeueStale(ctx)
r.updateGauges(ctx)
}
}
}
func (r *Runner) workLoop(ctx context.Context) {
for {
select {
case <-ctx.Done():
return
default:
}
job, err := r.claim(ctx)
if err != nil {
if !errors.Is(err, context.Canceled) {
r.log.Error("claim job", "err", err)
}
sleep(ctx, 2*time.Second)
continue
}
if job == nil {
sleep(ctx, time.Second)
continue
}
r.execute(ctx, job)
}
}
func sleep(ctx context.Context, d time.Duration) {
select {
case <-ctx.Done():
case <-time.After(d):
}
}
// claim atomically moves one due job to running.
func (r *Runner) claim(ctx context.Context) (*Job, error) {
now := time.Now().UTC()
var job Job
err := r.coll().FindOneAndUpdate(ctx,
bson.M{"status": StatusQueued, "runAfter": bson.M{"$lte": now}},
bson.M{"$set": bson.M{"status": StatusRunning, "updatedAt": now},
"$inc": bson.M{"attempts": 1}},
).Decode(&job)
if errors.Is(err, mongo.ErrNoDocuments) {
return nil, nil
}
if err != nil {
return nil, err
}
job.Attempts++ // FindOneAndUpdate returned the pre-update doc
job.Status = StatusRunning
return &job, nil
}
func (r *Runner) execute(ctx context.Context, job *Job) {
handler, ok := r.handlers[job.Kind]
if !ok {
r.finish(ctx, job, fmt.Errorf("no handler registered for kind %q", job.Kind), false)
return
}
var err error
func() {
defer func() {
if rec := recover(); rec != nil {
err = fmt.Errorf("panic: %v", rec)
r.log.Error("job panic", "job", job.ID, "kind", job.Kind,
"panic", fmt.Sprint(rec), "stack", string(debug.Stack()))
}
}()
jctx, cancel := context.WithTimeout(ctx, 10*time.Minute)
defer cancel()
err = handler(jctx, job)
}()
r.finish(ctx, job, err, true)
}
func (r *Runner) finish(ctx context.Context, job *Job, err error, retryable bool) {
// Bookkeeping must survive shutdown/cancellation: otherwise a canceled
// run leaves the job stuck in "running" until stale recovery.
ctx, cancel := context.WithTimeout(context.WithoutCancel(ctx), 10*time.Second)
defer cancel()
now := time.Now().UTC()
switch {
case err == nil:
_, uerr := r.coll().UpdateOne(ctx, bson.M{"_id": job.ID}, bson.M{
"$set": bson.M{"status": StatusSucceeded, "lastError": "", "updatedAt": now}})
if uerr != nil {
r.log.Error("mark job succeeded", "job", job.ID, "err", uerr)
}
r.reg.Inc("jobs_succeeded_total", 1)
case retryable && job.Attempts < maxAttempts:
backoff := time.Duration(1<<job.Attempts) * 5 * time.Second // 10s, 20s
_, uerr := r.coll().UpdateOne(ctx, bson.M{"_id": job.ID}, bson.M{
"$set": bson.M{"status": StatusQueued, "lastError": err.Error(),
"runAfter": now.Add(backoff), "updatedAt": now}})
if uerr != nil {
r.log.Error("requeue job", "job", job.ID, "err", uerr)
}
r.log.Warn("job failed, will retry", "job", job.ID, "kind", job.Kind,
"attempt", job.Attempts, "err", err)
r.reg.Inc("jobs_retried_total", 1)
default:
_, uerr := r.coll().UpdateOne(ctx, bson.M{"_id": job.ID}, bson.M{
"$set": bson.M{"status": StatusFailed, "lastError": err.Error(), "updatedAt": now}})
if uerr != nil {
r.log.Error("mark job failed", "job", job.ID, "err", uerr)
}
r.log.Error("job exhausted retries", "job", job.ID, "kind", job.Kind, "err", err)
r.reg.Inc("jobs_failed_total", 1)
}
}
// IsFinalAttempt lets handlers run compensation logic (revert status, notify)
// only when no more retries will happen.
func IsFinalAttempt(job *Job) bool { return job.Attempts >= maxAttempts }
func (r *Runner) requeueStale(ctx context.Context) {
cutoff := time.Now().UTC().Add(-staleAfter)
res, err := r.coll().UpdateMany(ctx,
bson.M{"status": StatusRunning, "updatedAt": bson.M{"$lt": cutoff}},
bson.M{"$set": bson.M{"status": StatusQueued, "updatedAt": time.Now().UTC()}})
if err != nil {
r.log.Error("requeue stale jobs", "err", err)
return
}
if res.ModifiedCount > 0 {
r.log.Warn("requeued stale running jobs", "count", res.ModifiedCount)
}
}
func (r *Runner) updateGauges(ctx context.Context) {
for _, status := range []string{StatusQueued, StatusRunning, StatusFailed} {
n, err := r.coll().CountDocuments(ctx, bson.M{"status": status})
if err == nil {
r.reg.Set("jobs_"+status, n)
}
}
}
// Statuses feeds the admin service-status panel.
func (r *Runner) Statuses() any {
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
defer cancel()
out := map[string]int64{}
for _, status := range []string{StatusQueued, StatusRunning, StatusSucceeded, StatusFailed} {
n, err := r.coll().CountDocuments(ctx, bson.M{"status": status})
if err != nil {
return map[string]string{"error": err.Error()}
}
out[status] = n
}
return out
}
+10 -1
View File
@@ -33,8 +33,13 @@ func EnsureIndexes(ctx context.Context, db *mongo.Database) error {
{Keys: bson.D{{Key: "assignee.userId", Value: 1}, {Key: "status", Value: 1}}}, {Keys: bson.D{{Key: "assignee.userId", Value: 1}, {Key: "status", Value: 1}}},
{Keys: bson.D{{Key: "rootId", Value: 1}}}, {Keys: bson.D{{Key: "rootId", Value: 1}}},
{Keys: bson.D{{Key: "parentId", Value: 1}}}, {Keys: bson.D{{Key: "parentId", Value: 1}}},
// The spec calls this "unique sparse", but a compound sparse index
// matches any doc where customerId exists (i.e. every task), which
// breaks subdivided children. A partial index expresses the intent:
// uniqueness only for tasks that actually have an external ref.
{Keys: bson.D{{Key: "external.system", Value: 1}, {Key: "external.key", Value: 1}, {Key: "customerId", Value: 1}}, {Keys: bson.D{{Key: "external.system", Value: 1}, {Key: "external.key", Value: 1}, {Key: "customerId", Value: 1}},
Options: options.Index().SetUnique(true).SetSparse(true)}, Options: options.Index().SetUnique(true).SetName("uniq_external_ref").
SetPartialFilterExpression(bson.M{"external.key": bson.M{"$exists": true}})},
{Keys: bson.D{{Key: "title", Value: "text"}, {Key: "description", Value: "text"}}}, {Keys: bson.D{{Key: "title", Value: "text"}, {Key: "description", Value: "text"}}},
}, },
"bountyAwards": { "bountyAwards": {
@@ -63,6 +68,10 @@ func EnsureIndexes(ctx context.Context, db *mongo.Database) error {
}, },
} }
// migration: an earlier build created the external-ref index as sparse
// under the auto-generated name; drop it so the partial variant applies
_ = db.Collection("tasks").Indexes().DropOne(ctx, "external.system_1_external.key_1_customerId_1")
for coll, models := range plan { for coll, models := range plan {
if _, err := db.Collection(coll).Indexes().CreateMany(ctx, models); err != nil { if _, err := db.Collection(coll).Indexes().CreateMany(ctx, models); err != nil {
return fmt.Errorf("create indexes for %s: %w", coll, err) return fmt.Errorf("create indexes for %s: %w", coll, err)
+4 -4
View File
@@ -28,7 +28,7 @@ func TestEnsureIndexesIsIdempotent(t *testing.T) {
// Spot-check a few critical indexes actually exist. // Spot-check a few critical indexes actually exist.
checks := map[string]string{ checks := map[string]string{
"users": "email_1", "users": "email_1",
"tasks": "external.system_1_external.key_1_customerId_1", "tasks": "uniq_external_ref",
"sessions": "expiresAt_1", "sessions": "expiresAt_1",
} }
for coll, wantName := range checks { for coll, wantName := range checks {
@@ -44,10 +44,10 @@ func TestEnsureIndexesIsIdempotent(t *testing.T) {
for _, s := range specs { for _, s := range specs {
if s["name"] == wantName { if s["name"] == wantName {
found = true found = true
// the unique upsert key must really be unique+sparse // the upsert key must be unique and partial (external-only)
if coll == "tasks" { if coll == "tasks" {
if s["unique"] != true || s["sparse"] != true { if s["unique"] != true || s["partialFilterExpression"] == nil {
t.Errorf("%s/%s: want unique sparse, got %v", coll, wantName, s) t.Errorf("%s/%s: want unique partial, got %v", coll, wantName, s)
} }
} }
} }
+190
View File
@@ -0,0 +1,190 @@
// Package ws implements the single multiplexed live-update WebSocket (§2.2):
// one connection per logged-in session carrying `chat`, `board`, and
// `notifications` channels, with 30 s heartbeats so idle proxies keep the
// connection alive (§12).
package ws
import (
"context"
"encoding/json"
"log/slog"
"net/http"
"sync"
"time"
"github.com/coder/websocket"
)
// Message is the wire format in both directions.
type Message struct {
Channel string `json:"channel"`
Event string `json:"event"`
Data json.RawMessage `json:"data,omitempty"`
}
type client struct {
userID string
send chan []byte
}
// Inbound handles client→server messages (e.g. typing indicators).
type Inbound func(userID string, msg Message)
type Hub struct {
log *slog.Logger
originOK func(r *http.Request) bool
inbound Inbound
mu sync.RWMutex
clients map[*client]struct{}
byUser map[string]map[*client]struct{}
}
func NewHub(log *slog.Logger, originOK func(*http.Request) bool) *Hub {
return &Hub{
log: log,
originOK: originOK,
clients: map[*client]struct{}{},
byUser: map[string]map[*client]struct{}{},
}
}
// SetInbound installs the client-message handler (chat typing etc.).
func (h *Hub) SetInbound(f Inbound) { h.inbound = f }
// ConnectedUserIDs lists users with at least one live socket.
func (h *Hub) ConnectedUserIDs() []string {
h.mu.RLock()
defer h.mu.RUnlock()
out := make([]string, 0, len(h.byUser))
for id := range h.byUser {
out = append(out, id)
}
return out
}
func (h *Hub) register(c *client) {
h.mu.Lock()
defer h.mu.Unlock()
h.clients[c] = struct{}{}
if h.byUser[c.userID] == nil {
h.byUser[c.userID] = map[*client]struct{}{}
}
h.byUser[c.userID][c] = struct{}{}
}
func (h *Hub) unregister(c *client) {
h.mu.Lock()
defer h.mu.Unlock()
delete(h.clients, c)
if set := h.byUser[c.userID]; set != nil {
delete(set, c)
if len(set) == 0 {
delete(h.byUser, c.userID)
}
}
}
func encode(channel, event string, payload any) []byte {
data, err := json.Marshal(payload)
if err != nil {
data = []byte("null")
}
b, _ := json.Marshal(Message{Channel: channel, Event: event, Data: data})
return b
}
// Broadcast sends to every connected client.
func (h *Hub) Broadcast(channel, event string, payload any) {
b := encode(channel, event, payload)
h.mu.RLock()
defer h.mu.RUnlock()
for c := range h.clients {
select {
case c.send <- b:
default: // slow client: drop rather than block the publisher
}
}
}
// SendTo delivers to all sockets of the given users.
func (h *Hub) SendTo(userIDs []string, channel, event string, payload any) {
b := encode(channel, event, payload)
h.mu.RLock()
defer h.mu.RUnlock()
for _, id := range userIDs {
for c := range h.byUser[id] {
select {
case c.send <- b:
default:
}
}
}
}
// Handle upgrades the request (the caller has already authenticated userID).
func (h *Hub) Handle(w http.ResponseWriter, r *http.Request, userID string) {
if h.originOK != nil && !h.originOK(r) {
http.Error(w, "origin not allowed", http.StatusForbidden)
return
}
conn, err := websocket.Accept(w, r, &websocket.AcceptOptions{
// Origin verified above against APP_BASE_URL (§12).
InsecureSkipVerify: true,
})
if err != nil {
h.log.Warn("ws accept", "err", err)
return
}
c := &client{userID: userID, send: make(chan []byte, 64)}
h.register(c)
defer h.unregister(c)
ctx, cancel := context.WithCancel(r.Context())
defer cancel()
go h.writeLoop(ctx, cancel, conn, c)
h.readLoop(ctx, conn, c)
conn.Close(websocket.StatusNormalClosure, "bye")
}
func (h *Hub) writeLoop(ctx context.Context, cancel func(), conn *websocket.Conn, c *client) {
defer cancel()
heartbeat := time.NewTicker(30 * time.Second)
defer heartbeat.Stop()
for {
select {
case <-ctx.Done():
return
case msg := <-c.send:
wctx, wcancel := context.WithTimeout(ctx, 10*time.Second)
err := conn.Write(wctx, websocket.MessageText, msg)
wcancel()
if err != nil {
return
}
case <-heartbeat.C:
pctx, pcancel := context.WithTimeout(ctx, 10*time.Second)
err := conn.Ping(pctx)
pcancel()
if err != nil {
return
}
}
}
}
func (h *Hub) readLoop(ctx context.Context, conn *websocket.Conn, c *client) {
for {
_, data, err := conn.Read(ctx)
if err != nil {
return
}
if h.inbound == nil {
continue
}
var msg Message
if err := json.Unmarshal(data, &msg); err != nil {
continue
}
h.inbound(c.userID, msg)
}
}
+17
View File
@@ -155,6 +155,23 @@ table.list th { color: var(--muted); font-size: 0.875rem; }
.toast.err { border-left-color: var(--err); } .toast.err { border-left-color: var(--err); }
.toast.ok { border-left-color: var(--ok); } .toast.ok { border-left-color: var(--ok); }
/* atomizing progress shimmer */
.shimmer {
position: relative;
overflow: hidden;
}
.shimmer::after {
content: "";
position: absolute; inset: 0;
background: linear-gradient(90deg, transparent, color-mix(in srgb, var(--accent) 12%, transparent), transparent);
animation: shimmer 1.6s infinite;
}
@keyframes shimmer {
from { transform: translateX(-100%); }
to { transform: translateX(100%); }
}
input[type=range] { width: 100%; accent-color: var(--accent); }
.tabs { display: flex; gap: 8px; margin-bottom: 16px; flex-wrap: wrap; } .tabs { display: flex; gap: 8px; margin-bottom: 16px; flex-wrap: wrap; }
.tabs [aria-selected=true] { background: var(--accent); color: var(--accent-contrast); border-color: var(--accent); } .tabs [aria-selected=true] { background: var(--accent); color: var(--accent-contrast); border-color: var(--accent); }
+319
View File
@@ -0,0 +1,319 @@
import { api, toast } from '/static/js/api.js';
import { subscribe, onPollFallback } from '/static/js/ws.js';
const errorBox = document.getElementById('error');
const board = document.getElementById('board');
const customerSel = document.getElementById('board-customer');
const icons = { epic: '◆', story: '▣', task: '▢' };
let customers = [];
let tasks = [];
let atomizerUp = true;
const selected = new Set();
function esc(s) {
return String(s ?? '').replace(/[&<>"']/g, (c) => ({
'&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;',
}[c]));
}
async function checkHealth() {
const badge = document.getElementById('atomizer-health');
try {
const res = await api('GET', '/api/v1/service-health');
const a = res.atomizer;
atomizerUp = a.healthy && a.breaker !== 'open';
badge.textContent = atomizerUp
? `atomizer healthy · ${a.latencyMs} ms`
: `atomizer ${a.healthy ? 'breaker ' + a.breaker : 'DOWN'} — Subdivide/Extend disabled`;
badge.style.color = atomizerUp ? 'var(--ok)' : 'var(--err)';
} catch (e) {
atomizerUp = false;
badge.textContent = 'atomizer status unknown';
}
document.querySelectorAll('[data-needs-atomizer]').forEach((b) => {
b.disabled = !atomizerUp;
b.title = atomizerUp ? '' : 'Atomization Service is unavailable';
});
}
async function load() {
try {
const cid = customerSel.value || '';
const res = await api('GET', `/api/v1/consultant/board?customerId=${encodeURIComponent(cid)}`);
customers = res.customers;
tasks = res.tasks;
if (!customerSel.options.length) {
customerSel.replaceChildren(
new Option('All customers', ''),
...customers.map((c) => new Option(c.name, c.id)),
);
customerSel.value = new URLSearchParams(location.search).get('customerId') || '';
if (customerSel.value) return load();
}
render();
} catch (e) { errorBox.textContent = e.message; }
}
function childrenOf(id) { return tasks.filter((t) => t.parentId === id); }
function render() {
selected.forEach((id) => { if (!tasks.find((t) => t.id === id)) selected.delete(id); });
updateBulkButton();
const roots = tasks.filter((t) => !t.parentId);
board.replaceChildren(...roots.map(renderRoot));
if (!roots.length) {
board.innerHTML = '<p class="muted">No tasks on the atomization board for this selection.</p>';
}
checkHealth();
}
function statusBadge(t) {
const cls = { atomizing: 'warn', published: 'ok' }[t.status] || '';
return `<span class="badge" style="${cls === 'ok' ? 'color:var(--ok)' : cls === 'warn' ? 'color:var(--warn)' : ''}">${esc(t.status)}</span>`;
}
function renderRoot(root) {
const card = document.createElement('div');
card.className = 'card' + (root.status === 'atomizing' ? ' shimmer' : '');
const cust = customers.find((c) => c.id === root.customerId);
const icon = root.external ? (icons[root.external.type] || '▢') : '▢';
const orphan = root.external && root.external.orphaned
? '<span class="badge" style="color:var(--err)" title="No longer returned by the upstream system">orphaned</span>' : '';
card.innerHTML = `
<div class="spread">
<h3>${icon} ${esc(root.title)} ${statusBadge(root)} ${orphan}</h3>
<span class="muted">${esc(cust ? cust.name : '')} ${root.external ? '· ' + esc(root.external.key) : ''}</span>
</div>
<p class="muted">${esc((root.description || '').slice(0, 240))}</p>
<div class="spread">
<span>Budget <strong>${root.budget}</strong> · bounty preview <strong>${root.bounty}</strong></span>
<span>
${root.external && root.external.url ? `<a class="btn small" href="${esc(root.external.url)}" target="_blank" rel="noopener">source ↗</a>` : ''}
<button class="btn small" data-act="edit">Edit</button>
<button class="btn small primary" data-act="subdivide" data-needs-atomizer ${root.status === 'atomizing' ? 'disabled' : ''}>Subdivide</button>
<button class="btn small" data-act="extend" data-needs-atomizer>Extend</button>
<button class="btn small" data-act="archive">Archive</button>
</span>
</div>
<div class="children"></div>`;
card.querySelector('[data-act=subdivide]').addEventListener('click', () => openSubdivide(root));
card.querySelector('[data-act=extend]').addEventListener('click', () => openExtend(root));
card.querySelector('[data-act=edit]').addEventListener('click', () => openEdit(root, true));
card.querySelector('[data-act=archive]').addEventListener('click', () => archive(root));
const kids = childrenOf(root.id);
if (kids.length) {
card.querySelector('.children').appendChild(renderChildren(root, kids, 1));
}
return card;
}
function renderChildren(parent, kids, depth) {
const wrap = document.createElement('div');
wrap.style.marginLeft = `${depth * 16}px`;
wrap.className = 'stack mt';
const subdivided = kids.filter((k) => k.origin === 'subdivided');
const sum = subdivided.reduce((acc, k) => acc + k.effortCoefficient, 0);
const hasExtension = kids.some((k) => k.origin === 'extended');
if (subdivided.length) {
const ind = document.createElement('p');
const bad = !hasExtension && Math.abs(sum - 1) > 0.005;
ind.innerHTML = `Coefficient sum: <strong style="color:${bad ? 'var(--err)' : 'var(--ok)'}">${sum.toFixed(2)}</strong>` +
(hasExtension ? ' <span class="muted">(extensions allow > 1.00)</span>'
: bad ? ' — should be 1.00' : '');
wrap.appendChild(ind);
}
kids.forEach((k) => {
const row = document.createElement('div');
row.className = 'card' + (k.status === 'atomizing' ? ' shimmer' : '');
row.style.padding = '12px 16px';
const ext = k.origin === 'extended' ? '<span class="badge">extension</span>' : '';
row.innerHTML = `
<div class="spread">
<span>
${k.status === 'atomized' ? `<input type="checkbox" data-sel aria-label="Select ${esc(k.title)} for publishing" ${selected.has(k.id) ? 'checked' : ''}>` : ''}
<strong>${esc(k.title)}</strong> ${ext} ${statusBadge(k)}
</span>
<span>bounty <strong data-bounty>${k.bounty}</strong></span>
</div>
<p class="muted" style="margin:4px 0">${esc((k.description || '').slice(0, 160))}</p>
<div class="spread">
<label style="flex:1;font-weight:normal">
<span class="muted">effort <span data-coeff>${k.effortCoefficient.toFixed(2)}</span></span>
<input type="range" min="0.01" max="${k.origin === 'extended' ? 2 : 1}" step="0.01"
value="${k.effortCoefficient}" ${['atomized', 'published'].includes(k.status) ? '' : 'disabled'}
aria-label="Effort coefficient for ${esc(k.title)}">
</label>
<span>
<button class="btn small" data-act="edit">Edit</button>
<button class="btn small" data-act="extend" data-needs-atomizer>Extend</button>
${k.status === 'atomized' ? '<button class="btn small primary" data-act="publish">Publish</button>' : ''}
<button class="btn small" data-act="archive">Archive</button>
</span>
</div>`;
const slider = row.querySelector('input[type=range]');
let sliderTimer = null;
slider.addEventListener('input', () => {
row.querySelector('[data-coeff]').textContent = parseFloat(slider.value).toFixed(2);
row.querySelector('[data-bounty]').textContent = (slider.value * k.budget).toFixed(2);
clearTimeout(sliderTimer);
sliderTimer = setTimeout(async () => {
try {
const res = await api('PATCH', `/api/v1/tasks/${k.id}`, {
effortCoefficient: parseFloat(slider.value), version: k.version,
});
k.version = res.task.version;
k.effortCoefficient = res.task.effortCoefficient;
k.bounty = res.task.bounty;
render();
} catch (e) {
if (e.status === 409) { toast('Task changed elsewhere — reloading.', 'err'); load(); }
else toast(e.message, 'err');
}
}, 400);
});
const sel = row.querySelector('[data-sel]');
if (sel) {
sel.addEventListener('change', () => {
if (sel.checked) selected.add(k.id); else selected.delete(k.id);
updateBulkButton();
});
}
row.querySelector('[data-act=edit]').addEventListener('click', () => openEdit(k, false));
row.querySelector('[data-act=extend]').addEventListener('click', () => openExtend(k));
row.querySelector('[data-act=archive]').addEventListener('click', () => archive(k));
const pub = row.querySelector('[data-act=publish]');
if (pub) {
pub.addEventListener('click', async () => {
try { await api('POST', `/api/v1/tasks/${k.id}/publish`, {}); toast('Published.', 'ok'); load(); }
catch (e) { toast(e.message, 'err'); }
});
}
wrap.appendChild(row);
const grand = childrenOf(k.id);
if (grand.length) wrap.appendChild(renderChildren(k, grand, depth + 1));
});
return wrap;
}
async function archive(t) {
if (!confirm(`Archive "${t.title}"?`)) return;
try { await api('POST', `/api/v1/tasks/${t.id}/archive`, {}); load(); }
catch (e) { toast(e.message, 'err'); }
}
function updateBulkButton() {
const btn = document.getElementById('bulk-publish');
btn.textContent = `Publish selected (${selected.size})`;
btn.disabled = selected.size === 0;
}
document.getElementById('select-all').addEventListener('click', () => {
tasks.filter((t) => t.status === 'atomized').forEach((t) => selected.add(t.id));
render();
});
document.getElementById('bulk-publish').addEventListener('click', async () => {
try {
const res = await api('POST', '/api/v1/tasks/publish', { ids: [...selected] });
toast(`Published ${res.published.length} task(s)` +
(Object.keys(res.failed).length ? `, ${Object.keys(res.failed).length} failed` : '.'),
Object.keys(res.failed).length ? 'err' : 'ok');
selected.clear();
load();
} catch (e) { toast(e.message, 'err'); }
});
// ---- dialogs ----
let dialogTask = null;
const sdDlg = document.getElementById('subdivide-dialog');
function openSubdivide(t) {
dialogTask = t;
document.getElementById('subdivide-task-title').textContent = t.title;
document.getElementById('sd-replace-warning').hidden = !childrenOf(t.id).length;
sdDlg.showModal();
}
document.getElementById('sd-cancel').addEventListener('click', () => sdDlg.close());
document.getElementById('subdivide-form').addEventListener('submit', async (e) => {
e.preventDefault();
try {
await api('POST', `/api/v1/tasks/${dialogTask.id}/subdivide`, {
note: document.getElementById('sd-note').value.trim(),
constraints: {
minTasks: parseInt(document.getElementById('sd-min').value, 10) || 0,
maxTasks: parseInt(document.getElementById('sd-max').value, 10) || 0,
},
confirmReplace: !document.getElementById('sd-replace-warning').hidden,
});
sdDlg.close();
toast('Subdivision queued — results stream in shortly.', 'ok');
load();
} catch (err) { toast(err.message, 'err'); }
});
const exDlg = document.getElementById('extend-dialog');
function openExtend(t) {
dialogTask = t;
document.getElementById('extend-task-title').textContent = t.title;
exDlg.showModal();
}
document.getElementById('ex-cancel').addEventListener('click', () => exDlg.close());
document.getElementById('extend-form').addEventListener('submit', async (e) => {
e.preventDefault();
try {
await api('POST', `/api/v1/tasks/${dialogTask.id}/extend`, {
note: document.getElementById('ex-note').value.trim(),
});
exDlg.close();
document.getElementById('ex-note').value = '';
toast('Extension queued.', 'ok');
} catch (err) { toast(err.message, 'err'); }
});
const edDlg = document.getElementById('edit-dialog');
function openEdit(t, isRoot) {
dialogTask = t;
document.getElementById('ed-title').value = t.title;
document.getElementById('ed-desc').value = t.description || '';
document.getElementById('ed-ac').value = (t.acceptanceCriteria || []).join('\n');
document.getElementById('ed-budget').value = t.budget;
document.getElementById('ed-cascade-wrap').style.display = isRoot ? '' : 'none';
edDlg.showModal();
}
document.getElementById('ed-cancel').addEventListener('click', () => edDlg.close());
document.getElementById('edit-form').addEventListener('submit', async (e) => {
e.preventDefault();
try {
await api('PATCH', `/api/v1/tasks/${dialogTask.id}`, {
version: dialogTask.version,
title: document.getElementById('ed-title').value.trim(),
description: document.getElementById('ed-desc').value,
acceptanceCriteria: document.getElementById('ed-ac').value.split('\n').map((s) => s.trim()).filter(Boolean),
budget: parseFloat(document.getElementById('ed-budget').value) || 0,
cascadeBudget: document.getElementById('ed-cascade').checked &&
document.getElementById('ed-cascade-wrap').style.display !== 'none',
});
edDlg.close();
toast('Saved.', 'ok');
load();
} catch (err) {
if (err.status === 409) { toast('Task changed elsewhere — reloading.', 'err'); load(); }
else toast(err.message, 'err');
}
});
// ---- live updates ----
let reloadTimer = null;
function scheduleReload() {
clearTimeout(reloadTimer);
reloadTimer = setTimeout(load, 300);
}
subscribe('board', () => scheduleReload());
onPollFallback(load);
customerSel.addEventListener('change', load);
setInterval(checkHealth, 30000);
load();
+57
View File
@@ -0,0 +1,57 @@
// Live updates: one multiplexed WebSocket with auto-reconnect and a 15 s
// polling fallback signal (§2.2). Subscribers register per channel.
const subs = new Map(); // channel -> Set<fn>
let socket = null;
let attempts = 0;
let fallbackPolling = false;
const pollListeners = new Set();
export function subscribe(channel, fn) {
if (!subs.has(channel)) subs.set(channel, new Set());
subs.get(channel).add(fn);
ensureSocket();
return () => subs.get(channel).delete(fn);
}
// onPollFallback registers a callback invoked every 15 s while the socket is
// down, so pages can refresh their data without WS.
export function onPollFallback(fn) {
pollListeners.add(fn);
}
export function send(channel, event, data) {
if (socket && socket.readyState === WebSocket.OPEN) {
socket.send(JSON.stringify({ channel, event, data }));
}
}
let pollTimer = null;
function startPolling() {
if (fallbackPolling) return;
fallbackPolling = true;
pollTimer = setInterval(() => pollListeners.forEach((fn) => fn()), 15000);
}
function stopPolling() {
fallbackPolling = false;
if (pollTimer) clearInterval(pollTimer);
}
function ensureSocket() {
if (socket && socket.readyState <= WebSocket.OPEN) return;
const proto = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
socket = new WebSocket(`${proto}//${window.location.host}/ws`);
socket.addEventListener('open', () => { attempts = 0; stopPolling(); });
socket.addEventListener('message', (e) => {
try {
const msg = JSON.parse(e.data);
(subs.get(msg.channel) || []).forEach((fn) => fn(msg.event, msg.data));
} catch (err) { /* ignore malformed frames */ }
});
socket.addEventListener('close', () => {
startPolling();
attempts += 1;
const delay = Math.min(30000, 1000 * 2 ** attempts);
setTimeout(ensureSocket, delay);
});
socket.addEventListener('error', () => socket.close());
}
+91
View File
@@ -0,0 +1,91 @@
{{define "content"}}
<div class="spread">
<h1>Atomization Board</h1>
<span id="atomizer-health" class="badge" aria-live="polite">checking atomizer…</span>
</div>
<div class="error-box" id="error" role="alert"></div>
<div class="spread mt">
<label for="board-customer" class="muted" style="display:inline">Customer:&nbsp;
<select id="board-customer" style="width:auto"></select>
</label>
<span>
<button class="btn small" id="select-all">Select all atomized</button>
<button class="btn primary small" id="bulk-publish" disabled>Publish selected (0)</button>
</span>
</div>
<div id="board" class="stack mt"></div>
<dialog id="subdivide-dialog">
<form method="dialog" id="subdivide-form" class="stack" style="min-width:420px">
<h2>Subdivide task</h2>
<p class="muted" id="subdivide-task-title"></p>
<div class="field">
<label for="sd-note">Note for the atomizer (optional)</label>
<textarea id="sd-note" placeholder="e.g. split backend/frontend, keep DB migration separate"></textarea>
</div>
<div class="row">
<div class="field">
<label for="sd-min">Min subtasks</label>
<input type="number" id="sd-min" min="0" max="20" value="2">
</div>
<div class="field">
<label for="sd-max">Max subtasks</label>
<input type="number" id="sd-max" min="0" max="20" value="8">
</div>
</div>
<p class="hint" id="sd-replace-warning" hidden>⚠ This task already has subtasks. Re-running replaces the unpublished ones.</p>
<div class="spread">
<button class="btn" type="button" id="sd-cancel">Cancel</button>
<button class="btn primary" type="submit">Subdivide</button>
</div>
</form>
</dialog>
<dialog id="extend-dialog">
<form method="dialog" id="extend-form" class="stack" style="min-width:420px">
<h2>Extend task</h2>
<p class="muted" id="extend-task-title"></p>
<div class="field">
<label for="ex-note">Extension note (required)</label>
<textarea id="ex-note" required placeholder="describe the additional scope"></textarea>
</div>
<div class="spread">
<button class="btn" type="button" id="ex-cancel">Cancel</button>
<button class="btn primary" type="submit">Create extension</button>
</div>
</form>
</dialog>
<dialog id="edit-dialog">
<form method="dialog" id="edit-form" class="stack" style="min-width:480px">
<h2>Edit task</h2>
<div class="field">
<label for="ed-title">Title</label>
<input type="text" id="ed-title" required>
</div>
<div class="field">
<label for="ed-desc">Description</label>
<textarea id="ed-desc" rows="6"></textarea>
</div>
<div class="field">
<label for="ed-ac">Acceptance criteria (one per line)</label>
<textarea id="ed-ac" rows="4"></textarea>
</div>
<div class="row">
<div class="field">
<label for="ed-budget">Budget</label>
<input type="number" id="ed-budget" min="0" step="0.01">
</div>
<div class="field" id="ed-cascade-wrap">
<label><input type="checkbox" id="ed-cascade" checked> Apply budget to subtasks</label>
</div>
</div>
<div class="spread">
<button class="btn" type="button" id="ed-cancel">Cancel</button>
<button class="btn primary" type="submit">Save</button>
</div>
</form>
</dialog>
{{end}}