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
+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
import "context"
import (
"context"
"errors"
"net/http"
)
// Late-bound providers: these are wired by later subsystems (atomizer
// 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.
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) {
s.syncProvider = p
}
+4
View File
@@ -39,6 +39,8 @@ type Server struct {
jobsProvider StatusProvider
syncTrigger func(customerID string)
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 {
@@ -67,6 +69,8 @@ func New(cfg *config.Config, log *slog.Logger, reg *metrics.Registry, st *store.
s.routesAuth(mux)
s.routesProfile(mux)
s.routesAdmin(mux)
s.routesTasks(mux)
mux.HandleFunc("GET /ws", s.handleWS)
s.routesWeb(mux)
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 := map[string]string{
"/board": "Bounty Board",
"/my-tasks": "My Tasks",
"/consultant/board": "Atomization Board",
"/consultant/reviews": "Review Queue",
"/consultant/pool": "Developer Pool",
"/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: "rootId", 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}},
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"}}},
},
"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 {
if _, err := db.Collection(coll).Indexes().CreateMany(ctx, models); err != nil {
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.
checks := map[string]string{
"users": "email_1",
"tasks": "external.system_1_external.key_1_customerId_1",
"tasks": "uniq_external_ref",
"sessions": "expiresAt_1",
}
for coll, wantName := range checks {
@@ -44,10 +44,10 @@ func TestEnsureIndexesIsIdempotent(t *testing.T) {
for _, s := range specs {
if s["name"] == wantName {
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 s["unique"] != true || s["sparse"] != true {
t.Errorf("%s/%s: want unique sparse, got %v", coll, wantName, s)
if s["unique"] != true || s["partialFilterExpression"] == nil {
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)
}
}