70a813edfa
- 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>
142 lines
4.7 KiB
Go
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:])
|
|
}
|