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:]) }