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

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

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