mirror of
https://github.com/multica-ai/multica.git
synced 2026-07-16 14:49:09 +02:00
* feat: implement Squad feature MVP
- Add migration 084_squad: squad, squad_member, squad_activity_log tables
- Extend issue.assignee_type to support 'squad'
- Add sqlc queries for squad CRUD, member management, activity logs
- Add Go handler with full Squad API (CRUD, members, activity log)
- Register routes: /api/squads/*, /api/issues/{id}/squad-activity, /api/squad-activity
- Add Squad trigger logic:
- Assign Squad immediately triggers leader
- Every external comment on squad-assigned issue triggers leader
- Anti-loop: squad members' comments don't trigger leader
- Dedup: skip if leader already has pending task
- Add squad activity log API (方案 B) for leader no-op recording
- Add frontend TypeScript types (Squad, SquadMember, SquadActivityLog)
- Add protocol events: squad:created, squad:updated, squad:deleted
Co-authored-by: multica-agent <github@multica.ai>
* fix: address PR review blocking issues
1. validateAssigneePair now accepts 'squad' assignee_type
2. All squad endpoints validate workspace ownership via GetSquadInWorkspace
3. CreateSquadActivityLog restricted to squad leader agent only
4. AddSquadMember validates member exists in workspace
5. UpdateSquad auto-adds new leader to squad members
6. DeleteSquad transfers assigned issues to leader before deletion
7. IssueAssigneeType includes 'squad' in frontend types
Co-authored-by: multica-agent <github@multica.ai>
* feat: soft-delete squads via archive instead of hard delete
- Add migration 085: archived_at + archived_by columns on squad table
- ListSquads now excludes archived squads (ListAllSquads for admin)
- DeleteSquad → ArchiveSquad (sets archived_at, preserves all records)
- Transfer squad-assigned issues to leader before archiving
- SquadResponse includes archived_at/archived_by fields
- Frontend Squad type updated with nullable archived fields
Co-authored-by: multica-agent <github@multica.ai>
* feat: re-add Squads frontend entry (sidebar nav + pages)
Re-applies the frontend squad entry that was lost during a merge:
- Sidebar nav: Squads item with Users icon
- Paths: squads() and squadDetail() in workspace paths
- Routes: /squads and /squads/[id] pages
- Views: SquadsPage (list) and SquadDetailPage
- i18n: en 'Squads' / zh '小队'
- Reserved slug: 'squads'
Co-authored-by: multica-agent <github@multica.ai>
* fix: fix SquadsPage rendering - use PageHeader children pattern
PageHeader takes children, not title/actions props. The incorrect
usage caused a React rendering error. Now matches the pattern used
by autopilots and agents pages.
Co-authored-by: multica-agent <github@multica.ai>
* fix(squads): add API client methods and package export for squads pages
* feat: complete Squad frontend - create dialog, member management, API methods
- Add CreateSquadModal with name/description/leader selection
- Register 'create-squad' in modal registry
- Wire 'New Squad' button to open the modal
- Add full API client methods: createSquad, updateSquad, deleteSquad,
addSquadMember, removeSquadMember
- Rewrite SquadDetailPage with:
- Member list showing resolved names
- Add/remove member UI
- Archive squad button
- Back navigation to squads list
Co-authored-by: multica-agent <github@multica.ai>
* feat: improve Squad UI - match create agent dialog style
- CreateSquadModal: proper Dialog with Header/Description/Footer,
agent picker with avatars, textarea for description
- SquadDetailPage: centered max-w-2xl layout, ActorAvatar for members,
Crown badge for leader, textarea for member description,
improved spacing and visual hierarchy
- Renamed 'role' field label to 'Description' in add member form
(describes the member's responsibilities in the squad)
Co-authored-by: multica-agent <github@multica.ai>
* feat(squad): add avatar, instructions; drop unique-name constraint
- 086: add squad.avatar_url
- 087: drop unique constraint on squad.name (squads with the same
name are legitimate across teams; uniqueness was an accidental
product constraint)
- 088: add squad.instructions (text, default '')
- UpdateSquad now COALESCEs avatar_url + instructions
- handler exposes Instructions in SquadResponse and accepts it in
UpdateSquad
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
* feat(squad): assignable + mention target; trigger leader on assign
- assignee picker and @mention suggestion list squads alongside
agents and members; renders squad avatar/icon
- creating or updating an issue with assignee_type=squad enqueues
a task for the squad's current leader (mirrors agent-assignee
parking-lot rule: skip backlog only)
- workspace queries/hooks expose squads where needed for the
pickers
- locales updated for new picker copy
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
* feat(squad): agent-style detail page with members + instructions tabs
- restructure squad detail page to mirror the agent detail page:
320px inspector (creator, leader, created/updated) + tabbed
pane (Members | Instructions) with dirty-guard AlertDialog
- inline name + avatar editing on the inspector
- inline description editor (modal textarea)
- members tab: leader + member picker with role descriptions,
swap leader, edit member roles, remove
- instructions tab: ContentEditor + Save (mirrors agent pattern)
- squads list shows the squad avatar/icon
- core types + api.updateSquad accept avatar_url + instructions
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
* feat(squad): inject leader briefing on claim (protocol + roster + instructions)
When a squad's leader agent claims a task on a squad-assigned issue,
append a system-level briefing to the agent's Instructions composed of:
1. Squad Operating Protocol — hard-coded rules: leader is a
coordinator, dispatch via @mention, stop after dispatching,
resume on re-trigger, do not work outside the roster.
2. Squad Roster — leader self-row plus one row per non-archived
member with a literal mention markdown string ([@Name](mention://
agent|member/<UUID>)) the leader can paste verbatim. Round-trips
through util.ParseMentions, enforced by a contract test.
3. Squad Instructions — the user-defined squad.instructions block,
omitted entirely when empty so we do not leave a dangling heading.
Non-leader members claiming the same issue receive no briefing.
Tests cover: full squad with mixed agent/human members, lone leader,
archived agents skipped, empty user instructions, mention round-trip,
and the leader/non-leader claim-handler gate.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
* fix(squad): tell leader not to restate issue context in dispatch comment
After observing leaders padding their delegation comments with full
re-summaries of the issue body and prior discussion, make the
Operating Protocol explicit:
- assignees on Multica already have the full issue (title,
description, all comments, attachments) and workspace context;
- delegation comments should add only what cannot be inferred
(who is picked, why, extra constraints), aim for two or three
sentences;
- restating context is now an explicit hard rule violation.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
* feat(squad): unify leader evaluation into activity_log, add CLI command
- Squad member comments now trigger leader (only leader self-excluded)
- Replace squad_activity_log with activity_log (action: squad_leader_evaluated)
- Add CLI: multica squad activity <issue-id> <outcome> --reason
- Add API: POST /api/issues/{id}/squad-evaluated
- Update squad operating protocol to require evaluation recording
- Remove squad_activity_log table from schema and generated code
* feat(cli): add squad list, get, member list commands
* fix(squad): address review findings (P1+P2)
P1 fixes:
- Add 'squads' to reserved_slugs.json (source of truth)
- Add 'create-squad' to ModalType union
- Remove unused leaderOpen/selectedLeader in create-squad modal
- Replace literal JSX strings with i18n selectors (en + zh-Hans)
P2 fixes:
- Add 'squad' to mention regex (MentionRe)
- Fix human member lookup in squad briefing (use GetUser directly)
- Add squads routes to desktop app
- Add squad:created/updated/deleted to WSEventType + invalidation
- Reject archived squads as issue assignees
* fix(squad): restore zh-Hans key, publish activity event, invalidate issues on archive
- Restore create_project.title in zh-Hans modals.json (dropped by prior edit)
- Publish activity:created WS event after squad leader evaluation
- Invalidate issue queries on squad:deleted (archive transfers assignees)
- Add creator info to squad list cards
* fix(squad): realtime sync, rerun support, leader validation
- Use workspaceKeys.squads prefix for detail/member queries (realtime invalidation)
- Publish squad:updated after add/remove/role-change member mutations
- Support rerun for squad-assigned issues (targets leader agent)
- Reject assignment to squads whose leader is archived
---------
Co-authored-by: multica-agent <github@multica.ai>
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
675 lines
21 KiB
Go
675 lines
21 KiB
Go
package handler
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"log/slog"
|
|
"net/http"
|
|
|
|
"github.com/go-chi/chi/v5"
|
|
"github.com/jackc/pgx/v5/pgtype"
|
|
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"`
|
|
}
|
|
|
|
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),
|
|
}
|
|
}
|
|
|
|
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),
|
|
}
|
|
}
|
|
|
|
// ── 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
|
|
}
|
|
|
|
// ── 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
|
|
}
|
|
resp := make([]SquadResponse, len(squads))
|
|
for i, s := range squads {
|
|
resp[i] = squadToResponse(s)
|
|
}
|
|
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"`
|
|
}
|
|
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
|
|
}
|
|
|
|
squad, err := h.Queries.CreateSquad(r.Context(), db.CreateSquadParams{
|
|
WorkspaceID: wsUUID,
|
|
Name: req.Name,
|
|
Description: req.Description,
|
|
LeaderID: leaderUUID,
|
|
CreatorID: member.UserID,
|
|
})
|
|
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 := squadToResponse(squad)
|
|
h.publish(protocol.EventSquadCreated, workspaceID, "member", uuidToString(member.UserID), map[string]any{"squad": resp})
|
|
writeJSON(w, http.StatusCreated, resp)
|
|
}
|
|
|
|
func (h *Handler) GetSquad(w http.ResponseWriter, r *http.Request) {
|
|
squad, _, ok := h.loadSquadInWorkspace(w, r)
|
|
if !ok {
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusOK, squadToResponse(squad))
|
|
}
|
|
|
|
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 := squadToResponse(updated)
|
|
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)
|
|
}
|
|
|
|
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)
|
|
}
|
|
|
|
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
|
|
}
|
|
|
|
if err := h.Queries.RemoveSquadMember(r.Context(), db.RemoveSquadMemberParams{
|
|
SquadID: squad.ID,
|
|
MemberType: req.MemberType,
|
|
MemberID: memberUUID,
|
|
}); err != nil {
|
|
writeError(w, http.StatusInternalServerError, "failed to remove squad member")
|
|
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
|
|
}
|
|
|
|
details, _ := json.Marshal(map[string]string{
|
|
"squad_id": uuidToString(squad.ID),
|
|
"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).
|
|
func (h *Handler) shouldEnqueueSquadLeaderOnComment(ctx context.Context, issue db.Issue, 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 (prevent self-trigger).
|
|
// Other squad members ARE allowed to trigger the leader — the leader uses
|
|
// silent/no-op turns when no action is needed.
|
|
if authorType == "agent" && authorID == uuidToString(squad.LeaderID) {
|
|
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
|
|
}
|
|
|
|
// 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 is ready (has a runtime, not archived).
|
|
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 || !agent.RuntimeID.Valid || agent.ArchivedAt.Valid {
|
|
return false
|
|
}
|
|
return true
|
|
}
|
|
|
|
// 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
|
|
}
|
|
|
|
// 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.EnqueueTaskForMention(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)
|
|
}
|
|
}
|