mirror of
https://github.com/multica-ai/multica.git
synced 2026-07-05 13:29:44 +02:00
* feat(server): funnel/community/commercial business metrics + PostHog pairing (MUL-2949) PR3 of the Grafana board metrics split (parent MUL-2328). Adds 23 new Prometheus counter/histogram families to the PR2 BusinessMetrics collector covering the activation/community/commercial funnels, and binds every PostHog event emission to a matching metric increment so the two sides cannot drift. Funnel: signup, workspace_created, team_invite_sent/accepted, onboarding_*, cloud_waitlist_joined. Content: issue_created, chat_message_sent, agent_created, squad_created, autopilot_created, issue_executed. Runtime: runtime_registered/ready/failed/offline + ready_seconds histogram, daemon_ws_message_received_total. Autopilot: autopilot_run_started/terminal/skipped. Webhook/GitHub: webhook_delivery_total, github_event_received_total, github_pr_review_total, github_pr_merge_seconds histogram. CloudRuntime: cloudruntime_request_total + duration histogram, wired through a small RequestRecorder interface so the cloudruntime package stays decoupled from metrics. Commercial: feedback_submitted, contact_sales_submitted. The pairing helper metrics.RecordEvent(client, m, ev) emits the PostHog event AND increments the matching counter via IncForEvent dispatch, reading labels from the analytics event Properties. Every existing h.Analytics.Capture(analytics.X(...)) call site has been migrated to the helper across handler/, service/, and cmd/server/runtime_sweeper.go. Lint enforcement (server/internal/metrics/business_pairing_test.go): - TestEveryAnalyticsEventHasPrometheusCounter: every Event* constant in analytics/events.go either dispatches via IncForEvent or is in the taskMetricEvents allow-list (PR2 typed RecordTask* methods). - TestNoNakedAnalyticsCaptureInHandlersOrServices: AST-walks handler/ service/cmd-server for direct Analytics.Capture(...) calls — only service/task.go's captureTaskEvent helper is allow-listed. - TestEveryAnalyticsRecordEventTakesAnalyticsHelper: validates the third arg of every metrics.RecordEvent call is built from analytics.*. Cardinality protection: all new label values pass through fixed allow-lists in labels_pr3.go; unknown values collapse to 'other'/'unknown'/'error'. Refs: - Spec MUL-2328 / MUL-2949. - Builds on PR2 (MUL-2948) — collectors registered through the same BusinessMetrics struct, no separate Registry. - Uses PR1's taskfailure.Reason (MUL-2946) for runtime_failed's failure_reason label via NormalizeFailureReason. Out of scope: Sampler-class metrics (PR4 / MUL-2947), pr_review_total emission point (no review event handler exists yet — counter is defined, TODO to wire up when /api/webhooks/github grows pull_request_review handling). Co-authored-by: multica-agent <github@multica.ai> * fix(server): tighten PR3 review items — signup_source bucket, fill platform/kind/form_source enums, onboarding_started server emission, lint scope (MUL-2949) Addresses 张大彪's review on #3698: 1. signup_source: NormalizeSignupSource added to labels_pr3.go with a fixed allow-list bucket (direct/google/twitter/linkedin/.../other). Parses JSON cookie payload for utm_source/source/referrer fields, strips URL schemes, maps well-known hostnames to channel buckets. PostHog event still ships the raw cookie value for analytics; only the Prometheus label is bucketed. 2. Filled the unknown/other label gaps: - analytics.IssueCreated and analytics.ChatMessageSent now take a platform parameter sourced from middleware.ClientMetadataFromContext (X-Client-Platform header) at the handler. Autopilot-originated issues stamp PlatformServer. - analytics.FeedbackSubmitted now takes a kind parameter; CreateFeedback reads req.Kind (default "general") so the picker selection lights up the metric's kind label instead of long-term "other". - analytics.ContactSalesSubmitted now takes a formSource (page / onboarding / agents_page); CreateContactSales reads req.Source. The metric reads ev.Properties["form_source"] so the analytics CoreProperties.Source ("marketing_contact_sales") stays backward-compat for PostHog dashboards. 3. analytics.OnboardingStarted helper added; server-side emission lives in PatchOnboarding, fired exactly once per user on the first PATCH that carries a non-empty questionnaire payload (firstTouch logic compares prior bytes against {} / null). Frontend onboarding_started keeps firing on page open; the server emission is what guarantees the Prometheus counter exists so Grafana can be cross-checked against the PostHog funnel without depending on the SDK roundtrip. 4. business_pairing_test.go tightened: - TestNoNakedAnalyticsCaptureInHandlersOrServices now allow-lists at function granularity (just captureTaskEvent in service/task.go), not whole-file. Any future naked Capture in the same file fails CI. - TestEveryAnalyticsRecordEventTakesAnalyticsHelper now does def-use tracking inside the enclosing FuncDecl: when RecordEvent's third arg is an *ast.Ident, the test walks the function body for the assignment that defined it and confirms the RHS is an analytics.<Helper>(...) call. Bare local idents that didn't originate from analytics are now caught. 5. gofmt -w applied across the touched files; gofmt -l clean. Tests: go test ./internal/metrics/... ./internal/analytics/... pass. Pre-existing TestClaimTask_/TestWebhook_MergedPR/TestDeleteIssueByIdentifier failures on origin/main are DB-environment-dependent and not regressions from this change. Co-authored-by: multica-agent <github@multica.ai> * fix(server): normalise onboarding_started platform label + regression test (MUL-2949) Addresses 张大彪's last review nit: - IncForEvent's EventOnboardingStarted case now wraps the platform property with NormalizePlatform, matching every other platform-bearing metric. A misbehaving frontend can no longer leak a raw X-Client-Platform header value into the multica_onboarding_started_total{platform=...} series. - New labels_pr3_test.go covers every PR3 normalizer with both a happy-path value and an unknown value, asserting the unknown collapses to the documented fallback bucket. Includes a focused regression for onboarding_started: emits one event with an attacker-shaped platform string and asserts the metric only exposes web + unknown label values (no raw header bleed). - testutil.go gains a small GatherForTest helper so the regression test can pull the typed MetricFamily map without re-implementing the registry-walk dance. Co-authored-by: multica-agent <github@multica.ai> * fix(server): NormalizeTaskSource on workspace_created + document lint limitations (MUL-2949) Final review touch-ups before merge: - IncForEvent's EventWorkspaceCreated case wraps source through NormalizeTaskSource, matching the other source-bearing dispatches (issue_created, agent_created, issue_executed). Closes the last raw property leak in the dispatcher table. - business_pairing_test.go inline docstrings now spell out the two known limitations of the lint gate that 张大彪 / Eve flagged: analyticsBackedIdents matches by ident NAME (not SSA def-use, so a nested-scope shadow could pass) and isMetricsRecordEvent hard-codes the import alias set. PR description carries a Follow-ups section with the same two items so the work is visible after merge. Co-authored-by: multica-agent <github@multica.ai> --------- Co-authored-by: 魏和尚 <agent+wei@multica.ai> Co-authored-by: multica-agent <github@multica.ai>
1066 lines
35 KiB
Go
1066 lines
35 KiB
Go
package handler
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"log/slog"
|
|
"net/http"
|
|
"strconv"
|
|
"time"
|
|
|
|
"github.com/go-chi/chi/v5"
|
|
"github.com/jackc/pgx/v5/pgtype"
|
|
"github.com/multica-ai/multica/server/internal/analytics"
|
|
obsmetrics "github.com/multica-ai/multica/server/internal/metrics"
|
|
"github.com/multica-ai/multica/server/internal/service"
|
|
"github.com/multica-ai/multica/server/internal/util"
|
|
db "github.com/multica-ai/multica/server/pkg/db/generated"
|
|
"github.com/multica-ai/multica/server/pkg/protocol"
|
|
)
|
|
|
|
// ── Response types ──────────────────────────────────────────────────────────
|
|
|
|
type SquadResponse struct {
|
|
ID string `json:"id"`
|
|
WorkspaceID string `json:"workspace_id"`
|
|
Name string `json:"name"`
|
|
Description string `json:"description"`
|
|
Instructions string `json:"instructions"`
|
|
AvatarURL *string `json:"avatar_url"`
|
|
LeaderID string `json:"leader_id"`
|
|
CreatorID string `json:"creator_id"`
|
|
CreatedAt string `json:"created_at"`
|
|
UpdatedAt string `json:"updated_at"`
|
|
ArchivedAt *string `json:"archived_at"`
|
|
ArchivedBy *string `json:"archived_by"`
|
|
MemberCount int `json:"member_count"`
|
|
MemberPreview []SquadMemberPreviewResponse `json:"member_preview"`
|
|
}
|
|
|
|
type SquadMemberPreviewResponse struct {
|
|
MemberType string `json:"member_type"`
|
|
MemberID string `json:"member_id"`
|
|
Role string `json:"role"`
|
|
}
|
|
|
|
type squadMemberSummary struct {
|
|
count int
|
|
preview []SquadMemberPreviewResponse
|
|
}
|
|
|
|
type SquadMemberResponse struct {
|
|
ID string `json:"id"`
|
|
SquadID string `json:"squad_id"`
|
|
MemberType string `json:"member_type"`
|
|
MemberID string `json:"member_id"`
|
|
Role string `json:"role"`
|
|
CreatedAt string `json:"created_at"`
|
|
}
|
|
|
|
// ── Converters ──────────────────────────────────────────────────────────────
|
|
|
|
func squadToResponse(s db.Squad) SquadResponse {
|
|
return SquadResponse{
|
|
ID: uuidToString(s.ID),
|
|
WorkspaceID: uuidToString(s.WorkspaceID),
|
|
Name: s.Name,
|
|
Description: s.Description,
|
|
Instructions: s.Instructions,
|
|
AvatarURL: textToPtr(s.AvatarUrl),
|
|
LeaderID: uuidToString(s.LeaderID),
|
|
CreatorID: uuidToString(s.CreatorID),
|
|
CreatedAt: timestampToString(s.CreatedAt),
|
|
UpdatedAt: timestampToString(s.UpdatedAt),
|
|
ArchivedAt: timestampToPtr(s.ArchivedAt),
|
|
ArchivedBy: uuidToPtr(s.ArchivedBy),
|
|
MemberPreview: []SquadMemberPreviewResponse{},
|
|
}
|
|
}
|
|
|
|
func squadMemberToResponse(m db.SquadMember) SquadMemberResponse {
|
|
return SquadMemberResponse{
|
|
ID: uuidToString(m.ID),
|
|
SquadID: uuidToString(m.SquadID),
|
|
MemberType: m.MemberType,
|
|
MemberID: uuidToString(m.MemberID),
|
|
Role: m.Role,
|
|
CreatedAt: timestampToString(m.CreatedAt),
|
|
}
|
|
}
|
|
|
|
func addSquadMemberPreview(summary *squadMemberSummary, memberType string, memberID pgtype.UUID, role string) {
|
|
summary.count++
|
|
if len(summary.preview) >= 3 {
|
|
return
|
|
}
|
|
summary.preview = append(summary.preview, SquadMemberPreviewResponse{
|
|
MemberType: memberType,
|
|
MemberID: uuidToString(memberID),
|
|
Role: role,
|
|
})
|
|
}
|
|
|
|
func applySquadMemberSummary(resp *SquadResponse, summary *squadMemberSummary) {
|
|
if summary == nil {
|
|
return
|
|
}
|
|
resp.MemberCount = summary.count
|
|
resp.MemberPreview = summary.preview
|
|
}
|
|
|
|
// ── Helpers ─────────────────────────────────────────────────────────────────
|
|
|
|
// loadSquadInWorkspace loads a squad scoped to the current workspace.
|
|
func (h *Handler) loadSquadInWorkspace(w http.ResponseWriter, r *http.Request) (db.Squad, string, bool) {
|
|
workspaceID := workspaceIDFromURL(r, "workspaceId")
|
|
squadID := chi.URLParam(r, "id")
|
|
squadUUID, ok := parseUUIDOrBadRequest(w, squadID, "squad id")
|
|
if !ok {
|
|
return db.Squad{}, "", false
|
|
}
|
|
wsUUID, ok := parseUUIDOrBadRequest(w, workspaceID, "workspace_id")
|
|
if !ok {
|
|
return db.Squad{}, "", false
|
|
}
|
|
squad, err := h.Queries.GetSquadInWorkspace(r.Context(), db.GetSquadInWorkspaceParams{
|
|
ID: squadUUID,
|
|
WorkspaceID: wsUUID,
|
|
})
|
|
if err != nil {
|
|
writeError(w, http.StatusNotFound, "squad not found")
|
|
return db.Squad{}, "", false
|
|
}
|
|
return squad, workspaceID, true
|
|
}
|
|
|
|
func (h *Handler) loadSquadMemberSummary(ctx context.Context, squadID pgtype.UUID) (*squadMemberSummary, error) {
|
|
rows, err := h.Queries.ListSquadMemberPreviewRowsBySquad(ctx, squadID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
summary := &squadMemberSummary{}
|
|
for _, row := range rows {
|
|
addSquadMemberPreview(summary, row.MemberType, row.MemberID, row.Role)
|
|
}
|
|
return summary, nil
|
|
}
|
|
|
|
func (h *Handler) squadToResponseWithPreview(ctx context.Context, squad db.Squad) (SquadResponse, error) {
|
|
resp := squadToResponse(squad)
|
|
summary, err := h.loadSquadMemberSummary(ctx, squad.ID)
|
|
if err != nil {
|
|
return resp, err
|
|
}
|
|
applySquadMemberSummary(&resp, summary)
|
|
return resp, nil
|
|
}
|
|
|
|
// ── Handlers ────────────────────────────────────────────────────────────────
|
|
|
|
func (h *Handler) ListSquads(w http.ResponseWriter, r *http.Request) {
|
|
workspaceID := workspaceIDFromURL(r, "workspaceId")
|
|
wsUUID, ok := parseUUIDOrBadRequest(w, workspaceID, "workspace_id")
|
|
if !ok {
|
|
return
|
|
}
|
|
squads, err := h.Queries.ListSquads(r.Context(), wsUUID)
|
|
if err != nil {
|
|
writeError(w, http.StatusInternalServerError, "failed to list squads")
|
|
return
|
|
}
|
|
|
|
previewRows, err := h.Queries.ListSquadMemberPreviewRows(r.Context(), wsUUID)
|
|
if err != nil {
|
|
writeError(w, http.StatusInternalServerError, "failed to list squad member preview")
|
|
return
|
|
}
|
|
summaries := make(map[string]*squadMemberSummary, len(squads))
|
|
for _, row := range previewRows {
|
|
squadID := uuidToString(row.SquadID)
|
|
summary := summaries[squadID]
|
|
if summary == nil {
|
|
summary = &squadMemberSummary{}
|
|
summaries[squadID] = summary
|
|
}
|
|
addSquadMemberPreview(summary, row.MemberType, row.MemberID, row.Role)
|
|
}
|
|
|
|
resp := make([]SquadResponse, len(squads))
|
|
for i, s := range squads {
|
|
resp[i] = squadToResponse(s)
|
|
applySquadMemberSummary(&resp[i], summaries[uuidToString(s.ID)])
|
|
}
|
|
writeJSON(w, http.StatusOK, resp)
|
|
}
|
|
|
|
func (h *Handler) CreateSquad(w http.ResponseWriter, r *http.Request) {
|
|
workspaceID := workspaceIDFromURL(r, "workspaceId")
|
|
member, ok := h.requireWorkspaceRole(w, r, workspaceID, "workspace not found", "owner", "admin")
|
|
if !ok {
|
|
return
|
|
}
|
|
|
|
var req struct {
|
|
Name string `json:"name"`
|
|
Description string `json:"description"`
|
|
LeaderID string `json:"leader_id"`
|
|
AvatarURL *string `json:"avatar_url"`
|
|
}
|
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
|
writeError(w, http.StatusBadRequest, "invalid request body")
|
|
return
|
|
}
|
|
if req.Name == "" {
|
|
writeError(w, http.StatusBadRequest, "name is required")
|
|
return
|
|
}
|
|
if req.LeaderID == "" {
|
|
writeError(w, http.StatusBadRequest, "leader_id is required")
|
|
return
|
|
}
|
|
|
|
leaderUUID, ok := parseUUIDOrBadRequest(w, req.LeaderID, "leader_id")
|
|
if !ok {
|
|
return
|
|
}
|
|
wsUUID, ok := parseUUIDOrBadRequest(w, workspaceID, "workspace_id")
|
|
if !ok {
|
|
return
|
|
}
|
|
|
|
// Validate leader is an agent in this workspace.
|
|
_, err := h.Queries.GetAgentInWorkspace(r.Context(), db.GetAgentInWorkspaceParams{
|
|
ID: leaderUUID,
|
|
WorkspaceID: wsUUID,
|
|
})
|
|
if err != nil {
|
|
writeError(w, http.StatusBadRequest, "leader must be a valid agent in this workspace")
|
|
return
|
|
}
|
|
|
|
avatarURL := pgtype.Text{}
|
|
if req.AvatarURL != nil {
|
|
avatarURL = pgtype.Text{String: *req.AvatarURL, Valid: true}
|
|
}
|
|
|
|
squad, err := h.Queries.CreateSquad(r.Context(), db.CreateSquadParams{
|
|
WorkspaceID: wsUUID,
|
|
Name: req.Name,
|
|
Description: req.Description,
|
|
LeaderID: leaderUUID,
|
|
CreatorID: member.UserID,
|
|
AvatarUrl: avatarURL,
|
|
})
|
|
if err != nil {
|
|
writeError(w, http.StatusInternalServerError, "failed to create squad")
|
|
return
|
|
}
|
|
|
|
// Auto-add leader as a member with role "leader".
|
|
h.Queries.AddSquadMember(r.Context(), db.AddSquadMemberParams{
|
|
SquadID: squad.ID,
|
|
MemberType: "agent",
|
|
MemberID: leaderUUID,
|
|
Role: "leader",
|
|
})
|
|
|
|
resp, err := h.squadToResponseWithPreview(r.Context(), squad)
|
|
if err != nil {
|
|
writeError(w, http.StatusInternalServerError, "failed to load squad member preview")
|
|
return
|
|
}
|
|
h.publish(protocol.EventSquadCreated, workspaceID, "member", uuidToString(member.UserID), map[string]any{"squad": resp})
|
|
obsmetrics.RecordEvent(h.Analytics, h.Metrics, analytics.SquadCreated(
|
|
uuidToString(member.UserID),
|
|
workspaceID,
|
|
uuidToString(squad.ID),
|
|
1,
|
|
))
|
|
writeJSON(w, http.StatusCreated, resp)
|
|
}
|
|
|
|
func (h *Handler) GetSquad(w http.ResponseWriter, r *http.Request) {
|
|
squad, _, ok := h.loadSquadInWorkspace(w, r)
|
|
if !ok {
|
|
return
|
|
}
|
|
resp, err := h.squadToResponseWithPreview(r.Context(), squad)
|
|
if err != nil {
|
|
writeError(w, http.StatusInternalServerError, "failed to load squad member preview")
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusOK, resp)
|
|
}
|
|
|
|
func (h *Handler) UpdateSquad(w http.ResponseWriter, r *http.Request) {
|
|
workspaceID := workspaceIDFromURL(r, "workspaceId")
|
|
if _, ok := h.requireWorkspaceRole(w, r, workspaceID, "workspace not found", "owner", "admin"); !ok {
|
|
return
|
|
}
|
|
|
|
squad, _, ok := h.loadSquadInWorkspace(w, r)
|
|
if !ok {
|
|
return
|
|
}
|
|
wsUUID, ok := parseUUIDOrBadRequest(w, workspaceID, "workspace_id")
|
|
if !ok {
|
|
return
|
|
}
|
|
|
|
var req struct {
|
|
Name *string `json:"name"`
|
|
Description *string `json:"description"`
|
|
Instructions *string `json:"instructions"`
|
|
LeaderID *string `json:"leader_id"`
|
|
AvatarURL *string `json:"avatar_url"`
|
|
}
|
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
|
writeError(w, http.StatusBadRequest, "invalid request body")
|
|
return
|
|
}
|
|
|
|
params := db.UpdateSquadParams{ID: squad.ID}
|
|
if req.Name != nil {
|
|
params.Name = pgtype.Text{String: *req.Name, Valid: true}
|
|
}
|
|
if req.Description != nil {
|
|
params.Description = pgtype.Text{String: *req.Description, Valid: true}
|
|
}
|
|
if req.Instructions != nil {
|
|
params.Instructions = pgtype.Text{String: *req.Instructions, Valid: true}
|
|
}
|
|
if req.AvatarURL != nil {
|
|
params.AvatarUrl = pgtype.Text{String: *req.AvatarURL, Valid: true}
|
|
}
|
|
if req.LeaderID != nil {
|
|
lid, ok := parseUUIDOrBadRequest(w, *req.LeaderID, "leader_id")
|
|
if !ok {
|
|
return
|
|
}
|
|
// Validate new leader is an agent in workspace.
|
|
if _, err := h.Queries.GetAgentInWorkspace(r.Context(), db.GetAgentInWorkspaceParams{
|
|
ID: lid, WorkspaceID: wsUUID,
|
|
}); err != nil {
|
|
writeError(w, http.StatusBadRequest, "leader must be a valid agent in this workspace")
|
|
return
|
|
}
|
|
// Ensure new leader is a squad member; auto-add if not.
|
|
isMember, _ := h.Queries.IsSquadMember(r.Context(), db.IsSquadMemberParams{
|
|
SquadID: squad.ID, MemberType: "agent", MemberID: lid,
|
|
})
|
|
if !isMember {
|
|
h.Queries.AddSquadMember(r.Context(), db.AddSquadMemberParams{
|
|
SquadID: squad.ID, MemberType: "agent", MemberID: lid, Role: "leader",
|
|
})
|
|
}
|
|
params.LeaderID = lid
|
|
}
|
|
|
|
updated, err := h.Queries.UpdateSquad(r.Context(), params)
|
|
if err != nil {
|
|
writeError(w, http.StatusInternalServerError, "failed to update squad")
|
|
return
|
|
}
|
|
|
|
resp, err := h.squadToResponseWithPreview(r.Context(), updated)
|
|
if err != nil {
|
|
writeError(w, http.StatusInternalServerError, "failed to load squad member preview")
|
|
return
|
|
}
|
|
h.publish(protocol.EventSquadUpdated, workspaceID, "member", requestUserID(r), map[string]any{"squad": resp})
|
|
writeJSON(w, http.StatusOK, resp)
|
|
}
|
|
|
|
func (h *Handler) DeleteSquad(w http.ResponseWriter, r *http.Request) {
|
|
workspaceID := workspaceIDFromURL(r, "workspaceId")
|
|
if _, ok := h.requireWorkspaceRole(w, r, workspaceID, "workspace not found", "owner", "admin"); !ok {
|
|
return
|
|
}
|
|
|
|
squad, _, ok := h.loadSquadInWorkspace(w, r)
|
|
if !ok {
|
|
return
|
|
}
|
|
|
|
if squad.ArchivedAt.Valid {
|
|
writeError(w, http.StatusBadRequest, "squad is already archived")
|
|
return
|
|
}
|
|
|
|
// Transfer issues assigned to this squad to the leader agent.
|
|
if err := h.Queries.TransferSquadAssignees(r.Context(), db.TransferSquadAssigneesParams{
|
|
AssigneeID: squad.ID,
|
|
AssigneeID_2: squad.LeaderID,
|
|
}); err != nil {
|
|
slog.Warn("transfer squad assignees failed", "squad_id", uuidToString(squad.ID), "error", err)
|
|
}
|
|
|
|
// Mirror the issue-assignee transfer for autopilots that target this
|
|
// squad. Without this, autopilot.assignee_id would still point at the
|
|
// archived squad row and every subsequent dispatch would skip with
|
|
// "assignee squad is archived" — visible to ops but useless to the
|
|
// owner. Rewriting to the leader keeps the autopilot semantics
|
|
// unchanged (Path A from MUL-2429 is leader-only execution anyway).
|
|
if err := h.Queries.TransferSquadAutopilotsToLeader(r.Context(), db.TransferSquadAutopilotsToLeaderParams{
|
|
AssigneeID: squad.ID,
|
|
AssigneeID_2: squad.LeaderID,
|
|
}); err != nil {
|
|
slog.Warn("transfer squad autopilots failed", "squad_id", uuidToString(squad.ID), "error", err)
|
|
}
|
|
|
|
userID := requestUserID(r)
|
|
userUUID, _ := parseUUIDOrBadRequest(w, userID, "user_id")
|
|
|
|
if _, err := h.Queries.ArchiveSquad(r.Context(), db.ArchiveSquadParams{
|
|
ID: squad.ID,
|
|
ArchivedBy: userUUID,
|
|
}); err != nil {
|
|
writeError(w, http.StatusInternalServerError, "failed to archive squad")
|
|
return
|
|
}
|
|
|
|
h.publish(protocol.EventSquadDeleted, workspaceID, "member", userID, map[string]any{
|
|
"squad_id": uuidToString(squad.ID),
|
|
"leader_id": uuidToString(squad.LeaderID),
|
|
})
|
|
w.WriteHeader(http.StatusNoContent)
|
|
}
|
|
|
|
// ── Squad Members ───────────────────────────────────────────────────────────
|
|
|
|
func (h *Handler) ListSquadMembers(w http.ResponseWriter, r *http.Request) {
|
|
squad, _, ok := h.loadSquadInWorkspace(w, r)
|
|
if !ok {
|
|
return
|
|
}
|
|
members, err := h.Queries.ListSquadMembers(r.Context(), squad.ID)
|
|
if err != nil {
|
|
writeError(w, http.StatusInternalServerError, "failed to list squad members")
|
|
return
|
|
}
|
|
resp := make([]SquadMemberResponse, len(members))
|
|
for i, m := range members {
|
|
resp[i] = squadMemberToResponse(m)
|
|
}
|
|
writeJSON(w, http.StatusOK, resp)
|
|
}
|
|
|
|
// ── Squad Member Status ────────────────────────────────────────────────────
|
|
|
|
// SquadMemberStatus is the per-member entry in the squad member status
|
|
// response. Agent members carry a derived working/idle/offline/unstable
|
|
// status plus any active issues; human members are returned with member_type
|
|
// only so the front-end can render them in the same list without
|
|
// reordering.
|
|
type SquadMemberStatusResponse struct {
|
|
MemberType string `json:"member_type"`
|
|
MemberID string `json:"member_id"`
|
|
Status *string `json:"status"`
|
|
ActiveIssues []SquadActiveIssueBrief `json:"active_issues"`
|
|
LastActiveAt *string `json:"last_active_at"`
|
|
}
|
|
|
|
type SquadActiveIssueBrief struct {
|
|
IssueID string `json:"issue_id"`
|
|
Identifier string `json:"identifier"`
|
|
Title string `json:"title"`
|
|
IssueStatus string `json:"issue_status"`
|
|
}
|
|
|
|
type SquadMemberStatusListResponse struct {
|
|
Members []SquadMemberStatusResponse `json:"members"`
|
|
}
|
|
|
|
// deriveSquadMemberStatus collapses runtime + task signals into the five
|
|
// status buckets used by the squad UI. Mirrors the workload+availability
|
|
// split in packages/core/agents/derive-presence.ts: working wins over
|
|
// runtime health (an agent that is in the middle of dispatched/running
|
|
// work counts as working even if the runtime briefly drops), then
|
|
// availability buckets decide between idle / unstable / offline.
|
|
//
|
|
// Thresholds match deriveRuntimeHealth: any offline runtime whose
|
|
// last_seen_at is within the last 5 minutes is reported as "unstable" so
|
|
// the squad UI surfaces transient drops the same way the agent dot does.
|
|
//
|
|
// Archived agents always report `archived` regardless of any leftover
|
|
// runtime row or task — they should appear in the list but never look
|
|
// like they're still working or merely offline (a leftover online
|
|
// runtime row would otherwise read as "offline" and hide the fact that
|
|
// the agent has been archived). Per the RFC decision (see MUL-2319), we
|
|
// surface archived agents in this endpoint rather than filtering them
|
|
// out in the SQL.
|
|
func deriveSquadMemberStatus(
|
|
archived bool,
|
|
runtimeStatus pgtype.Text,
|
|
lastSeen pgtype.Timestamptz,
|
|
hasActiveTask bool,
|
|
now time.Time,
|
|
) string {
|
|
if archived {
|
|
return "archived"
|
|
}
|
|
if hasActiveTask {
|
|
return "working"
|
|
}
|
|
if !runtimeStatus.Valid {
|
|
return "offline"
|
|
}
|
|
if runtimeStatus.String == "online" {
|
|
return "idle"
|
|
}
|
|
if !lastSeen.Valid {
|
|
return "offline"
|
|
}
|
|
if now.Sub(lastSeen.Time) < 5*time.Minute {
|
|
return "unstable"
|
|
}
|
|
return "offline"
|
|
}
|
|
|
|
// ListSquadMemberStatus returns one entry per squad member with derived
|
|
// status, the issues each agent member is currently running, and the last
|
|
// observed runtime activity. The endpoint is read-only and inherits the
|
|
// workspace-membership guard from the route middleware — any member of the
|
|
// workspace can read it.
|
|
func (h *Handler) ListSquadMemberStatus(w http.ResponseWriter, r *http.Request) {
|
|
squad, _, ok := h.loadSquadInWorkspace(w, r)
|
|
if !ok {
|
|
return
|
|
}
|
|
|
|
rows, err := h.Queries.ListSquadMemberStatusRows(r.Context(), squad.ID)
|
|
if err != nil {
|
|
writeError(w, http.StatusInternalServerError, "failed to list squad member status")
|
|
return
|
|
}
|
|
|
|
prefix := h.getIssuePrefix(r.Context(), squad.WorkspaceID)
|
|
now := time.Now()
|
|
|
|
// Group rows by member_id while preserving the SQL ORDER BY (squad_member
|
|
// insertion order). One member may appear in multiple rows when they have
|
|
// more than one active task.
|
|
type memberAcc struct {
|
|
response SquadMemberStatusResponse
|
|
archived bool
|
|
hasActiveTask bool
|
|
runtimeStatus pgtype.Text
|
|
runtimeSeenAt pgtype.Timestamptz
|
|
latestActiveAt pgtype.Timestamptz
|
|
}
|
|
order := make([]string, 0, len(rows))
|
|
acc := make(map[string]*memberAcc, len(rows))
|
|
|
|
for _, row := range rows {
|
|
memberID := uuidToString(row.MemberID)
|
|
entry, exists := acc[memberID]
|
|
if !exists {
|
|
entry = &memberAcc{
|
|
response: SquadMemberStatusResponse{
|
|
MemberType: row.MemberType,
|
|
MemberID: memberID,
|
|
ActiveIssues: []SquadActiveIssueBrief{},
|
|
},
|
|
archived: row.AgentArchivedAt.Valid,
|
|
runtimeStatus: row.RuntimeStatus,
|
|
runtimeSeenAt: row.RuntimeLastSeenAt,
|
|
}
|
|
acc[memberID] = entry
|
|
order = append(order, memberID)
|
|
}
|
|
|
|
if row.MemberType != "agent" {
|
|
continue
|
|
}
|
|
|
|
// A dispatched/running task occupies an agent slot even when it
|
|
// has no associated issue (chat / quick-create tasks set
|
|
// agent_task_queue.issue_id = NULL). The `working` bucket is
|
|
// defined by task presence, not by whether we can render an
|
|
// issue link, so flag the agent here regardless of issue_id.
|
|
if row.TaskID.Valid {
|
|
entry.hasActiveTask = true
|
|
|
|
if row.TaskIssueID.Valid {
|
|
brief := SquadActiveIssueBrief{
|
|
IssueID: uuidToString(row.TaskIssueID),
|
|
Identifier: prefix + "-" + strconv.Itoa(int(row.IssueNumber.Int32)),
|
|
Title: row.IssueTitle.String,
|
|
IssueStatus: func() string {
|
|
if row.IssueStatus.Valid {
|
|
return row.IssueStatus.String
|
|
}
|
|
return ""
|
|
}(),
|
|
}
|
|
entry.response.ActiveIssues = append(entry.response.ActiveIssues, brief)
|
|
}
|
|
|
|
if row.TaskDispatchedAt.Valid && (!entry.latestActiveAt.Valid ||
|
|
row.TaskDispatchedAt.Time.After(entry.latestActiveAt.Time)) {
|
|
entry.latestActiveAt = row.TaskDispatchedAt
|
|
}
|
|
}
|
|
}
|
|
|
|
resp := SquadMemberStatusListResponse{
|
|
Members: make([]SquadMemberStatusResponse, 0, len(order)),
|
|
}
|
|
for _, id := range order {
|
|
entry := acc[id]
|
|
if entry.response.MemberType == "agent" {
|
|
status := deriveSquadMemberStatus(
|
|
entry.archived,
|
|
entry.runtimeStatus,
|
|
entry.runtimeSeenAt,
|
|
entry.hasActiveTask,
|
|
now,
|
|
)
|
|
entry.response.Status = &status
|
|
// last_active_at prefers the freshest active-task dispatch
|
|
// over the runtime heartbeat: a working agent should not
|
|
// look stale because the runtime heartbeat is a few seconds
|
|
// behind. Falls back to runtime last_seen_at otherwise.
|
|
if entry.latestActiveAt.Valid {
|
|
entry.response.LastActiveAt = timestampToPtr(entry.latestActiveAt)
|
|
} else if entry.runtimeSeenAt.Valid {
|
|
entry.response.LastActiveAt = timestampToPtr(entry.runtimeSeenAt)
|
|
}
|
|
}
|
|
resp.Members = append(resp.Members, entry.response)
|
|
}
|
|
|
|
writeJSON(w, http.StatusOK, resp)
|
|
}
|
|
|
|
func (h *Handler) AddSquadMember(w http.ResponseWriter, r *http.Request) {
|
|
workspaceID := workspaceIDFromURL(r, "workspaceId")
|
|
if _, ok := h.requireWorkspaceRole(w, r, workspaceID, "workspace not found", "owner", "admin"); !ok {
|
|
return
|
|
}
|
|
|
|
squad, _, ok := h.loadSquadInWorkspace(w, r)
|
|
if !ok {
|
|
return
|
|
}
|
|
wsUUID, ok := parseUUIDOrBadRequest(w, workspaceID, "workspace_id")
|
|
if !ok {
|
|
return
|
|
}
|
|
|
|
var req struct {
|
|
MemberType string `json:"member_type"`
|
|
MemberID string `json:"member_id"`
|
|
Role string `json:"role"`
|
|
}
|
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
|
writeError(w, http.StatusBadRequest, "invalid request body")
|
|
return
|
|
}
|
|
if req.MemberType != "agent" && req.MemberType != "member" {
|
|
writeError(w, http.StatusBadRequest, "member_type must be 'agent' or 'member'")
|
|
return
|
|
}
|
|
if req.MemberID == "" {
|
|
writeError(w, http.StatusBadRequest, "member_id is required")
|
|
return
|
|
}
|
|
|
|
memberUUID, ok := parseUUIDOrBadRequest(w, req.MemberID, "member_id")
|
|
if !ok {
|
|
return
|
|
}
|
|
|
|
// Validate the member belongs to this workspace.
|
|
if req.MemberType == "agent" {
|
|
if _, err := h.Queries.GetAgentInWorkspace(r.Context(), db.GetAgentInWorkspaceParams{
|
|
ID: memberUUID, WorkspaceID: wsUUID,
|
|
}); err != nil {
|
|
writeError(w, http.StatusBadRequest, "agent not found in this workspace")
|
|
return
|
|
}
|
|
} else {
|
|
if _, err := h.Queries.GetMemberByUserAndWorkspace(r.Context(), db.GetMemberByUserAndWorkspaceParams{
|
|
UserID: memberUUID, WorkspaceID: wsUUID,
|
|
}); err != nil {
|
|
writeError(w, http.StatusBadRequest, "member not found in this workspace")
|
|
return
|
|
}
|
|
}
|
|
|
|
sm, err := h.Queries.AddSquadMember(r.Context(), db.AddSquadMemberParams{
|
|
SquadID: squad.ID,
|
|
MemberType: req.MemberType,
|
|
MemberID: memberUUID,
|
|
Role: req.Role,
|
|
})
|
|
if err != nil {
|
|
if isUniqueViolation(err) {
|
|
writeError(w, http.StatusConflict, "member already in squad")
|
|
return
|
|
}
|
|
writeError(w, http.StatusInternalServerError, "failed to add squad member")
|
|
return
|
|
}
|
|
|
|
writeJSON(w, http.StatusCreated, squadMemberToResponse(sm))
|
|
h.publish(protocol.EventSquadUpdated, workspaceID, "member", requestUserID(r), map[string]any{
|
|
"squad_id": uuidToString(squad.ID),
|
|
})
|
|
}
|
|
|
|
func (h *Handler) RemoveSquadMember(w http.ResponseWriter, r *http.Request) {
|
|
workspaceID := workspaceIDFromURL(r, "workspaceId")
|
|
if _, ok := h.requireWorkspaceRole(w, r, workspaceID, "workspace not found", "owner", "admin"); !ok {
|
|
return
|
|
}
|
|
|
|
squad, _, ok := h.loadSquadInWorkspace(w, r)
|
|
if !ok {
|
|
return
|
|
}
|
|
|
|
var req struct {
|
|
MemberType string `json:"member_type"`
|
|
MemberID string `json:"member_id"`
|
|
}
|
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
|
writeError(w, http.StatusBadRequest, "invalid request body")
|
|
return
|
|
}
|
|
|
|
memberUUID, ok := parseUUIDOrBadRequest(w, req.MemberID, "member_id")
|
|
if !ok {
|
|
return
|
|
}
|
|
|
|
// Prevent removing the leader.
|
|
if req.MemberType == "agent" && uuidToString(squad.LeaderID) == req.MemberID {
|
|
writeError(w, http.StatusBadRequest, "cannot remove the squad leader; change leader first")
|
|
return
|
|
}
|
|
|
|
rows, err := h.Queries.RemoveSquadMember(r.Context(), db.RemoveSquadMemberParams{
|
|
SquadID: squad.ID,
|
|
MemberType: req.MemberType,
|
|
MemberID: memberUUID,
|
|
})
|
|
if err != nil {
|
|
writeError(w, http.StatusInternalServerError, "failed to remove squad member")
|
|
return
|
|
}
|
|
if rows == 0 {
|
|
writeError(w, http.StatusNotFound, "squad member not found")
|
|
return
|
|
}
|
|
|
|
h.publish(protocol.EventSquadUpdated, workspaceID, "member", requestUserID(r), map[string]any{
|
|
"squad_id": uuidToString(squad.ID),
|
|
})
|
|
w.WriteHeader(http.StatusNoContent)
|
|
}
|
|
|
|
func (h *Handler) UpdateSquadMemberRole(w http.ResponseWriter, r *http.Request) {
|
|
workspaceID := workspaceIDFromURL(r, "workspaceId")
|
|
if _, ok := h.requireWorkspaceRole(w, r, workspaceID, "workspace not found", "owner", "admin"); !ok {
|
|
return
|
|
}
|
|
|
|
squad, _, ok := h.loadSquadInWorkspace(w, r)
|
|
if !ok {
|
|
return
|
|
}
|
|
|
|
var req struct {
|
|
MemberType string `json:"member_type"`
|
|
MemberID string `json:"member_id"`
|
|
Role string `json:"role"`
|
|
}
|
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
|
writeError(w, http.StatusBadRequest, "invalid request body")
|
|
return
|
|
}
|
|
|
|
memberUUID, ok := parseUUIDOrBadRequest(w, req.MemberID, "member_id")
|
|
if !ok {
|
|
return
|
|
}
|
|
|
|
sm, err := h.Queries.UpdateSquadMemberRole(r.Context(), db.UpdateSquadMemberRoleParams{
|
|
SquadID: squad.ID,
|
|
MemberType: req.MemberType,
|
|
MemberID: memberUUID,
|
|
Role: req.Role,
|
|
})
|
|
if err != nil {
|
|
writeError(w, http.StatusNotFound, "squad member not found")
|
|
return
|
|
}
|
|
|
|
h.publish(protocol.EventSquadUpdated, workspaceID, "member", requestUserID(r), map[string]any{
|
|
"squad_id": uuidToString(squad.ID),
|
|
})
|
|
writeJSON(w, http.StatusOK, squadMemberToResponse(sm))
|
|
}
|
|
|
|
// ── Squad Leader Evaluation ──────────────────────────────────────────────────
|
|
|
|
// RecordSquadLeaderEvaluation records a squad leader's evaluation decision
|
|
// into the unified activity_log. Called by the leader agent via CLI after
|
|
// each trigger to record whether it took action, stayed silent, or failed.
|
|
func (h *Handler) RecordSquadLeaderEvaluation(w http.ResponseWriter, r *http.Request) {
|
|
issue, ok := h.loadIssueForUser(w, r, chi.URLParam(r, "id"))
|
|
if !ok {
|
|
return
|
|
}
|
|
|
|
var req struct {
|
|
Outcome string `json:"outcome"` // action | no_action | failed
|
|
Reason string `json:"reason"` // short explanation from leader
|
|
}
|
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
|
writeError(w, http.StatusBadRequest, "invalid request body")
|
|
return
|
|
}
|
|
|
|
if req.Outcome != "action" && req.Outcome != "no_action" && req.Outcome != "failed" {
|
|
writeError(w, http.StatusBadRequest, "outcome must be 'action', 'no_action', or 'failed'")
|
|
return
|
|
}
|
|
|
|
// The issue must be assigned to a squad.
|
|
if !issue.AssigneeType.Valid || issue.AssigneeType.String != "squad" || !issue.AssigneeID.Valid {
|
|
writeError(w, http.StatusBadRequest, "issue is not assigned to a squad")
|
|
return
|
|
}
|
|
|
|
squad, err := h.Queries.GetSquadInWorkspace(r.Context(), db.GetSquadInWorkspaceParams{
|
|
ID: issue.AssigneeID,
|
|
WorkspaceID: issue.WorkspaceID,
|
|
})
|
|
if err != nil {
|
|
writeError(w, http.StatusNotFound, "squad not found")
|
|
return
|
|
}
|
|
|
|
// Security: only the squad leader agent can record evaluations.
|
|
workspaceID := uuidToString(issue.WorkspaceID)
|
|
userID := requestUserID(r)
|
|
actorType, actorID := h.resolveActor(r, userID, workspaceID)
|
|
if actorType != "agent" || actorID != uuidToString(squad.LeaderID) {
|
|
writeError(w, http.StatusForbidden, "only the squad leader agent can record evaluations")
|
|
return
|
|
}
|
|
|
|
taskID := r.Header.Get("X-Task-ID")
|
|
taskUUID, ok := parseUUIDOrBadRequest(w, taskID, "task id")
|
|
if !ok {
|
|
return
|
|
}
|
|
task, err := h.Queries.GetAgentTask(r.Context(), taskUUID)
|
|
if err != nil || !task.IssueID.Valid || uuidToString(task.IssueID) != uuidToString(issue.ID) {
|
|
writeError(w, http.StatusBadRequest, "task does not belong to issue")
|
|
return
|
|
}
|
|
|
|
details, _ := json.Marshal(map[string]string{
|
|
"squad_id": uuidToString(squad.ID),
|
|
"task_id": util.UUIDToString(taskUUID),
|
|
"outcome": req.Outcome,
|
|
"reason": req.Reason,
|
|
})
|
|
|
|
activity, err := h.Queries.CreateActivity(r.Context(), db.CreateActivityParams{
|
|
WorkspaceID: issue.WorkspaceID,
|
|
IssueID: issue.ID,
|
|
ActorType: pgtype.Text{String: "agent", Valid: true},
|
|
ActorID: squad.LeaderID,
|
|
Action: "squad_leader_evaluated",
|
|
Details: details,
|
|
})
|
|
if err != nil {
|
|
writeError(w, http.StatusInternalServerError, "failed to record evaluation")
|
|
return
|
|
}
|
|
|
|
h.publish(protocol.EventActivityCreated, uuidToString(issue.WorkspaceID), "agent", actorID, map[string]any{
|
|
"issue_id": uuidToString(issue.ID),
|
|
"entry": map[string]any{
|
|
"type": "activity",
|
|
"id": uuidToString(activity.ID),
|
|
"actor_type": "agent",
|
|
"actor_id": actorID,
|
|
"action": activity.Action,
|
|
"details": json.RawMessage(details),
|
|
"created_at": timestampToString(activity.CreatedAt),
|
|
},
|
|
})
|
|
|
|
writeJSON(w, http.StatusCreated, map[string]string{
|
|
"id": uuidToString(activity.ID),
|
|
"action": activity.Action,
|
|
"created_at": timestampToString(activity.CreatedAt),
|
|
})
|
|
}
|
|
|
|
// ── Squad Trigger Logic ─────────────────────────────────────────────────────
|
|
|
|
// shouldEnqueueSquadLeaderOnComment returns true if the issue is assigned to a
|
|
// squad and the comment author is NOT a member of that squad (anti-loop).
|
|
// commentContent is the new comment's markdown body; when a member explicitly
|
|
// @mentions anyone (agent, member, squad, or @all) in that body, the leader
|
|
// is skipped — the @ marks deliberate routing and the leader would otherwise
|
|
// just observe and record no_action. Issue cross-reference mentions
|
|
// (mention://issue/...) are NOT a routing signal and do not suppress the
|
|
// leader. Agent-authored comments always go through the leader (subject to
|
|
// the leader self-trigger guard) so agent updates still drive coordination.
|
|
func (h *Handler) shouldEnqueueSquadLeaderOnComment(ctx context.Context, issue db.Issue, commentContent, authorType, authorID string) bool {
|
|
if !issue.AssigneeType.Valid || issue.AssigneeType.String != "squad" || !issue.AssigneeID.Valid {
|
|
return false
|
|
}
|
|
|
|
// Load the squad.
|
|
squad, err := h.Queries.GetSquadInWorkspace(ctx, db.GetSquadInWorkspaceParams{
|
|
ID: issue.AssigneeID,
|
|
WorkspaceID: issue.WorkspaceID,
|
|
})
|
|
if err != nil {
|
|
return false
|
|
}
|
|
|
|
// Skip if the comment author is the squad leader itself AND the agent's
|
|
// last activity on this issue was in the leader role (prevent self-trigger
|
|
// loop). An agent that is simultaneously the squad's leader and one of its
|
|
// workers must still wake the leader role after posting a comment from
|
|
// its worker task — role is inferred from the agent's most recent task
|
|
// on the issue, not from author ID alone.
|
|
if authorType == "agent" && authorID == uuidToString(squad.LeaderID) &&
|
|
h.lastTaskWasLeader(ctx, issue.ID, squad.LeaderID) {
|
|
return false
|
|
}
|
|
|
|
// Member explicitly @mentioned someone → that someone owns the next step,
|
|
// skip the leader. Covers @agent / @member / @squad / @all; issue
|
|
// cross-references do NOT count as routing. Agent-authored comments are
|
|
// intentionally exempt: when an agent posts a result that @mentions
|
|
// another agent, the leader still needs to coordinate the thread.
|
|
if authorType == "member" && commentMentionsAnyone(commentContent) {
|
|
return false
|
|
}
|
|
|
|
// Verify leader agent is ready (has runtime, not archived).
|
|
agent, err := h.Queries.GetAgent(ctx, squad.LeaderID)
|
|
if err != nil || !agent.RuntimeID.Valid || agent.ArchivedAt.Valid {
|
|
return false
|
|
}
|
|
|
|
return true
|
|
}
|
|
|
|
// lastTaskWasLeader returns true when the agent's most recent task on the
|
|
// issue was enqueued in the squad-leader role. Used by the self-trigger
|
|
// guards to tell apart a comment posted while the agent was acting as
|
|
// leader (skip) from one posted while it was acting as a worker (do not
|
|
// skip). When the agent has no prior task on this issue the role is
|
|
// undetermined and we treat it as non-leader so a brand-new external
|
|
// trigger can still reach the leader.
|
|
func (h *Handler) lastTaskWasLeader(ctx context.Context, issueID, agentID pgtype.UUID) bool {
|
|
flag, err := h.Queries.GetLatestTaskIsLeaderForIssueAndAgent(ctx, db.GetLatestTaskIsLeaderForIssueAndAgentParams{
|
|
IssueID: issueID,
|
|
AgentID: agentID,
|
|
})
|
|
if err != nil {
|
|
return false
|
|
}
|
|
return flag
|
|
}
|
|
|
|
// commentMentionsAnyone returns true when the comment body contains at least
|
|
// one routing-style mention — [@Name](mention://agent|member|squad|all/<id>).
|
|
// Issue cross-references (mention://issue/...) are ignored because they are
|
|
// not directed at a participant. Only the current comment is inspected —
|
|
// parent (thread root) mentions are NOT inherited here.
|
|
func commentMentionsAnyone(content string) bool {
|
|
for _, m := range util.ParseMentions(content) {
|
|
switch m.Type {
|
|
case "agent", "member", "squad", "all":
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
// shouldEnqueueSquadLeaderOnAssign returns true when assigning an issue to a
|
|
// squad (or creating an issue pre-assigned to a squad) should immediately
|
|
// trigger the squad leader. Mirrors shouldEnqueueAgentTask: backlog issues
|
|
// are skipped (parking lot), and the leader agent must have a runtime and
|
|
// not be archived.
|
|
func (h *Handler) shouldEnqueueSquadLeaderOnAssign(ctx context.Context, issue db.Issue) bool {
|
|
if issue.Status == "backlog" {
|
|
return false
|
|
}
|
|
return h.isSquadLeaderReady(ctx, issue)
|
|
}
|
|
|
|
// isSquadLeaderReady returns true when the issue is assigned to a squad whose
|
|
// leader agent can accept work right now. Readiness criteria (archived,
|
|
// runtime bound, runtime online) are shared with the autopilot admission
|
|
// gate via service.AgentReadiness — both paths must move together or one
|
|
// will start enqueueing tasks the other refuses (MUL-2429 RFC §4.b B4).
|
|
func (h *Handler) isSquadLeaderReady(ctx context.Context, issue db.Issue) bool {
|
|
if !issue.AssigneeType.Valid || issue.AssigneeType.String != "squad" || !issue.AssigneeID.Valid {
|
|
return false
|
|
}
|
|
squad, err := h.Queries.GetSquadInWorkspace(ctx, db.GetSquadInWorkspaceParams{
|
|
ID: issue.AssigneeID,
|
|
WorkspaceID: issue.WorkspaceID,
|
|
})
|
|
if err != nil {
|
|
return false
|
|
}
|
|
agent, err := h.Queries.GetAgent(ctx, squad.LeaderID)
|
|
if err != nil {
|
|
return false
|
|
}
|
|
ready, _, err := service.AgentReadiness(ctx, h.Queries, agent)
|
|
if err != nil {
|
|
// Fail closed when we can't tell — same posture as the rest of
|
|
// this function (any error path returns false).
|
|
return false
|
|
}
|
|
return ready
|
|
}
|
|
|
|
// enqueueSquadLeaderTask triggers the squad leader agent for an issue assigned to a squad.
|
|
func (h *Handler) enqueueSquadLeaderTask(ctx context.Context, issue db.Issue, triggerCommentID pgtype.UUID, authorType, authorID string) {
|
|
squad, err := h.Queries.GetSquadInWorkspace(ctx, db.GetSquadInWorkspaceParams{
|
|
ID: issue.AssigneeID,
|
|
WorkspaceID: issue.WorkspaceID,
|
|
})
|
|
if err != nil {
|
|
return
|
|
}
|
|
|
|
// Private-leader gate: deny if the actor cannot access the leader.
|
|
if !h.canEnqueueSquadLeader(ctx, squad.LeaderID, authorType, authorID, uuidToString(issue.WorkspaceID)) {
|
|
return
|
|
}
|
|
|
|
// Dedup: skip if leader already has a pending task for this issue.
|
|
hasPending, err := h.Queries.HasPendingTaskForIssueAndAgent(ctx, db.HasPendingTaskForIssueAndAgentParams{
|
|
IssueID: issue.ID,
|
|
AgentID: squad.LeaderID,
|
|
})
|
|
if err != nil || hasPending {
|
|
return
|
|
}
|
|
|
|
if _, err := h.TaskService.EnqueueTaskForSquadLeader(ctx, issue, squad.LeaderID, triggerCommentID); err != nil {
|
|
slog.Warn("enqueue squad leader task failed",
|
|
"issue_id", uuidToString(issue.ID),
|
|
"squad_id", uuidToString(squad.ID),
|
|
"leader_id", uuidToString(squad.LeaderID),
|
|
"error", err)
|
|
}
|
|
}
|