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:
@@ -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 == "" {
|
||||
|
||||
Reference in New Issue
Block a user