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