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:
@@ -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 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 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).
|
||||
|
||||
@@ -6,20 +6,25 @@ import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/signal"
|
||||
"strings"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"bountyboard/internal/atomize"
|
||||
"bountyboard/internal/auth"
|
||||
"bountyboard/internal/config"
|
||||
"bountyboard/internal/crypto"
|
||||
"bountyboard/internal/domain"
|
||||
"bountyboard/internal/files"
|
||||
httpx "bountyboard/internal/http"
|
||||
"bountyboard/internal/jobs"
|
||||
"bountyboard/internal/metrics"
|
||||
"bountyboard/internal/store"
|
||||
syncpkg "bountyboard/internal/sync"
|
||||
"bountyboard/internal/ws"
|
||||
)
|
||||
|
||||
func main() {
|
||||
@@ -69,6 +74,40 @@ func run() error {
|
||||
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,
|
||||
func(c *domain.Customer) (syncpkg.Credentials, error) {
|
||||
if c.Ticketing.CredentialsEnc == "" {
|
||||
|
||||
@@ -3,6 +3,7 @@ module bountyboard
|
||||
go 1.26
|
||||
|
||||
require (
|
||||
github.com/coder/websocket v1.8.14
|
||||
github.com/coreos/go-oidc/v3 v3.18.0
|
||||
go.mongodb.org/mongo-driver/v2 v2.6.0
|
||||
golang.org/x/crypto v0.53.0
|
||||
|
||||
@@ -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/go.mod h1:DYCf24+ncYi+XkIH97GY1+dqoRlbaSI26KVTCI9SrY4=
|
||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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]
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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{
|
||||
|
||||
@@ -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
@@ -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",
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -155,6 +155,23 @@ table.list th { color: var(--muted); font-size: 0.875rem; }
|
||||
.toast.err { border-left-color: var(--err); }
|
||||
.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 [aria-selected=true] { background: var(--accent); color: var(--accent-contrast); border-color: var(--accent); }
|
||||
|
||||
|
||||
@@ -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) => ({
|
||||
'&': '&', '<': '<', '>': '>', '"': '"', "'": ''',
|
||||
}[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();
|
||||
@@ -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());
|
||||
}
|
||||
@@ -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:
|
||||
<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}}
|
||||
Reference in New Issue
Block a user