Files
multica/server/internal/handler/squad.go
LinYushen de900b2ba6 feat(server): funnel/community/commercial business metrics + PostHog pairing (MUL-2949) (#3698)
* 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>
2026-06-03 16:39:06 +08:00

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