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" "github.com/multica-ai/multica/server/internal/logger" obsmetrics "github.com/multica-ai/multica/server/internal/metrics" "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/dbid" "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 (h *Handler) 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: h.resolveAvatarURLPtr(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 ───────────────────────────────────────────────────────────────── // canManageSquad reports whether the member may mutate the squad. Workspace // owner/admin manage every squad; a regular member manages only the squads // they created. Squads stay creator-scoped for management while remaining // visible workspace-wide (ListSquads is unfiltered). Mirrors the front-end // per-squad `canManage` gate so the UI and API agree on who can rename / add // members / archive (MUL-4223). func canManageSquad(member db.Member, squad db.Squad) bool { if roleAllowed(member.Role, "owner", "admin") { return true } return uuidToString(squad.CreatorID) == uuidToString(member.UserID) } // memberCanWireAgent reports whether the acting member may attach the given // agent to a squad (as leader or worker). Workspace owner/admin may wire any // workspace agent — their management surface is unchanged. A regular member // (a creator managing their own squad) may only wire agents they can // @-trigger: canInvokeAgent judged as the member themselves, so public_to // agents on their allow-list and their own private agents pass, while other // members' private / non-allow-listed agents are rejected. This stops a // creator from smuggling an agent they cannot invoke into a squad and reaching // it through squad routing (MUL-4223). func (h *Handler) memberCanWireAgent(ctx context.Context, member db.Member, agent db.Agent, workspaceID string) bool { if roleAllowed(member.Role, "owner", "admin") { return true } uid := uuidToString(member.UserID) return h.canInvokeAgent(ctx, agent, "member", uid, uid, workspaceID) } // 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 := h.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] = h.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") // Any workspace member can create a squad and becomes its creator // (CreatorID below). This aligns squads with agents/projects, which are // also member-creatable; management stays creator-scoped (MUL-4223). member, ok := h.requireWorkspaceMember(w, r, workspaceID, "workspace not found") 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. leaderAgent, 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 } // A non-admin creator may only lead their squad with an agent they can // @-trigger; admins may wire any workspace agent (MUL-4223). if !h.memberCanWireAgent(r.Context(), member, leaderAgent, workspaceID) { writeError(w, http.StatusForbidden, "you can only use an agent you have access to as leader") return } avatarURL := pgtype.Text{} if req.AvatarURL != nil { accepted, ok := h.acceptAvatarURL(w, r, *req.AvatarURL, "") if !ok { return } avatarURL = pgtype.Text{String: accepted, 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") member, ok := h.requireWorkspaceMember(w, r, workspaceID, "workspace not found") if !ok { return } squad, _, ok := h.loadSquadInWorkspace(w, r) if !ok { return } if !canManageSquad(member, squad) { writeError(w, http.StatusForbidden, "insufficient permissions") 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 { accepted, ok := h.acceptAvatarURL(w, r, *req.AvatarURL, squad.AvatarUrl.String) if !ok { return } params.AvatarUrl = pgtype.Text{String: accepted, Valid: true} } tx, err := h.TxStarter.Begin(r.Context()) if err != nil { writeError(w, http.StatusInternalServerError, "failed to update squad") return } defer tx.Rollback(r.Context()) qtx := h.Queries.WithTx(tx) // Autopilot assignment takes FOR SHARE on the squad before locking its // leader Agent. Take the exclusive side in the same order so leader // rotation and active Autopilot saves cannot leave an automation pointing // at an unbound effective Agent. if _, err := qtx.LockSquadForUpdate(r.Context(), db.LockSquadForUpdateParams{ ID: squad.ID, WorkspaceID: wsUUID, }); err != nil { writeError(w, http.StatusInternalServerError, "failed to update squad") return } newLeaderRuntimeBound := true if req.LeaderID != nil { lid, ok := parseUUIDOrBadRequest(w, *req.LeaderID, "leader_id") if !ok { return } // Stabilize runtime_id through commit. Runtime teardown takes FOR UPDATE // on this row and follows the same Agent→Autopilot lock order, so // whichever operation starts first produces a complete result. newLeader, err := qtx.LockAgentForAutopilotAssignment(r.Context(), db.LockAgentForAutopilotAssignmentParams{ ID: lid, WorkspaceID: wsUUID, }) if err != nil { writeError(w, http.StatusBadRequest, "leader must be a valid agent in this workspace") return } // A non-admin creator may only promote an agent they can @-trigger. if !h.memberCanWireAgent(r.Context(), member, newLeader, workspaceID) { writeError(w, http.StatusForbidden, "you can only use an agent you have access to as leader") return } // Ensure new leader is a squad member; auto-add if not. isMember, err := qtx.IsSquadMember(r.Context(), db.IsSquadMemberParams{ SquadID: squad.ID, MemberType: "agent", MemberID: lid, }) if err != nil { writeError(w, http.StatusInternalServerError, "failed to update squad") return } if !isMember { if _, err := qtx.AddSquadMember(r.Context(), db.AddSquadMemberParams{ SquadID: squad.ID, MemberType: "agent", MemberID: lid, Role: "leader", }); err != nil { writeError(w, http.StatusInternalServerError, "failed to update squad") return } } params.LeaderID = lid newLeaderRuntimeBound = newLeader.RuntimeID.Valid } updated, err := qtx.UpdateSquad(r.Context(), params) if err != nil { writeError(w, http.StatusInternalServerError, "failed to update squad") return } var pausedAutopilots []db.Autopilot if req.LeaderID != nil && !newLeaderRuntimeBound { pausedAutopilots, err = qtx.PauseAutopilotsByUnrunnableSquad(r.Context(), squad.ID) if err != nil { writeError(w, http.StatusInternalServerError, "failed to update squad") return } } if err := tx.Commit(r.Context()); 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}) for _, autopilot := range pausedAutopilots { h.publish(protocol.EventAutopilotUpdated, workspaceID, "member", requestUserID(r), map[string]any{ "autopilot": autopilotToResponse(autopilot, nil), }) } writeJSON(w, http.StatusOK, resp) } func (h *Handler) DeleteSquad(w http.ResponseWriter, r *http.Request) { workspaceID := workspaceIDFromURL(r, "workspaceId") member, ok := h.requireWorkspaceMember(w, r, workspaceID, "workspace not found") if !ok { return } squad, _, ok := h.loadSquadInWorkspace(w, r) if !ok { return } if !canManageSquad(member, squad) { writeError(w, http.StatusForbidden, "insufficient permissions") 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"` } // deriveRuntimeAvailability owns the shared three-state runtime reachability // policy. Agent projections use these values directly; squad presence maps an // online runtime to its user-facing "idle" bucket after workload precedence. func deriveRuntimeAvailability( runtimeStatus pgtype.Text, lastSeen pgtype.Timestamptz, now time.Time, ) string { if !runtimeStatus.Valid { return "offline" } if runtimeStatus.String == "online" { return "online" } if !lastSeen.Valid { return "offline" } if now.Sub(lastSeen.Time) < 5*time.Minute { return "unstable" } return "offline" } // 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, hasWorkingTask bool, now time.Time, ) string { if archived { return "archived" } if hasWorkingTask { return "working" } availability := deriveRuntimeAvailability(runtimeStatus, lastSeen, now) if availability == "online" { return "idle" } return availability } // ListSquadMemberStatus returns one entry per squad member with derived // status, the issues each agent member is currently running or waiting to run, // 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 hasWorkingTask 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 } // Keep waiting_local_directory rows available for issue visibility, // but only dispatched/running work drives the `working` bucket. A // working task may have no issue (chat / quick-create), so decide the // bucket independently from whether an issue link can be rendered. if row.TaskID.Valid { if row.TaskStatus.Valid && (row.TaskStatus.String == "dispatched" || row.TaskStatus.String == "running") { entry.hasWorkingTask = 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.hasWorkingTask, 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") member, ok := h.requireWorkspaceMember(w, r, workspaceID, "workspace not found") if !ok { return } squad, _, ok := h.loadSquadInWorkspace(w, r) if !ok { return } if !canManageSquad(member, squad) { writeError(w, http.StatusForbidden, "insufficient permissions") 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" { agent, err := h.Queries.GetAgentInWorkspace(r.Context(), db.GetAgentInWorkspaceParams{ ID: memberUUID, WorkspaceID: wsUUID, }) if err != nil { writeError(w, http.StatusBadRequest, "agent not found in this workspace") return } // A non-admin creator may only add agents they can @-trigger (public // or their own / allow-listed agents); admins may add any workspace // agent (MUL-4223). if !h.memberCanWireAgent(r.Context(), member, agent, workspaceID) { writeError(w, http.StatusForbidden, "you can only add an agent you have access to") 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") member, ok := h.requireWorkspaceMember(w, r, workspaceID, "workspace not found") if !ok { return } squad, _, ok := h.loadSquadInWorkspace(w, r) if !ok { return } if !canManageSquad(member, squad) { writeError(w, http.StatusForbidden, "insufficient permissions") 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") member, ok := h.requireWorkspaceMember(w, r, workspaceID, "workspace not found") if !ok { return } squad, _, ok := h.loadSquadInWorkspace(w, r) if !ok { return } if !canManageSquad(member, squad) { writeError(w, http.StatusForbidden, "insufficient permissions") 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. // // The leader-turn check is task-provenance based (is_leader_task + squad_id on // the X-Task-ID row), the SAME source the claim path uses to inject the squad // briefing and the mandatory-recording instruction. The target issue's own // assignee is deliberately NOT consulted: leaders legitimately run on issues // that are not squad-assigned. See MUL-6622 / GH #7487. // // Two authorization gates, in this order: the caller must own the task, and must // still be the squad's leader. The first is what makes it safe to quote the // task's issue id in an error; the second is what keeps a claim-downgraded run // (see the comment at gate 2) from writing a leader verdict. 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 } // Authority for "is this run a squad leader turn" is the TASK ROW, not the // target issue's assignee (MUL-6622 / GH #7487). is_leader_task + squad_id // are stamped at enqueue time and are exactly what the claim path keys the // squad briefing and the mandatory-`squad activity` instruction off // (handler/daemon.go, daemon/prompt.go taskIsSquadLeader). Gating this // endpoint on issue.assignee_type == "squad" instead made the recording // call unsatisfiable on the paths where a leader legitimately runs on a // non-squad-assigned issue — a `@squad` mention on an issue owned by a // plain agent, or a leader task bound to a child issue — and because the // no_action instruction forbids substituting a comment, the decision left // no trace at all. // // Check ORDER is load-bearing. Every rejection below the ownership gate may // quote task-derived ids; every rejection above it must not. `GetAgentTask` // is a global lookup by id, so tenant scoping (GetAgentTaskInWorkspace, // which joins the owning agent's workspace) and the "caller owns this task" // gate both come first — otherwise an unrelated task id, probed through an // issue the caller can legitimately read, would echo back a foreign // workspace's issue id. taskID := r.Header.Get("X-Task-ID") taskUUID, ok := parseUUIDOrBadRequest(w, taskID, "task id") if !ok { return } task, err := h.Queries.GetAgentTaskInWorkspace(r.Context(), db.GetAgentTaskInWorkspaceParams{ ID: taskUUID, WorkspaceID: issue.WorkspaceID, }) if err != nil || !task.IssueID.Valid { writeError(w, http.StatusBadRequest, "task does not belong to issue") return } // Security gate 1: the caller must be the agent this task was enqueued for. workspaceID := uuidToString(issue.WorkspaceID) userID := requestUserID(r) actorType, actorID := h.resolveActor(r, userID, workspaceID) if actorType != "agent" || !task.AgentID.Valid || actorID != uuidToString(task.AgentID) { writeError(w, http.StatusForbidden, "only the squad leader agent can record evaluations") return } // Past the ownership gate, naming the task's own issue is safe — and useful: // a leader woken by a stage barrier / child-done callback runs on the PARENT // issue, so recording against the child it just read is the common mistake. if uuidToString(task.IssueID) != uuidToString(issue.ID) { writeError(w, http.StatusBadRequest, "task does not belong to issue; record the evaluation on issue "+uuidToString(task.IssueID)+" (the issue this task is running on)") return } // Narrowing vs. the old behavior: a leader agent running a NON-leader task // on a squad-assigned issue (for example a same-squad worker task) used to // be accepted here. It is rejected now — it is not running as the leader, // and the runtime only mandates this call when taskIsSquadLeader(task). if !task.IsLeaderTask { writeError(w, http.StatusBadRequest, "task is not a squad leader task") return } if !task.SquadID.Valid { // Pre-MUL-3730 rows can be leader tasks without a stamped squad. Log it // rather than failing silently from the operator's point of view. slog.Warn("squad leader evaluation: leader task has no squad_id", append(logger.RequestAttrs(r), "task_id", uuidToString(task.ID), "issue_id", uuidToString(issue.ID), )...) writeError(w, http.StatusBadRequest, "leader task has no squad_id") return } squad, err := h.Queries.GetSquadInWorkspace(r.Context(), db.GetSquadInWorkspaceParams{ ID: task.SquadID, WorkspaceID: issue.WorkspaceID, }) if err != nil { writeError(w, http.StatusNotFound, "squad not found") return } // Security gate 2: the caller must still be this squad's leader. // // `is_leader_task` on the row records the enqueue-time INTENT, which is not // always the role the claim actually delivered: when the leader was swapped // between enqueue and claim, the claim path clears resp.IsLeaderTask and the // run proceeds as an ordinary agent turn, while the row keeps // is_leader_task = true (handler/daemon.go, "claim delivered as a non-leader // task"). Trusting the row alone would let such a downgraded run write a // leader verdict — and, on no_action, suppress its own comment. Until the // delivered role is persisted, the live leader check is the only thing that // distinguishes the two, so it stays. // // Cost of the conservative choice: a leader rotated away MID-run is refused // here. That no longer means silence — the injected rules now tell a leader // whose recording call failed to leave a short comment instead. if actorID != uuidToString(squad.LeaderID) { writeError(w, http.StatusForbidden, "only the squad leader agent can record evaluations") 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{ ID: dbid.NewV7(), WorkspaceID: issue.WorkspaceID, IssueID: issue.ID, ActorType: pgtype.Text{String: "agent", Valid: true}, // task.AgentID, not squad.LeaderID: the no_action comment suppression // lookup matches actor_id against task.agent_id // (service.HasSquadLeaderNoActionEvaluationForTask), so this column has // to carry the task's agent for suppression to find the row at all. ActorID: task.AgentID, 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 ───────────────────────────────────────────────────── // shouldSuppressSquadLeaderSelfTrigger reports whether a squad leader's own // comment should be blocked from re-enqueuing that same leader. The only // leader-authored non-leader task allowed to wake the assigned leader is a // same-squad worker task; generic agent tasks such as direct mentions and // thread-parent replies are not worker-role proof and must not self-trigger. func (h *Handler) shouldSuppressSquadLeaderSelfTrigger(ctx context.Context, issueID, leaderID, squadID pgtype.UUID) bool { latest, err := h.Queries.GetLatestTaskRoleForIssueAndAgent(ctx, db.GetLatestTaskRoleForIssueAndAgentParams{ IssueID: issueID, AgentID: leaderID, }) if err != nil { return false } if latest.IsLeaderTask { return true } return !latest.SquadID.Valid || uuidToString(latest.SquadID) != uuidToString(squadID) } // commentMentionsAnyone returns true when the comment body contains at least // one routing-style mention — [@Name](mention://agent|member|squad|all/). // 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 } // The squad-leader assign/promotion readiness decision now lives in the single // service.IssueService.WillEnqueueRun predicate (MUL-3375), shared by the issue // write paths and the preview endpoint. The former handler-local mirrors // (shouldEnqueueSquadLeaderOnAssign / isSquadLeaderReady) were removed to stop // the four-entry-point drift. The squad enqueue side effect still flows through // enqueueSquadLeaderTask below, which keeps the leader access gate and pending // dedup in one place. // enqueueSquadLeaderTask triggers the squad leader agent for an issue assigned // to a squad. Assign and backlog-promotion paths use this directly; comment // paths go through computeCommentAgentTriggers so preview and create share the // same trigger set. // It returns true only when a leader task was actually enqueued. func (h *Handler) enqueueSquadLeaderTask(ctx context.Context, issue db.Issue, triggerCommentID pgtype.UUID, authorType, authorID, handoffNote string) bool { squad, err := h.Queries.GetSquadInWorkspace(ctx, db.GetSquadInWorkspaceParams{ ID: issue.AssigneeID, WorkspaceID: issue.WorkspaceID, }) if err != nil { return false } // The gate must judge the SAME top-of-chain human the enqueue path will // persist on the leader task row, or it drifts: an agent-created issue that // correctly inherits its originator (MUL-4305) would still be denied here // if the gate used an empty originator. Member authors are their own // originator; for agent/system-triggered assigns we resolve the originator // exactly like EnqueueTaskForSquadLeader* does (via the issue's origin // link). triggerCommentID is always empty on the assign/promote path, so we // pass an invalid UUID to match. A still-unresolved originator leaves // leaderOriginator empty, which correctly fails closed for member/team // targets while a workspace target still admits the agent principal. leaderOriginator := "" if authorType == "member" { leaderOriginator = authorID } else { leaderOriginator = uuidToString(h.TaskService.OriginatorForIssueTask(ctx, issue, pgtype.UUID{})) } if !h.canEnqueueSquadLeader(ctx, squad.LeaderID, authorType, authorID, leaderOriginator, uuidToString(issue.WorkspaceID)) { return false } hasPending, err := h.Queries.HasPendingTaskForIssueAndAgent(ctx, db.HasPendingTaskForIssueAndAgentParams{ IssueID: issue.ID, AgentID: squad.LeaderID, // Key dedup on the reviewed head (TEN-356). HeadSha: h.TaskService.ResolveIssueReviewSHAParam(ctx, issue.ID), }) if err != nil || hasPending { return false } // triggerCommentID is always empty on the assign/promote path; legacy // handoff text rides its dedicated task column instead. _ = triggerCommentID // The member who performed the assign/promote is the accountable human for the // leader run (MUL-4302 §4) — the same principal the gate above judged. An agent // author is not a human, so only a member actor is threaded. if _, err := h.TaskService.EnqueueTaskForSquadLeaderWithHandoff(ctx, issue, squad.LeaderID, squad.ID, handoffNote, memberActorUserID(authorType, authorID)); 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) return false } return true }