Files
etalon 70a813edfa feat: bounty board, task lifecycle, AI work performer flow, notifications (phase 8)
- whitelist HTML sanitizer (stdlib tokenizer) with XSS vector tests
- developer board: pool-scoped visibility, customer/search/minBounty/sort
  filters, stale-task age badges, competing-claims visibility setting,
  saved filters in profile extras
- claims: request/withdraw (developer), approve/decline (consultant) with
  notifications to winners and losers; unassign/abandon back to board
- work tracking: start, sanitized comments with @mention notifications,
  time logging, submit for review
- review queue + review with per-AC checklist stored on the timeline;
  approve writes the immutable bountyAwards row (human assignees only)
- assign-to-AI: §5.2 job submission, HMAC-verified callback endpoint,
  idempotent by jobId, artifacts downloaded into GridFS, failure path
  keeps the task assigned with timeline + notification
- notifications API + bell with unread badge, dropdown, page, WS toasts
- pages: bounty board, my-tasks kanban, task detail (role-driven actions,
  review dialog, AI dialog), review queue, developer pool

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-12 20:11:05 +02:00

142 lines
4.7 KiB
Go

package httpx
import (
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"time"
"go.mongodb.org/mongo-driver/v2/bson"
"go.mongodb.org/mongo-driver/v2/mongo"
"bountyboard/internal/domain"
"bountyboard/internal/files"
"bountyboard/internal/workperform"
)
func (s *Server) routesWorkResults(mux *http.ServeMux) {
// Service-to-app callback: authenticated by HMAC signature, not session.
mux.HandleFunc("POST /api/v1/internal/work-results", s.handleWorkResults)
}
// handleWorkResults implements the §5.2 callback: signature-verified,
// idempotent by jobId, artifacts ingested into GridFS so review survives the
// performer container.
func (s *Server) handleWorkResults(w http.ResponseWriter, r *http.Request) {
body, err := io.ReadAll(http.MaxBytesReader(w, r.Body, 4<<20))
if err != nil {
writeError(w, http.StatusBadRequest, "bad_request", "unreadable body")
return
}
if !workperform.VerifySignature(s.cfg.WorkPerformerToken, body, r.Header.Get("X-Signature")) {
s.metrics.Inc("wp_callback_bad_signature_total", 1)
writeError(w, http.StatusUnauthorized, "bad_signature", "invalid X-Signature")
return
}
var cb workperform.Callback
if err := json.Unmarshal(body, &cb); err != nil {
writeError(w, http.StatusBadRequest, "bad_request", "invalid JSON: "+err.Error())
return
}
if cb.JobID == "" || cb.TaskID == "" {
writeError(w, http.StatusBadRequest, "bad_request", "jobId and taskId are required")
return
}
// Idempotency by jobId: first writer wins, duplicates are acknowledged
// without effect (§5.2).
_, err = s.store.DB.Collection("wpCallbacks").InsertOne(r.Context(), bson.M{
"_id": cb.JobID, "taskId": cb.TaskID, "status": cb.Status, "at": time.Now().UTC(),
})
if err != nil {
if mongo.IsDuplicateKeyError(err) {
writeJSON(w, http.StatusOK, map[string]string{"status": "duplicate_ignored"})
return
}
s.internalError(w, r, "record callback", err)
return
}
t, err := s.store.TaskByID(r.Context(), cb.TaskID)
if err != nil {
writeError(w, http.StatusNotFound, "not_found", "task not found")
return
}
if t.Assignee == nil || t.Assignee.Kind != "ai" || t.Assignee.JobID != cb.JobID {
writeError(w, http.StatusConflict, "job_mismatch", "task is not assigned to this job")
return
}
switch cb.Status {
case "succeeded":
attachments := s.ingestArtifacts(r.Context(), t, cb)
err = s.store.TransitionTask(r.Context(), t, domain.StatusInReview, "system:ai", "ai_completed",
map[string]any{"jobId": cb.JobID, "summary": cb.Summary, "log": tail(cb.Log, 4000)},
map[string]any{"attachments": attachments})
if err != nil {
s.taskTransitionError(w, err)
return
}
s.notifyUser(r.Context(), t.ConsultantID, "review_requested",
"AI finished: "+t.Title, cb.Summary, "/tasks/"+t.ID)
case "failed":
// status stays assigned (§5.2); record + notify
if err := s.store.AppendTimeline(r.Context(), t.ID, domain.TimelineEntry{
ActorID: "system:ai", Event: "ai_failed",
Data: map[string]any{"jobId": cb.JobID, "summary": cb.Summary, "log": tail(cb.Log, 4000)},
}); err != nil {
s.internalError(w, r, "record ai failure", err)
return
}
s.notifyUser(r.Context(), t.ConsultantID, "ai_failed",
"AI work failed: "+t.Title, cb.Summary, "/tasks/"+t.ID)
default:
writeError(w, http.StatusBadRequest, "bad_status", "status must be succeeded or failed")
return
}
s.Publish("board", "task.updated", map[string]any{"taskId": t.ID, "customerId": t.CustomerID})
writeJSON(w, http.StatusOK, map[string]string{"status": "accepted"})
}
// ingestArtifacts downloads every artifact URL into GridFS; failures are
// recorded but never block the review (the summary alone is reviewable).
func (s *Server) ingestArtifacts(ctx context.Context, t *domain.Task, cb workperform.Callback) []domain.TaskAttachment {
attachments := append([]domain.TaskAttachment{}, t.Attachments...)
client := &http.Client{Timeout: 60 * time.Second}
for _, a := range cb.Artifacts {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, a.URL, nil)
if err != nil {
continue
}
resp, err := client.Do(req)
if err != nil {
s.log.Warn("download artifact", "url", a.URL, "err", err)
continue
}
if resp.StatusCode != http.StatusOK {
resp.Body.Close()
s.log.Warn("download artifact", "url", a.URL, "status", resp.StatusCode)
continue
}
meta, err := s.files.Save(ctx, files.ScopeTask, "system:ai", a.Name, "", resp.Body)
resp.Body.Close()
if err != nil {
s.log.Warn("store artifact", "name", a.Name, "err", err)
continue
}
attachments = append(attachments, domain.TaskAttachment{
Name: a.Name, URL: "/files/" + meta.ID, MimeType: meta.MimeType, FileID: meta.ID,
})
}
return attachments
}
func tail(s string, n int) string {
if len(s) <= n {
return s
}
return fmt.Sprintf("…%s", s[len(s)-n:])
}