Files
multica/server/pkg/db/generated/squad.sql.go
Bohan Jiang 341ce7bfa5 feat: support local working directory for projects (MUL-2618 v1) (#3283)
* feat(project): add local_directory project_resource type (MUL-2662)

Adds a second project_resource type alongside github_repo so a project
can be pinned to an existing directory on a specific daemon (the v1 of
the local-working-directory flow tracked in MUL-2618). The ref schema is
{ local_path, daemon_id, label? }; local_path must be absolute and
daemon_id is required. The same (daemon_id, local_path) pair is allowed
on multiple projects by design — no UNIQUE constraint is added.

Implementation reuses the existing project_resource API surface: the new
type is wired through the validator switch with no migration, no new
events, and no daemon-handler changes (daemon already passes through
arbitrary resource types via ProjectResources). The CLI gains
--local-path / --daemon-id / --ref-label shortcuts so
`multica project resource add --type local_directory` mirrors the
existing `--type github_repo --url ...` ergonomics; the generic --ref
flag still works for both types.

Tests cover the full CRUD lifecycle, the same-path-across-projects
allowance, the same-path-same-project conflict, the validator rejections
(missing/blank/relative path, missing daemon_id, wrong payload type),
and the cross-platform isAbsoluteLocalPath helper.

Co-authored-by: multica-agent <github@multica.ai>

* feat(project): add update endpoint + label-shadow guard for project_resource (MUL-2662)

Addresses the Elon review on PR #3263:

- Add PUT /api/projects/{id}/resources/{resourceId} with sqlc query,
  matching handler, CLI `project resource update`, and a new
  EventProjectResourceUpdated WS event. resource_type stays immutable;
  ref/label/position are all individually optional.
- Catch same-project (daemon_id, local_path) collisions where only the
  embedded label differs — the row-level UNIQUE only matches the full
  ref JSON, so a label typo would otherwise let the same working
  directory bind twice.
- Tests cover the update lifecycle (label-only / ref / clear / 404 /
  invalid path) and the label-shadow conflict on both create and
  update; the in-place rename still succeeds because the conflict
  scan ignores the row being edited.

Incidental: regenerating sqlc picked up a missing skills_local scan in
UpdateAgentCustomEnv that drifted in from #3200.

Co-authored-by: multica-agent <github@multica.ai>

* fix(project): close bundled-create label-shadow gap + merge resource_ref on CLI update (MUL-2662)

Two follow-ups from MUL-2662 review round 2:

- CreateProject inline resources path now dedupes local_directory entries on
  (daemon_id, local_path) before opening the transaction. The DB-level
  UNIQUE(project_id, resource_type, resource_ref) constraint only fires on a
  full JSON match, so two rows with the same target but different `label`
  would otherwise slip past. Standalone POST/PUT already cover this via
  findLocalDirectoryConflict; bundled create was the missing surface.
- `multica project resource update` now seeds resource_ref from the existing
  row before applying per-type shortcut flags, so `--default-branch-hint x`
  on its own no longer constructs a payload missing `url` (which the server
  400s on). Local_directory partial edits get the same merge behavior.

Co-authored-by: multica-agent <github@multica.ai>

* feat(desktop): local_directory project_resource UI (MUL-2665) (#3273)

* feat(desktop): local_directory project_resource UI (MUL-2665)

First UI surface for the local-working-directory flow tracked in MUL-2618.
Lets users on the desktop pin a project to an existing folder on this
machine; web stays read-only since the per-daemon check can't be done in
the browser.

What's new for the renderer:

- ProjectResourcesSection grows a desktop-only "Add local directory"
  button next to the existing GitHub-repo popover. Clicking it opens
  Electron's native folder picker, validates the path through a new
  IPC pair (existence + r/w), and submits a project_resource of
  resource_type=local_directory with daemon_id pulled live from
  daemonAPI.getStatus.
- LocalDirectoryRow renders the rename pencil + path tooltip, and
  greys out when ref.daemon_id != this machine's daemon_id (with a
  "only available on the machine that registered this directory"
  tooltip). Delete stays enabled so users can drop stale registrations
  from any device.
- LocalDirectoryHint sits above the issue-detail comment composer and
  shows "Agent will work in-place at {label} ({path})" when the issue's
  project has a local_directory matching this daemon. Hidden on web.
- TaskStatusPill picks up a new "waiting_for_directory_release" stage
  that the daemon will publish when it dequeues a task but can't
  acquire the path lock. The render is in place now so the daemon
  sibling subtask can wire the status string without an additional UI
  PR.

Plumbing:

- @multica/core/types gains LocalDirectoryResourceRef +
  UpdateProjectResourceRequest, and the api client gets the matching
  PUT method backed by the server endpoint that landed in
  2ac3faebb (MUL-2662). A useUpdateProjectResource hook drives the
  in-place label edit.
- New Electron handlers under apps/desktop/src/main/local-directory.ts:
    local-directory:pick     -> dialog.showOpenDialog (openDirectory)
    local-directory:validate -> stat + access(R_OK + W_OK)
  exposed through the preload as desktopAPI.pickDirectory /
  validateLocalDirectory. View code talks to them via a thin
  packages/views/platform helper that returns reason=unsupported on
  web instead of crashing.
- useLocalDaemonStatus exposes the local daemon's id, device name, and
  running flag from daemonAPI.onStatusChange so the renderer can do the
  cross-device match without coupling to the desktop preload typings.

Tests:

- pickStageKeys gets a unit test covering the new stage and proving
  the directory-release status outranks availability hints.
- LocalDirectoryHint tests cover the four render branches (no project,
  no daemon, foreign daemon, matching daemon).
- i18n parity stays green; new keys added under projects.resources.*
  and chat.status_pill.stages.waiting_for_directory_release in both
  locales.

Out of scope (will land separately):
- The daemon-side waiting/lock signal that flips the pill into the
  new state.
- Adding local_directory to the create-project modal's bulk
  attach flow.
- Docs page refresh for project-resources.mdx — left for the
  MUL-2618 umbrella sweep.

Co-authored-by: multica-agent <github@multica.ai>

* fix(desktop): hide rename for foreign daemon local_directory rows (MUL-2618)

Address review nit on #3273: the rename pencil was gated only by
`canEdit`, so a foreign / unknown-daemon row still showed it even
though the spec says cross-device rows are disabled. Gate rename on
`!mismatch` so it disappears on those rows; delete stays available
so a stale registration can still be dropped from any device.

Co-authored-by: multica-agent <github@multica.ai>

---------

Co-authored-by: multica-agent <github@multica.ai>

* feat(daemon): local_directory execution + path mutex + GC exception (MUL-2663) (#3274)

* feat(daemon): local_directory execution + path mutex + GC exception (MUL-2663)

Wires up the daemon side of the local_directory project_resource introduced
in MUL-2662. When a task is dispatched against a project whose resources
include a local_directory pinned to this daemon's UUID, the daemon now:

  - Validates the path (absolute, exists, daemon process can read+write,
    not in the system-root / $HOME blacklist) and fails the task fast on
    any precondition violation, with a user-readable reason.
  - Serialises concurrent tasks on the same on-disk path via a
    daemon-local LocalPathLocker keyed by symlink-resolved realpath. The
    lock is held for the entire task lifetime (claim → context write →
    agent → result report).
  - When the lock is contended, the daemon flips the row to a new
    waiting_local_directory status on the server (carrying a wait_reason
    like "<path> (held by task <short id>)") so the UI can render
    "等待本地目录释放" instead of leaving the row silently in dispatched
    past the sweeper timeout. The status accepts being woken into running
    once the lock is acquired.
  - Sets execenv.WorkDir to the user's path (no copy, no mount). envRoot
    still lives under workspacesRoot/<wsID>/ and hosts output/, logs/, and
    .gc_meta.json — the daemon's logbook for the run.
  - Stamps GCMeta.LocalDirectory=true so the GC loop never RemoveAlls
    envRoot for these tasks (gcActionClean → gcActionCleanArtifacts,
    gcActionOrphan → gcActionSkip). The user's directory was never under
    envRoot to begin with, so this is defense in depth.
  - Skips execenv.Reuse for local_directory tasks because the prior
    WorkDir is the user's path and reusing it through that code path
    loses the envRoot association the GC loop needs. Prepare is cheap
    here (no clone, no copy), so always running it is fine.

Server-side protocol changes:

  - New CHECK value 'waiting_local_directory' on agent_task_queue.status
    plus a wait_reason TEXT column (migration 109).
  - All cancel / active / counted-as-running / orphan-recovery queries
    expanded to include the new status; FailStaleTasks intentionally
    excludes it (the daemon owns the wait).
  - New SQL MarkAgentTaskWaitingLocalDirectory(id, reason) and a relaxed
    StartAgentTask that accepts both dispatched and
    waiting_local_directory as preconditions (and clears wait_reason on
    the way through).
  - New POST /api/daemon/tasks/{taskId}/wait-local-directory endpoint,
    TaskService.MarkTaskWaitingLocalDirectory broadcaster, and matching
    daemon Client.MarkTaskWaitingLocalDirectory.

Tests cover: path blacklist + R/W enforcement, mutex serialisation +
ctx-cancelled wait, lock handover between two tasks, GC never returns
gcActionClean / gcActionOrphan for local_directory rows (with negative
control for the standard path), and Prepare/Cleanup correctly substitute
+ protect the user's WorkDir.

The desktop UI side (UI for adding a local_directory resource, surfacing
the "等待本地目录" badge) is MUL-2665; the agent-task lifecycle changes
(no branch switch, dirty-tree tolerant, auto-commit) are MUL-2664.

This PR targets the shared MUL-2618 v1 feature branch agent/j/912b8cb1,
not main; the whole v1 will be merged to main together when complete.

Co-authored-by: multica-agent <github@multica.ai>

* fix(daemon): tighten local_directory status, symlink, cancel handling (MUL-2618)

Address the 3 must-fix items from Elon's review of PR #3274.

1. Status string unified. The server / daemon publish
   `waiting_local_directory`; align views, locales, and the
   pickStageKeys test (PR #3273 had used `waiting_for_directory_release`
   on a placeholder string). Without this, the daemon's wait state
   never reached the pill once the two siblings merged.

2. validateLocalPath now also runs the blacklist against the
   symlink-resolved realpath, with macOS's `/etc` -> `/private/etc`
   redirect handled via `isBlacklistedRealPath` which compares
   canonical forms. Without this, a symlink such as
   `/Users/me/proj/home -> /Users/me` slipped the literal $HOME check
   while every daemon write still landed in the user's home. Tests
   cover symlink-to-home, symlink-to-system-root, and the negative
   case (symlink to a regular subdirectory).

3. acquireLocalDirectoryLockIfNeeded now spins up a cancellation
   watcher inside `onWait` (lazy — the fast path stays free) so the
   gap between dispatch and StartTask responds to server-side cancel
   or row deletion. If the watcher fires while the daemon is parked
   on the path mutex, the lock-wait context is cancelled, Acquire
   returns promptly, and the helper exits silently the same way the
   run-phase poller does. New TestAcquireLocalDirectoryLock_CancelDuringWait
   exercises the path end-to-end with a fake server.

Co-authored-by: multica-agent <github@multica.ai>

* fix(daemon): unconditional canonical blacklist + Windows drive-root generalisation (MUL-2618)

- validateLocalPath now always runs isBlacklistedRealPath on the
  symlink-resolved path, not only when it differs from absPath. The old
  guard let users type the canonical form of an OS-symlinked banned root
  (e.g. /private/tmp, /private/etc, /private/var on macOS) straight
  through, since EvalSymlinks is a no-op on already-canonical input.
- Windows drive-root rejection moved off the static C/D/E/F enumeration
  onto filepath.VolumeName via a new isDriveRoot helper, so removable /
  network drives mounted at G:..Z: and UNC \\server\share roots are also
  blocked. systemRootBlacklist keeps the well-known C:\ trees only.
- Tests: macOS-only case exercises direct /private/{tmp,etc,var}; a
  new TestIsDriveRoot covers the Windows generalisation (skipped on
  POSIX runners by runtime guard).

Co-authored-by: multica-agent <github@multica.ai>

---------

Co-authored-by: multica-agent <github@multica.ai>

* feat(views): wire waiting_local_directory end-to-end in issue UI + presence (MUL-2618)

Connect the daemon-emitted `task:waiting_local_directory` and `task:running`
events through to issue execution log, sticky agent banner, activity indicator,
and agent presence so a parked task is no longer invisible on the issue page.

- Add `waiting_local_directory` to `AgentTask.status` and the typed
  `task:running` / `task:waiting_local_directory` WS event payloads.
- Chat realtime sync writes both new statuses into the pending-task cache so
  the chat StatusPill flips out of a stale `dispatched` frame.
- ExecutionLogSection: count `waiting_local_directory` as active, add tone +
  status label, treat parked tasks the same as dispatched for time anchor /
  transcript visibility / terminate-confirm note.
- AgentLiveCard: subscribe to both new events, rank the parked state between
  dispatched and queued, and surface a "is waiting for the local directory"
  banner with the muted "Clock" treatment used for queued.
- IssueAgentActivityIndicator: route parked tasks into the queued bucket so
  the hover stack and chip stay visible.
- derive-presence: parked tasks count toward `queuedCount` so the agent
  workload chip stays out of `idle` while the daemon waits on the path lock.
- Locales: add `agent_live.is_waiting_local_directory` and
  `execution_log.status_waiting_local_directory` (en + zh-Hans).

Co-authored-by: multica-agent <github@multica.ai>

* feat(project): enforce one local_directory per (project, daemon) (MUL-2618)

The daemon-side resolver picks the first matching local_directory by
daemon_id, so allowing two rows on the same daemon — even at different
paths — let the agent silently write into whichever sorted first. Tighten
the invariant top to bottom:

- server: `findLocalDirectoryConflict` rejects any second row sharing a
  daemon_id, regardless of `local_path` or label. Bundled-create surface in
  `CreateProject` runs the same daemon-scoped dedupe up front.
- daemon: `findLocalDirectoryAssignment` fails fast when it finds more than
  one row pinned to the current daemon (older API client / direct DB
  writes can still produce that state — refuse to guess).
- desktop UI: hide the "Add local directory" action once the current
  daemon owns a row on this project, with a hint and a defensive toast on
  the call path; foreign-daemon rows stay visible read-only as before.
- Tests:
  * daemon: new `two local_directory rows on this daemon fail fast` /
    `local_directory rows on different daemons coexist` cases.
  * handler: rewrite the legacy `LabelShadow` cases as
    `DaemonScopedConflict` / `BundledLocalDirectoryDaemonConflict` —
    asserts 409 on same-daemon different-path, 201 on per-daemon bundles.
- Locales: en + zh-Hans copy for the new hint + toast.

Co-authored-by: multica-agent <github@multica.ai>

* chore(sqlc): drop stale skills_local in UpdateAgentCustomEnv (MUL-2618)

Follow-up to the main-merge in 0f8e8ca7: the auto-merge preserved most
of main's skills_local revert but kept the column reference inside the
UpdateAgentCustomEnv scanner because that block hadn't been touched by
either side. Re-running `sqlc generate` regenerates the file without
skills_local in this query, matching the rest of the file and the
post-revert schema.

Co-authored-by: multica-agent <github@multica.ai>

* feat(create-project): binary source picker — repos OR local directory

Turn the create-project dialog's "Repos" pill into a binary Source
picker. A project's source is mutually exclusive: either a set of
GitHub repos (worktree mode, default) or a single local working
directory (local mode, desktop-only). Mirrors the constraint the
backend will enforce next.

Behavior:
- Pill shows the active mode's selection (GitHub icon + repo count, or
  folder icon + local label/path).
- Popover has a 2-tab segmented control at the top; the Local tab is
  hidden entirely on web (local_directory needs a daemon_id).
- Local tab requires the daemon online — amber notice + disabled picker
  when offline, re-renders automatically via useLocalDaemonStatus.
- Switching tabs preserves the other side's stash, but handleSubmit
  only emits the resource matching the active sourceMode, so abandoned
  picks never leak into the created project.

Backend mutual-exclusion validation + the resources-section
conditional-add-button still to come — this PR just unblocks the
dialog so it can be demoed.

* fix(mobile): cover waiting_local_directory in run row status maps (MUL-2618)

---------

Co-authored-by: multica-agent <github@multica.ai>
Co-authored-by: Multica J <j@multica.ai>
2026-05-27 13:44:31 +08:00

704 lines
19 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// Code generated by sqlc. DO NOT EDIT.
// versions:
// sqlc v1.31.1
// source: squad.sql
package db
import (
"context"
"github.com/jackc/pgx/v5/pgtype"
)
const addSquadMember = `-- name: AddSquadMember :one
INSERT INTO squad_member (squad_id, member_type, member_id, role)
VALUES ($1, $2, $3, $4)
RETURNING id, squad_id, member_type, member_id, role, created_at
`
type AddSquadMemberParams struct {
SquadID pgtype.UUID `json:"squad_id"`
MemberType string `json:"member_type"`
MemberID pgtype.UUID `json:"member_id"`
Role string `json:"role"`
}
func (q *Queries) AddSquadMember(ctx context.Context, arg AddSquadMemberParams) (SquadMember, error) {
row := q.db.QueryRow(ctx, addSquadMember,
arg.SquadID,
arg.MemberType,
arg.MemberID,
arg.Role,
)
var i SquadMember
err := row.Scan(
&i.ID,
&i.SquadID,
&i.MemberType,
&i.MemberID,
&i.Role,
&i.CreatedAt,
)
return i, err
}
const archiveSquad = `-- name: ArchiveSquad :one
UPDATE squad SET archived_at = now(), archived_by = $2, updated_at = now()
WHERE id = $1
RETURNING id, workspace_id, name, description, leader_id, creator_id, created_at, updated_at, archived_at, archived_by, avatar_url, instructions
`
type ArchiveSquadParams struct {
ID pgtype.UUID `json:"id"`
ArchivedBy pgtype.UUID `json:"archived_by"`
}
func (q *Queries) ArchiveSquad(ctx context.Context, arg ArchiveSquadParams) (Squad, error) {
row := q.db.QueryRow(ctx, archiveSquad, arg.ID, arg.ArchivedBy)
var i Squad
err := row.Scan(
&i.ID,
&i.WorkspaceID,
&i.Name,
&i.Description,
&i.LeaderID,
&i.CreatorID,
&i.CreatedAt,
&i.UpdatedAt,
&i.ArchivedAt,
&i.ArchivedBy,
&i.AvatarUrl,
&i.Instructions,
)
return i, err
}
const countSquadMembers = `-- name: CountSquadMembers :one
SELECT count(*) FROM squad_member WHERE squad_id = $1
`
func (q *Queries) CountSquadMembers(ctx context.Context, squadID pgtype.UUID) (int64, error) {
row := q.db.QueryRow(ctx, countSquadMembers, squadID)
var count int64
err := row.Scan(&count)
return count, err
}
const createSquad = `-- name: CreateSquad :one
INSERT INTO squad (workspace_id, name, description, leader_id, creator_id, avatar_url)
VALUES ($1, $2, $3, $4, $5, $6)
RETURNING id, workspace_id, name, description, leader_id, creator_id, created_at, updated_at, archived_at, archived_by, avatar_url, instructions
`
type CreateSquadParams struct {
WorkspaceID pgtype.UUID `json:"workspace_id"`
Name string `json:"name"`
Description string `json:"description"`
LeaderID pgtype.UUID `json:"leader_id"`
CreatorID pgtype.UUID `json:"creator_id"`
AvatarUrl pgtype.Text `json:"avatar_url"`
}
func (q *Queries) CreateSquad(ctx context.Context, arg CreateSquadParams) (Squad, error) {
row := q.db.QueryRow(ctx, createSquad,
arg.WorkspaceID,
arg.Name,
arg.Description,
arg.LeaderID,
arg.CreatorID,
arg.AvatarUrl,
)
var i Squad
err := row.Scan(
&i.ID,
&i.WorkspaceID,
&i.Name,
&i.Description,
&i.LeaderID,
&i.CreatorID,
&i.CreatedAt,
&i.UpdatedAt,
&i.ArchivedAt,
&i.ArchivedBy,
&i.AvatarUrl,
&i.Instructions,
)
return i, err
}
const getSquad = `-- name: GetSquad :one
SELECT id, workspace_id, name, description, leader_id, creator_id, created_at, updated_at, archived_at, archived_by, avatar_url, instructions FROM squad WHERE id = $1
`
func (q *Queries) GetSquad(ctx context.Context, id pgtype.UUID) (Squad, error) {
row := q.db.QueryRow(ctx, getSquad, id)
var i Squad
err := row.Scan(
&i.ID,
&i.WorkspaceID,
&i.Name,
&i.Description,
&i.LeaderID,
&i.CreatorID,
&i.CreatedAt,
&i.UpdatedAt,
&i.ArchivedAt,
&i.ArchivedBy,
&i.AvatarUrl,
&i.Instructions,
)
return i, err
}
const getSquadByAssignee = `-- name: GetSquadByAssignee :one
SELECT s.id, s.workspace_id, s.name, s.description, s.leader_id, s.creator_id, s.created_at, s.updated_at, s.archived_at, s.archived_by, s.avatar_url, s.instructions FROM squad s WHERE s.id = $1 AND s.workspace_id = $2
`
type GetSquadByAssigneeParams struct {
ID pgtype.UUID `json:"id"`
WorkspaceID pgtype.UUID `json:"workspace_id"`
}
// Look up the squad when an issue is assigned to a squad.
func (q *Queries) GetSquadByAssignee(ctx context.Context, arg GetSquadByAssigneeParams) (Squad, error) {
row := q.db.QueryRow(ctx, getSquadByAssignee, arg.ID, arg.WorkspaceID)
var i Squad
err := row.Scan(
&i.ID,
&i.WorkspaceID,
&i.Name,
&i.Description,
&i.LeaderID,
&i.CreatorID,
&i.CreatedAt,
&i.UpdatedAt,
&i.ArchivedAt,
&i.ArchivedBy,
&i.AvatarUrl,
&i.Instructions,
)
return i, err
}
const getSquadInWorkspace = `-- name: GetSquadInWorkspace :one
SELECT id, workspace_id, name, description, leader_id, creator_id, created_at, updated_at, archived_at, archived_by, avatar_url, instructions FROM squad WHERE id = $1 AND workspace_id = $2
`
type GetSquadInWorkspaceParams struct {
ID pgtype.UUID `json:"id"`
WorkspaceID pgtype.UUID `json:"workspace_id"`
}
func (q *Queries) GetSquadInWorkspace(ctx context.Context, arg GetSquadInWorkspaceParams) (Squad, error) {
row := q.db.QueryRow(ctx, getSquadInWorkspace, arg.ID, arg.WorkspaceID)
var i Squad
err := row.Scan(
&i.ID,
&i.WorkspaceID,
&i.Name,
&i.Description,
&i.LeaderID,
&i.CreatorID,
&i.CreatedAt,
&i.UpdatedAt,
&i.ArchivedAt,
&i.ArchivedBy,
&i.AvatarUrl,
&i.Instructions,
)
return i, err
}
const isSquadMember = `-- name: IsSquadMember :one
SELECT EXISTS(
SELECT 1 FROM squad_member
WHERE squad_id = $1 AND member_type = $2 AND member_id = $3
) AS is_member
`
type IsSquadMemberParams struct {
SquadID pgtype.UUID `json:"squad_id"`
MemberType string `json:"member_type"`
MemberID pgtype.UUID `json:"member_id"`
}
func (q *Queries) IsSquadMember(ctx context.Context, arg IsSquadMemberParams) (bool, error) {
row := q.db.QueryRow(ctx, isSquadMember, arg.SquadID, arg.MemberType, arg.MemberID)
var is_member bool
err := row.Scan(&is_member)
return is_member, err
}
const listAllSquads = `-- name: ListAllSquads :many
SELECT id, workspace_id, name, description, leader_id, creator_id, created_at, updated_at, archived_at, archived_by, avatar_url, instructions FROM squad WHERE workspace_id = $1 ORDER BY created_at ASC
`
func (q *Queries) ListAllSquads(ctx context.Context, workspaceID pgtype.UUID) ([]Squad, error) {
rows, err := q.db.Query(ctx, listAllSquads, workspaceID)
if err != nil {
return nil, err
}
defer rows.Close()
items := []Squad{}
for rows.Next() {
var i Squad
if err := rows.Scan(
&i.ID,
&i.WorkspaceID,
&i.Name,
&i.Description,
&i.LeaderID,
&i.CreatorID,
&i.CreatedAt,
&i.UpdatedAt,
&i.ArchivedAt,
&i.ArchivedBy,
&i.AvatarUrl,
&i.Instructions,
); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const listSquadMemberPreviewRows = `-- name: ListSquadMemberPreviewRows :many
SELECT
sm.squad_id,
sm.member_type,
sm.member_id,
sm.role
FROM squad_member sm
JOIN squad s ON s.id = sm.squad_id
WHERE s.workspace_id = $1 AND s.archived_at IS NULL
ORDER BY
sm.squad_id ASC,
(sm.member_type = 'agent' AND sm.member_id = s.leader_id) DESC,
sm.created_at ASC
`
type ListSquadMemberPreviewRowsRow struct {
SquadID pgtype.UUID `json:"squad_id"`
MemberType string `json:"member_type"`
MemberID pgtype.UUID `json:"member_id"`
Role string `json:"role"`
}
// Static squad membership summary for list/hover previews. This deliberately
// excludes derived runtime/task status; the squad detail members-status
// endpoint owns live state.
func (q *Queries) ListSquadMemberPreviewRows(ctx context.Context, workspaceID pgtype.UUID) ([]ListSquadMemberPreviewRowsRow, error) {
rows, err := q.db.Query(ctx, listSquadMemberPreviewRows, workspaceID)
if err != nil {
return nil, err
}
defer rows.Close()
items := []ListSquadMemberPreviewRowsRow{}
for rows.Next() {
var i ListSquadMemberPreviewRowsRow
if err := rows.Scan(
&i.SquadID,
&i.MemberType,
&i.MemberID,
&i.Role,
); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const listSquadMemberPreviewRowsBySquad = `-- name: ListSquadMemberPreviewRowsBySquad :many
SELECT
sm.squad_id,
sm.member_type,
sm.member_id,
sm.role
FROM squad_member sm
JOIN squad s ON s.id = sm.squad_id
WHERE sm.squad_id = $1
ORDER BY
(sm.member_type = 'agent' AND sm.member_id = s.leader_id) DESC,
sm.created_at ASC
`
type ListSquadMemberPreviewRowsBySquadRow struct {
SquadID pgtype.UUID `json:"squad_id"`
MemberType string `json:"member_type"`
MemberID pgtype.UUID `json:"member_id"`
Role string `json:"role"`
}
func (q *Queries) ListSquadMemberPreviewRowsBySquad(ctx context.Context, squadID pgtype.UUID) ([]ListSquadMemberPreviewRowsBySquadRow, error) {
rows, err := q.db.Query(ctx, listSquadMemberPreviewRowsBySquad, squadID)
if err != nil {
return nil, err
}
defer rows.Close()
items := []ListSquadMemberPreviewRowsBySquadRow{}
for rows.Next() {
var i ListSquadMemberPreviewRowsBySquadRow
if err := rows.Scan(
&i.SquadID,
&i.MemberType,
&i.MemberID,
&i.Role,
); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const listSquadMemberStatusRows = `-- name: ListSquadMemberStatusRows :many
SELECT
sm.id AS squad_member_id,
sm.member_type AS member_type,
sm.member_id AS member_id,
a.archived_at AS agent_archived_at,
ar.status AS runtime_status,
ar.last_seen_at AS runtime_last_seen_at,
atq.id AS task_id,
atq.status AS task_status,
atq.issue_id AS task_issue_id,
atq.dispatched_at AS task_dispatched_at,
i.number AS issue_number,
i.title AS issue_title,
i.status AS issue_status
FROM squad_member sm
LEFT JOIN agent a
ON sm.member_type = 'agent' AND a.id = sm.member_id
LEFT JOIN agent_runtime ar
ON ar.id = a.runtime_id
LEFT JOIN agent_task_queue atq
ON sm.member_type = 'agent'
AND atq.agent_id = sm.member_id
AND atq.status IN ('dispatched', 'running', 'waiting_local_directory')
LEFT JOIN issue i
ON i.id = atq.issue_id
WHERE sm.squad_id = $1
ORDER BY sm.created_at ASC, atq.dispatched_at DESC NULLS LAST
`
type ListSquadMemberStatusRowsRow struct {
SquadMemberID pgtype.UUID `json:"squad_member_id"`
MemberType string `json:"member_type"`
MemberID pgtype.UUID `json:"member_id"`
AgentArchivedAt pgtype.Timestamptz `json:"agent_archived_at"`
RuntimeStatus pgtype.Text `json:"runtime_status"`
RuntimeLastSeenAt pgtype.Timestamptz `json:"runtime_last_seen_at"`
TaskID pgtype.UUID `json:"task_id"`
TaskStatus pgtype.Text `json:"task_status"`
TaskIssueID pgtype.UUID `json:"task_issue_id"`
TaskDispatchedAt pgtype.Timestamptz `json:"task_dispatched_at"`
IssueNumber pgtype.Int4 `json:"issue_number"`
IssueTitle pgtype.Text `json:"issue_title"`
IssueStatus pgtype.Text `json:"issue_status"`
}
// Per-row join used to build the squad-members status view. One row per
// (squad_member × active_task); members with no active task return a
// single row with NULL task_* columns. Human members and agent members
// with no agent row also return one row with NULL agent_/runtime_ columns.
// The handler aggregates rows by member_id.
func (q *Queries) ListSquadMemberStatusRows(ctx context.Context, squadID pgtype.UUID) ([]ListSquadMemberStatusRowsRow, error) {
rows, err := q.db.Query(ctx, listSquadMemberStatusRows, squadID)
if err != nil {
return nil, err
}
defer rows.Close()
items := []ListSquadMemberStatusRowsRow{}
for rows.Next() {
var i ListSquadMemberStatusRowsRow
if err := rows.Scan(
&i.SquadMemberID,
&i.MemberType,
&i.MemberID,
&i.AgentArchivedAt,
&i.RuntimeStatus,
&i.RuntimeLastSeenAt,
&i.TaskID,
&i.TaskStatus,
&i.TaskIssueID,
&i.TaskDispatchedAt,
&i.IssueNumber,
&i.IssueTitle,
&i.IssueStatus,
); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const listSquadMembers = `-- name: ListSquadMembers :many
SELECT id, squad_id, member_type, member_id, role, created_at FROM squad_member WHERE squad_id = $1 ORDER BY created_at ASC
`
func (q *Queries) ListSquadMembers(ctx context.Context, squadID pgtype.UUID) ([]SquadMember, error) {
rows, err := q.db.Query(ctx, listSquadMembers, squadID)
if err != nil {
return nil, err
}
defer rows.Close()
items := []SquadMember{}
for rows.Next() {
var i SquadMember
if err := rows.Scan(
&i.ID,
&i.SquadID,
&i.MemberType,
&i.MemberID,
&i.Role,
&i.CreatedAt,
); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const listSquads = `-- name: ListSquads :many
SELECT id, workspace_id, name, description, leader_id, creator_id, created_at, updated_at, archived_at, archived_by, avatar_url, instructions FROM squad WHERE workspace_id = $1 AND archived_at IS NULL ORDER BY created_at ASC
`
func (q *Queries) ListSquads(ctx context.Context, workspaceID pgtype.UUID) ([]Squad, error) {
rows, err := q.db.Query(ctx, listSquads, workspaceID)
if err != nil {
return nil, err
}
defer rows.Close()
items := []Squad{}
for rows.Next() {
var i Squad
if err := rows.Scan(
&i.ID,
&i.WorkspaceID,
&i.Name,
&i.Description,
&i.LeaderID,
&i.CreatorID,
&i.CreatedAt,
&i.UpdatedAt,
&i.ArchivedAt,
&i.ArchivedBy,
&i.AvatarUrl,
&i.Instructions,
); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const listSquadsByMember = `-- name: ListSquadsByMember :many
SELECT s.id, s.workspace_id, s.name, s.description, s.leader_id, s.creator_id, s.created_at, s.updated_at, s.archived_at, s.archived_by, s.avatar_url, s.instructions FROM squad s
JOIN squad_member sm ON sm.squad_id = s.id
WHERE s.workspace_id = $1 AND sm.member_type = $2 AND sm.member_id = $3
ORDER BY s.created_at ASC
`
type ListSquadsByMemberParams struct {
WorkspaceID pgtype.UUID `json:"workspace_id"`
MemberType string `json:"member_type"`
MemberID pgtype.UUID `json:"member_id"`
}
// Find all squads a given entity belongs to in a workspace.
func (q *Queries) ListSquadsByMember(ctx context.Context, arg ListSquadsByMemberParams) ([]Squad, error) {
rows, err := q.db.Query(ctx, listSquadsByMember, arg.WorkspaceID, arg.MemberType, arg.MemberID)
if err != nil {
return nil, err
}
defer rows.Close()
items := []Squad{}
for rows.Next() {
var i Squad
if err := rows.Scan(
&i.ID,
&i.WorkspaceID,
&i.Name,
&i.Description,
&i.LeaderID,
&i.CreatorID,
&i.CreatedAt,
&i.UpdatedAt,
&i.ArchivedAt,
&i.ArchivedBy,
&i.AvatarUrl,
&i.Instructions,
); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const removeSquadMember = `-- name: RemoveSquadMember :execrows
DELETE FROM squad_member
WHERE squad_id = $1 AND member_type = $2 AND member_id = $3
`
type RemoveSquadMemberParams struct {
SquadID pgtype.UUID `json:"squad_id"`
MemberType string `json:"member_type"`
MemberID pgtype.UUID `json:"member_id"`
}
func (q *Queries) RemoveSquadMember(ctx context.Context, arg RemoveSquadMemberParams) (int64, error) {
result, err := q.db.Exec(ctx, removeSquadMember, arg.SquadID, arg.MemberType, arg.MemberID)
if err != nil {
return 0, err
}
return result.RowsAffected(), nil
}
const transferSquadAssignees = `-- name: TransferSquadAssignees :exec
UPDATE issue SET assignee_type = 'agent', assignee_id = $2, updated_at = now()
WHERE assignee_type = 'squad' AND assignee_id = $1
`
type TransferSquadAssigneesParams struct {
AssigneeID pgtype.UUID `json:"assignee_id"`
AssigneeID_2 pgtype.UUID `json:"assignee_id_2"`
}
// Transfer all issues assigned to a squad to the squad's leader agent.
func (q *Queries) TransferSquadAssignees(ctx context.Context, arg TransferSquadAssigneesParams) error {
_, err := q.db.Exec(ctx, transferSquadAssignees, arg.AssigneeID, arg.AssigneeID_2)
return err
}
const transferSquadAutopilotsToLeader = `-- name: TransferSquadAutopilotsToLeader :exec
UPDATE autopilot
SET assignee_type = 'agent',
assignee_id = $2,
updated_at = now()
WHERE assignee_type = 'squad' AND assignee_id = $1
`
type TransferSquadAutopilotsToLeaderParams struct {
AssigneeID pgtype.UUID `json:"assignee_id"`
AssigneeID_2 pgtype.UUID `json:"assignee_id_2"`
}
// Mirrors TransferSquadAssignees for autopilot rows: when a squad is archived,
// any autopilot still pointing at the squad would otherwise dangle and the
// admission gate would skip every subsequent dispatch with "assignee squad
// cannot be resolved". Rewrite the assignee in place to the leader agent so
// the autopilot keeps firing under the same leader-only execution semantics
// it had a moment before the archive (Path A from MUL-2429).
func (q *Queries) TransferSquadAutopilotsToLeader(ctx context.Context, arg TransferSquadAutopilotsToLeaderParams) error {
_, err := q.db.Exec(ctx, transferSquadAutopilotsToLeader, arg.AssigneeID, arg.AssigneeID_2)
return err
}
const updateSquad = `-- name: UpdateSquad :one
UPDATE squad SET
name = COALESCE($2, name),
description = COALESCE($3, description),
leader_id = COALESCE($4, leader_id),
avatar_url = COALESCE($5, avatar_url),
instructions = COALESCE($6, instructions),
updated_at = now()
WHERE id = $1
RETURNING id, workspace_id, name, description, leader_id, creator_id, created_at, updated_at, archived_at, archived_by, avatar_url, instructions
`
type UpdateSquadParams struct {
ID pgtype.UUID `json:"id"`
Name pgtype.Text `json:"name"`
Description pgtype.Text `json:"description"`
LeaderID pgtype.UUID `json:"leader_id"`
AvatarUrl pgtype.Text `json:"avatar_url"`
Instructions pgtype.Text `json:"instructions"`
}
func (q *Queries) UpdateSquad(ctx context.Context, arg UpdateSquadParams) (Squad, error) {
row := q.db.QueryRow(ctx, updateSquad,
arg.ID,
arg.Name,
arg.Description,
arg.LeaderID,
arg.AvatarUrl,
arg.Instructions,
)
var i Squad
err := row.Scan(
&i.ID,
&i.WorkspaceID,
&i.Name,
&i.Description,
&i.LeaderID,
&i.CreatorID,
&i.CreatedAt,
&i.UpdatedAt,
&i.ArchivedAt,
&i.ArchivedBy,
&i.AvatarUrl,
&i.Instructions,
)
return i, err
}
const updateSquadMemberRole = `-- name: UpdateSquadMemberRole :one
UPDATE squad_member SET role = $4
WHERE squad_id = $1 AND member_type = $2 AND member_id = $3
RETURNING id, squad_id, member_type, member_id, role, created_at
`
type UpdateSquadMemberRoleParams struct {
SquadID pgtype.UUID `json:"squad_id"`
MemberType string `json:"member_type"`
MemberID pgtype.UUID `json:"member_id"`
Role string `json:"role"`
}
func (q *Queries) UpdateSquadMemberRole(ctx context.Context, arg UpdateSquadMemberRoleParams) (SquadMember, error) {
row := q.db.QueryRow(ctx, updateSquadMemberRole,
arg.SquadID,
arg.MemberType,
arg.MemberID,
arg.Role,
)
var i SquadMember
err := row.Scan(
&i.ID,
&i.SquadID,
&i.MemberType,
&i.MemberID,
&i.Role,
&i.CreatedAt,
)
return i, err
}