mirror of
https://gh-proxy.org/https://github.com/klinsmaya/multica.git
synced 2026-11-09 01:46:46 +08:00
* refactor(issues): remove the assignment handoff note (MUL-6743) Usage data puts the handoff note at 0.2% of assignments, so the box occupied the confirm dialog's centre for the other 99.8% and bought them nothing. Removing it takes the whole feature with it: the note input and its runtime version gate, the `handoff_note` write field on PATCH/batch issue updates, the task column write, and the daemon's opening-prompt + issue_context.md handoff sections. The confirm dialog itself stays — it still names who is about to run and still offers "Don't start yet" (suppress_run). The `*WithHandoff` enqueue methods only ever varied by that note, so they become `EnqueueTaskForIssueByActor` / `EnqueueTaskForSquadLeaderByActor` — the actor-carrying variants autopilot already called with an empty note. `preview-trigger` drops `handoff_supported`, which nothing read after the gate moved client-side. `agent_task_queue.handoff_note` is deliberately left in place: it holds historical rows and this is the expand half of an expand-contract pair. Nothing reads or writes it now, so a follow-up migration can drop it once no deployed binary selects it. Co-authored-by: multica-agent <[email protected]> * docs(issues): reshoot the assignment confirm dialog (MUL-6743) The asset still showed the handoff note box the surrounding prose no longer describes. Recaptured from the app at the same 600x450 frame; the confirm button now carries the send-chord keycaps it has shipped with since MUL-5694. Co-authored-by: multica-agent <[email protected]> --------- Co-authored-by: Lambda <[email protected]> Co-authored-by: multica-agent <[email protected]>
1228 lines
43 KiB
Go
1228 lines
43 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"
|
|
"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"`
|
|
}
|
|
|
|
// 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"
|
|
}
|
|
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 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/<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
|
|
}
|
|
|
|
// 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 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.
|
|
_ = 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.EnqueueTaskForSquadLeaderByActor(ctx, issue, squad.LeaderID, squad.ID, 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
|
|
}
|