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:
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user