Files
multica/server/pkg/db/generated/autopilot.sql.go
LinYushen cb68669c73 feat(composio): gate MCP apps behind feature flag (#4876)
* feat(composio): server-side connect flow + connections REST (Notion MVP) (MUL-3720) (#4608)

* feat(composio): server-side connect flow + connections REST (Notion MVP) (MUL-3720)

Compose the merged server/pkg/composio SDK into a user-facing connection
manager: signed-state connect handshake, local user_composio_connection
mirror, idempotent disconnect, and a per-user MCP session helper (not yet
wired into task dispatch).

- migration 127_user_composio_connection (no FK/cascade, per DB rules)
- sqlc queries: upsert (idempotent on user_id+connected_account_id), list
  active, owner-scoped get, mark revoked
- internal/integrations/composio: signed HMAC-SHA256 state, BeginConnect,
  CompleteCallback (idempotent upsert), ListConnections, Disconnect
  (upstream 404 = idempotent success), CreateMCPSession (no-op when empty,
  pins connected_accounts per toolkit), CallbackRedirect
- REST handlers under /api/integrations/composio (user-scoped, 503 when
  COMPOSIO_API_KEY unset): connect/init, callback (302), connections list,
  delete
- router wiring gated by COMPOSIO_API_KEY; COMPOSIO_AUTH_CONFIGS_JSON maps
  toolkit->auth_config (MVP: notion); state secret from COMPOSIO_STATE_SECRET
  or derived from JWT_SECRET; callback base from COMPOSIO_CALLBACK_BASE_URL
  or MULTICA_PUBLIC_URL
- tests: state (expire/tamper/wrong-secret), service (mapping, callback
  idempotency, non-success, disconnect owner/404 idempotency, MCP pin),
  handlers (httptest), redact regression for Bearer mcp_ tokens

MVP scope: Notion only; no task-dispatch overlay, sharing, or webhook
event handling (later stages).

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

* fix(composio): bind callback account to user + idempotent revoked disconnect (MUL-3720)

Address PR 4608 review (CHANGES_REQUESTED):

- callback: verify connected_account_id with Composio before mirroring it.
  The signed state only proved user/toolkit/exp, so a valid state paired with
  a tampered connected_account_id would be written verbatim. CompleteCallback
  now calls ListConnectedAccounts and fails closed (ErrAccountVerification)
  unless the account belongs to the state's user (composio_user_id == multica
  user id) and was created under the toolkit's auth config. No row is written
  on mismatch / unknown account / upstream error.

- disconnect: short-circuit to a no-op when the local row is already revoked,
  before touching upstream. Previously a second DELETE re-hit Composio and a
  non-404 upstream error surfaced as a 502, breaking the 204-idempotent
  contract.

- CreateMCPSession: document the v1 single-active-connection-per-(user,toolkit)
  constraint and make duplicate selection deterministic (newest-wins, rows are
  connected_at DESC) instead of order-dependent map overwrite. Stage 3 owns the
  real single-account-enforcement vs multi-account-shape decision.

Tests: tampered/wrong-auth-config/unknown-account callback rejection, revoked-row
disconnect no-op (asserts upstream not re-hit). composio pkg 85% coverage; all
green.

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

* feat(composio): list all toolkits + dynamic auth-config resolution (MUL-3720)

Yushen's follow-up to the Notion MVP: surface the full Composio toolkit
catalog, render it in Settings, and drop the static env mapping in favor of
dynamic auth-config discovery.

Config correctness (per Composio docs):
- Remove COMPOSIO_AUTH_CONFIGS_JSON entirely. The toolkit→auth_config mapping
  is now resolved at request time from the project's /auth_configs (cached,
  5-min TTL), so enabling a toolkit is a dashboard action, not a redeploy.
- Do NOT add COMPOSIO_PROJECT_ID. The project API key (x-api-key) authenticates
  to exactly one project; the project is resolved from the key. Only org-level
  endpoints use x-org-api-key, which this integration never calls.

Backend:
- SDK: server/pkg/composio/auth_configs.go — ListAuthConfigs (toolkit_slug,
  is_composio_managed, show_disabled, limit, cursor).
- service: dynamic resolver (authConfigMap cache; betterAuthConfig prefers a
  custom/white-label config over Composio-managed, newest wins); BeginConnect
  and CompleteCallback resolve via it; ListToolkits fetches the full catalog
  (paginated, capped) annotated with connectable = has an enabled auth config,
  connectable-first ordering.
- handler + route: GET /api/integrations/composio/toolkits (user-scoped, 503
  when COMPOSIO_API_KEY unset) returning slug/name/logo/category/connectable.

Frontend:
- core: ComposioToolkit/ComposioConnection types, api client methods, and
  composio query options (@multica/core/composio).
- views: Settings → Integrations now has a Composio section rendering every
  toolkit as a card with search. Connect is gated on `connectable`;
  non-connectable toolkits show a muted "not configured" hint instead of a
  dead button. Connected toolkits show a badge + Disconnect (with confirm).
- i18n: composio block added to en/zh-Hans/ja/ko settings.

Tests: SDK + service (dynamic resolution, custom-over-managed preference,
connectable flag, resolver-error soft-degrade) and handler toolkits endpoint;
composio pkg 85.7% coverage. go build/vet/gofmt clean; core+views typecheck,
core+views lint, and core tests (691) all green.

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

* fix(composio): close cross-toolkit callback fail-open by signing auth_config_id into state (MUL-3720)

Re-review blocker: CompleteCallback resolved the toolkit's auth config at
callback time and ignored a resolve error/empty result, while
verifyAccountOwnership skipped the auth-config comparison when the expected
value was empty. A user could then pass another toolkit's connected_account_id
into this toolkit's callback — the owner check passed and it was written under
the wrong toolkit_slug/account binding.

Fix: the auth_config_id is already resolved in BeginConnect (before the state
is signed), so sign it into the state and compare it exactly at callback. No
re-resolve, no fail-open. verifyAccountOwnership now fails closed when the
expected auth config is empty (rejects instead of skipping) and requires an
exact match — closing the cross-toolkit binding gap.

Tests: state round-trips auth_config_id; BeginConnect signs it; callback
rejects wrong/cross-toolkit auth config and an empty (no-mapping) auth config
fails closed. composio pkg 85.2% coverage, all green.

Frontend (non-blocking): the Composio settings tab now surfaces an error when
the connections query fails instead of silently rendering everything as
unconnected.

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

* fix(composio): hide Settings section entirely when integration unconfigured (MUL-3720)

Decision (option 2, hide-then-merge): don't show a card that leaks the internal
COMPOSIO_API_KEY env-var name to every end user. IntegrationsTab now gates the
whole Composio section (heading + body) on the toolkits query — a 503 means the
key is unset, so the section is withheld instead of rendering the not-configured
card. Admin-only setup guidance is a later, role-gated affordance.

Removed the notConfigured card (and now-unused ApiError import) from
ComposioTab; it only mounts when configured. views typecheck + lint clean.

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

---------

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

* feat(composio): Stage 2 frontend polish — callback toast, last_used & expired UI, e2e (MUL-3718) (#4688)

* feat(composio): callback toast + refresh, last_used & expired UI, e2e (MUL-3718)

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

* fix(composio): real callback redirect route + StrictMode-safe toast dedup (MUL-3718 review)

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

---------

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

* fix(composio): callback endpoint should not require Multica auth (MUL-3843) (#4709)

* fix(composio): move OAuth callback out of the Auth group (MUL-3843)

Composio 302-redirects the browser to /api/integrations/composio/callback
at the end of the OAuth flow, but PR #4608 mounted it inside the cookie-auth
middleware group. When the session cookie is absent (expired session,
SameSite=Strict / Safari ITP, private window, self-hosted callback subdomain)
the Auth middleware returned a hard 401 and a JSON blob instead of the
settings redirect, breaking the flow.

Identity never came from the cookie anyway: it is carried by the HMAC-signed
state param that CompleteCallback verifies (signature, expiry, replay) and
cross-checked by verifyAccountOwnership; h.Composio == nil still 503s. So the
callback is registered alongside the other public OAuth/webhook routes; the
other four composio endpoints stay session-gated.

Refs MUL-3843, MUL-3715.

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

* fix(composio): correct stale callback routing comments (MUL-3843)

The package header and ComposioCallback doc comments still described the
callback as sitting under the Auth middleware group. After the route was
moved out (this PR), update both to state it is a public route whose identity
comes from the signed state — addressing review nit from 张大彪.

Refs MUL-3843.

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

---------

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

* feat(composio): inject MCP overlay into agent runtime at task dispatch (MUL-3721) (#4704)

Stage 3 of the Composio epic. Wires the per-user Composio MCP session into
every agent task so the agent process sees the initiator's connected tools
without any prompt-time plumbing.

Server side
  - Migration 128 adds agent_task_queue.runtime_mcp_overlay JSONB plus a
    BEFORE-UPDATE trigger that wipes the column on any transition into a
    terminal status (completed / failed / cancelled). A trigger is the single
    source of truth — future queries that flip status cannot bypass it.
  - composio.Service.BuildTaskOverlay(userID) reuses CreateMCPSession and
    emits the Claude-style { mcpServers: { composio: { type: http, url,
    headers } } } shape the daemon's existing sidecar generators consume.
    Returns (nil, nil) on zero active connections so we never burn a
    Composio session for a user with nothing to call.
  - TaskService grows a Composio ComposioOverlayBuilder seam, wired in
    router.go after composiointeg.NewService succeeds. Five enqueue paths
    (issue / mention / quick-create / chat / auto-retry) attach the overlay
    after CreateAgentTask returns and before the daemon is notified — so
    every claim reads a settled row, with no second daemon hop. Best-effort:
    a builder failure logs and proceeds with no overlay.
  - resolveInitiatorFromTriggerComment derives the initiator user from the
    trigger comment when it was authored by a member. Agent-authored
    triggers are not treated as initiators (their connected-apps view is
    empty by construction).

Daemon side
  - handler/daemon.go claim path merges task.runtime_mcp_overlay onto
    agent.mcp_config via mergeMCPOverlay before populating
    TaskAgentData.McpConfig. Overlay wins on server-name collisions
    because it carries the live user-scoped session URL. Errors fall back
    to the agent config unchanged — a bad overlay must not surprise-disable
    saved MCP tools. The existing execenv sidecar generators (cursor /
    codex / openclaw / opencode / hermes-kiro) need no changes: they keep
    consuming the merged result through TaskAgentData.McpConfig.

Tests
  - 9 merge cases (mcp_overlay_test): both-nil short-circuit, agent-only
    pass-through, overlay-only canonicalization, two-side merge, name
    collision (overlay wins), top-level key preservation, malformed agent
    fallback, malformed overlay fallback, non-object server rejection.
  - 4 dispatch cases (composio): zero-connections returns nil without
    CreateSession, happy-path emits the right shape with the right user
    id, empty-URL defensive branch, SDK error surfacing.
  - 4 TaskService helper cases: nil Composio is a no-op (Queries-safe),
    invalid initiator does not call the builder, nil overlay skips the
    UPDATE, builder error swallowed without panic.
  - Migration 128 verified to roll up + down + up cleanly against the test
    database.

Out of scope (deferred): assignment-triggered enqueue paths with no
trigger comment get no overlay attached today (no initiator UUID flows
through enqueueIssueTask in that case). Retry paths recompute the overlay
fresh from the parent's initiator_user_id instead of inheriting the bearer
from the parent row, so a stale token can never resurface on a retry.

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

* feat(composio): per-agent allowlist + originator-scoped MCP overlay (MUL-3869) (#4736)

* feat(composio): per-agent allowlist + originator-scoped MCP overlay (MUL-3869)

Stage 3.1 of the Composio epic (MUL-3721 parent). PR #4704 wired in the
runtime_mcp_overlay column and a per-task dispatch hook; this change
inverts the default from "all-on" to opt-in and locks the overlay to the
agent owner's own connected apps:

- Agents carry composio_toolkit_allowlist TEXT[]. NULL or [] => no MCP.
  Owner-only read/write; non-owner GET/PUT silently redacts/drops the
  field (same shape as mcp_config).
- agent_task_queue carries originator_user_id UUID. Set from the
  top-of-chain HUMAN at every enqueue path:
    * issue/mention comment by member  -> author_id
    * issue/mention comment by agent   -> inherit via comment.source_task_id
                                          -> parent task originator_user_id
    * quick-create                     -> requester_id
    * chat                             -> initiator_user_id
    * retry                            -> SQL-inherited from parent row
    * autopilot                        -> NULL (system-driven)
- BuildTaskOverlay (composio dispatch) now takes (ctx, originatorUserID,
  agent) and short-circuits on five gates: invalid originator,
  originator != agent.owner_id, empty allowlist, empty intersection of
  allowlist ∩ active connections, defensive empty session URL. Composio
  CreateSession is called with BOTH `toolkits.slugs` (the intersection)
  AND `connected_accounts` (the pinned account ids), narrowing the
  tool-router twice.
- The originator-vs-owner gate closes the agent-fanout privacy hole: any
  workspace member who can @-mention a public agent used to project the
  owner's connected apps into their run. Now the overlay only mounts
  when the human at the top of the chain IS the agent owner.

Tests:
- dispatch_test.go covers all 5 gates plus uppercase/whitespace slug
  normalisation.
- task_runtime_mcp_overlay_test.go covers the no-op gates of the new
  applyRuntimeMCPOverlay signature.
- agent_composio_allowlist_test.go (handler): owner roundtrip
  (list/empty/null), workspace-admin silent-drop, owner-only GET
  visibility, pure normaliseComposioToolkitAllowlist.
- resolve_originator_test.go (service, DB-backed): member-authored,
  agent-authored inherits via comment.source_task_id, invalid id.

Migration 129 up/down/up verified against docker postgres.

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

* chore(composio): gofmt + regenerate sqlc with v1.31.1 (MUL-3869 review nits)

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

---------

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

* fix(composio): accept nested connected account auth config

* feat(views): creator-only MCP tab for per-agent Composio allowlist (MUL-3870) (#4743)

Stage 3.2 frontend on top of the Stage 3.1 backend (MUL-3869, 4708dba97).
Adds an agent-detail tab that lets the agent owner pick which of their own
active Composio connections this agent may mount as MCP servers, writing the
selection to agent.composio_toolkit_allowlist via the existing PUT /api/agents.

- core/types: composio_toolkit_allowlist (+ _redacted) on Agent; tri-state
  composio_toolkit_allowlist on UpdateAgentRequest (omit/no-change, null/clear,
  array/replace), matching the backend contract.
- core/agents: useUpdateAgentAllowlist - optimistic mutation hook (patches the
  cached workspace agent list, rolls back on error, invalidates on settle).
- views: AgentMcpTab renders the owner's active connections as checkboxes;
  empty state links to Settings -> Integrations; defensive redacted state.
- views: wired into AgentOverviewPane as tab "composio_mcp", labeled "MCP Apps"
  to disambiguate from the existing raw-JSON "MCP" (mcp_config) tab. The entry
  is gated to the creator (currentUserId === agent.owner_id), matching the
  backend's owner-only read/write of the allowlist.
- i18n: tabs.composio_mcp + tab_body.composio_mcp.* in en/ja/ko/zh-Hans.
- tests: agent-mcp-tab.test.tsx (gating, toggle->allowlist body, active-only,
  empty, redacted); e2e/agent-mcp.spec.ts (creator sees tab + PUT body,
  non-creator hidden) with Composio + agent endpoints mocked at the boundary.

Note: the product spec says "creator"; the schema has no creator_id - the
backend gate and redaction are keyed on owner_id, so the tab uses owner_id.

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

* fix(composio): mount remote MCP for codex

* feat(agents): agent invocation permission system (MUL-3963) (#4844)

* feat(agents): agent invocation permission system (permission_mode + invocation targets)

MUL-3963: split who may INVOKE an agent out of the overloaded visibility
column into an explicit, extensible model on feature/composio-integration.

- DB: agent.permission_mode (private|public_to) + agent_invocation_target
  table (workspace/member/team targets) + lossless backfill from visibility
  (migration 130).
- canInvokeAgent: owner-only for private (NO admin bypass, NO A2A bypass);
  public_to honours the allow-list; A2A judged by the top-of-chain originator.
- All trigger paths rewired: issue assign, comment @agent/@squad, chat,
  quick-create, autopilot, squad leader, child-done.
- Agent API: permission_mode + invocation_targets on responses and
  create/update (owner-only writes); legacy visibility kept as a derived field
  so old clients never see a permission widening.
- Composio: BuildTaskOverlay now FOLLOWS invocation permission and uses the
  agent OWNER connection (removed the originator==owner gate); front-end warns
  when a shared agent enables Composio apps.
- CLI: --permission-mode / --public-to-workspace / --public-to-member (legacy
  --visibility still mapped).
- Frontend: AccessPicker (Private / workspace / specific people / team soon),
  permission rules mirror canInvokeAgent, Composio warning banner.
- Tests: migration backfill, admin cannot invoke others private, public_to
  workspace/member whitelist, A2A by originator, Composio overlay uses owner
  connection.

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

* feat(agents): stackable, mixed public_to invocation targets (MUL-3963)

Follow-up on PR #4844: public_to now supports selecting MULTIPLE, MIXED
targets on one agent (e.g. Public to workspace + specific people + team),
with canInvokeAgent admitting on ANY matching target (OR).

- Frontend AccessPicker: reworked from a single exclusive kind into a
  stackable multi-select — an "Everyone in workspace" toggle, a member
  multi-select checklist, and a (disabled, v1) team placeholder can be
  combined freely. Emits the full union of selected targets; empty union
  collapses to Private. Existing team targets are preserved across saves.
  Added the access.public_group locale string (en/zh-Hans/ja/ko).
- Backend already supported this (agent_invocation_target is multi-row per
  agent; create/update take a target ARRAY and batch-replace the whole
  allow-list; canInvokeAgent OR-matches). Added tests to lock it in:
  mixed member+team targets, overlapping-member batch replace, and
  workspace+member stacking then narrowing.

Refs MUL-3963.

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

* fix(agents): address review on invocation permission (MUL-3963)

张大彪 review on PR #4844 — three blockers + product ruling + nits:

1. Migration 130: drop the FK/cascade on agent_invocation_target
   (agent_id, created_by) per the Multica no-FK rule; relationships are now
   maintained in the app layer (matching MUL-3515 §4). Added
   DeleteAgentInvocationTargetsByArchivedRuntimeAgents and call it before
   DeleteArchivedAgentsByRuntime in all three runtime-delete paths
   (runtime.go x2, runtime_profile.go) so hard-deleting agents can't orphan
   target rows.
2. revokeAndRemoveMember: prune the leaving member's member-target grants
   (DeleteAgentInvocationTargetsByMember) in the same tx as the member-row
   delete, so a re-invited user can't reclaim a stale invocation grant.
3. Empty public_to is a phantom — parsePermissionInput now normalises a
   public_to with no resolvable targets to a single workspace target, so
   `--permission-mode public_to` alone (and any empty target array) means
   "public to workspace" instead of "shared but nobody can run it".

Product ruling: the system/no-human-originator → workspace-target path in
canInvokeAgent is a deliberate, documented exception (webhook/system/
workspace-wide automation); member/team targets still fail closed without a
resolved originator. Documented in code + locked with a test.

Nits: refreshed the stale "originator must be owner" comments — models.go
(via migration 130 COMMENT ON COLUMN + sqlc regen for composio_toolkit_allowlist
and originator_user_id) and agent-mcp-tab.tsx — to the owner-connection +
invocation-permission rules.

Tests: member remove/re-add regression, system workspace exception + member
fail-closed, empty public_to → workspace (plus the earlier mixed/overlap/
batch-replace suite). Migration 130 applied to the test DB; Go handler/service/
composio suites green; views typecheck clean.

Refs MUL-3963.

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

* fix(agents): scope member invocation-target cleanup to one workspace (MUL-3963)

张大彪 3rd review — cross-workspace permission bug + comment nits:

- DeleteAgentInvocationTargetsByMember was a GLOBAL delete by user id, so
  removing a user from workspace A also wiped their member-target grants on
  agents in workspace B. Scoped it to a single workspace by joining through
  agent.workspace_id; revokeAndRemoveMember now passes (workspaceID, userID).
- Regression test TestRevokeMember_InvocationTargetCleanupIsWorkspaceScoped:
  same user allow-listed by agents in two workspaces; removal from one leaves
  the other workspace's target intact.
- Nits: refreshed the remaining stale "originator == agent.owner_id" /
  "owner-vs-originator" comments — CreateRetryTask (agent.sql, regenerated),
  and the AgentResponse allowlist doc + ListAgents/UpdateAgent redaction
  rationale in agent.go — to the owner-connection + invocation-permission rule.

Migration 130 applied to the test DB; Go handler/service/composio suites green;
go vet clean.

Refs MUL-3963.

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

---------

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

* fix(agents): agent access owner-only editable, read-only for others (MUL-3963) (#4853)

* fix(agents): make agent access owner-only editable, read-only for others (MUL-3963)

Interaction bug: a non-owner (incl. workspace admin) could open the AccessPicker
and set an agent public — the backend silently ignored it and the UI bounced
back to private. Access is owner-only, so non-owners must see a read-only state
and the backend must reject real changes explicitly.

Frontend:
- AccessPicker renders a static, non-interactive read-only state when the
  viewer is not the owner: the current access value + a lock affordance + a
  tooltip "Only the agent owner can change who can run this agent." No clickable
  trigger is rendered, so a non-owner can never open a control the backend would
  reject (the GitHub/Notion pattern for permission settings you can see but not
  edit). The editable multi-select picker is unchanged for the owner.
- agent-detail-inspector gates the picker on ownership specifically
  (currentUserId === agent.owner_id), NOT the general canEdit (which also admits
  admins, who may edit other fields but not access).
- New locale key access.owner_only_readonly (en/zh-Hans/ja/ko).

Backend:
- UpdateAgent now returns an explicit 403 when a non-owner submits a REAL
  permission change (permissionInputChangesAgent compares requested mode +
  target set against the persisted state); a no-op resubmit (admin PATCH-as-PUT
  echoing unchanged permission) is still tolerated so admin edits of other
  fields keep working. Replaces the previous silent-drop that caused the bounce.

Tests:
- access-picker.test.tsx: non-owner gets a non-interactive read-only display
  with the owner-only tooltip; owner gets an interactive picker; owner can pick
  a member and stack workspace + member.
- TestUpdateAgent_AccessChangeIsOwnerOnly: admin real change → 403; admin no-op
  resubmit → 200; admin editing other fields → 200; owner change → 200.

Incidental: fixed a pre-existing base typecheck break in
slash-command-suggestion.test.tsx (stray `signal` arg not in the suggestion
items type) that otherwise fails the whole @multica/views typecheck.

Refs MUL-3963.

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

* fix(agents): compare legacy visibility, not expanded permission, for no-op detection (MUL-3963)

PR #4853 review: permissionInputChangesAgent expanded a legacy-only
visibility:"private" into a real private permission and compared it against the
agent's actual permission. A member-only public_to agent derives legacy
visibility "private", so an admin PATCH-as-PUT echoing visibility:"private"
while editing another field was misread as a public_to→private downgrade and
rejected with 403 — contradicting the "unchanged permission no-op is allowed"
contract.

Fix (per review): when a request carries ONLY legacy `visibility` (no
permission_mode / invocation_targets), derive the agent's CURRENT legacy
visibility from its real targets and compare the legacy string values. Equal =
no-op (allowed); a real legacy change (e.g. "workspace") still returns 403.
Requests that carry permission_mode / invocation_targets keep the precise
mode+target comparison.

Regression test TestUpdateAgent_LegacyVisibilityNoOpForMemberOnlyPublicTo:
member-only public_to agent — admin submitting visibility:"private" + a
non-permission field → 200 with targets unchanged; admin submitting
visibility:"workspace" → 403.

Go handler/composio suites green; migration 130 applied; go vet clean.

Refs MUL-3963.

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

---------

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

* feat(composio): brief agents on connected apps

* feat(composio): gate MCP apps behind feature flag

* fix(mobile): parse agent invocation permissions

* fix(tests): update agent fixtures for access fields

---------

Co-authored-by: multica-agent <github@multica.ai>
Co-authored-by: Multica Eve <eve@devv.ai>
Co-authored-by: Eve <eve@multica.ai>
Co-authored-by: Eve <eve@multica-ai.local>
2026-07-03 14:18:43 +08:00

1649 lines
51 KiB
Go

// Code generated by sqlc. DO NOT EDIT.
// versions:
// sqlc v1.31.1
// source: autopilot.sql
package db
import (
"context"
"github.com/jackc/pgx/v5/pgtype"
)
const addAutopilotCollaborator = `-- name: AddAutopilotCollaborator :one
INSERT INTO autopilot_collaborator (autopilot_id, user_type, user_id, granted_by)
VALUES ($1, $2, $3, $4)
ON CONFLICT (autopilot_id, user_type, user_id)
DO UPDATE SET granted_by = EXCLUDED.granted_by
RETURNING autopilot_id, user_type, user_id, granted_by, created_at
`
type AddAutopilotCollaboratorParams struct {
AutopilotID pgtype.UUID `json:"autopilot_id"`
UserType string `json:"user_type"`
UserID pgtype.UUID `json:"user_id"`
GrantedBy pgtype.UUID `json:"granted_by"`
}
// Re-granting an existing collaborator is a no-op that refreshes granted_by,
// so the call is idempotent from the API boundary.
func (q *Queries) AddAutopilotCollaborator(ctx context.Context, arg AddAutopilotCollaboratorParams) (AutopilotCollaborator, error) {
row := q.db.QueryRow(ctx, addAutopilotCollaborator,
arg.AutopilotID,
arg.UserType,
arg.UserID,
arg.GrantedBy,
)
var i AutopilotCollaborator
err := row.Scan(
&i.AutopilotID,
&i.UserType,
&i.UserID,
&i.GrantedBy,
&i.CreatedAt,
)
return i, err
}
const addAutopilotSubscriber = `-- name: AddAutopilotSubscriber :exec
INSERT INTO autopilot_subscriber (autopilot_id, user_type, user_id)
VALUES ($1, $2, $3)
ON CONFLICT (autopilot_id, user_type, user_id) DO NOTHING
`
type AddAutopilotSubscriberParams struct {
AutopilotID pgtype.UUID `json:"autopilot_id"`
UserType string `json:"user_type"`
UserID pgtype.UUID `json:"user_id"`
}
func (q *Queries) AddAutopilotSubscriber(ctx context.Context, arg AddAutopilotSubscriberParams) error {
_, err := q.db.Exec(ctx, addAutopilotSubscriber, arg.AutopilotID, arg.UserType, arg.UserID)
return err
}
const advanceTriggerNextRun = `-- name: AdvanceTriggerNextRun :exec
UPDATE autopilot_trigger
SET next_run_at = $2,
last_fired_at = now(),
updated_at = now()
WHERE id = $1
`
type AdvanceTriggerNextRunParams struct {
ID pgtype.UUID `json:"id"`
NextRunAt pgtype.Timestamptz `json:"next_run_at"`
}
func (q *Queries) AdvanceTriggerNextRun(ctx context.Context, arg AdvanceTriggerNextRunParams) error {
_, err := q.db.Exec(ctx, advanceTriggerNextRun, arg.ID, arg.NextRunAt)
return err
}
const createAutopilot = `-- name: CreateAutopilot :one
INSERT INTO autopilot (
workspace_id, title, description, assignee_type, assignee_id,
status, execution_mode, issue_title_template, project_id,
created_by_type, created_by_id
) VALUES (
$1, $2, $9, $3, $4,
$5, $6, $10, $11,
$7, $8
) RETURNING id, workspace_id, title, description, assignee_id, status, execution_mode, issue_title_template, created_by_type, created_by_id, last_run_at, created_at, updated_at, assignee_type, project_id
`
type CreateAutopilotParams struct {
WorkspaceID pgtype.UUID `json:"workspace_id"`
Title string `json:"title"`
AssigneeType string `json:"assignee_type"`
AssigneeID pgtype.UUID `json:"assignee_id"`
Status string `json:"status"`
ExecutionMode string `json:"execution_mode"`
CreatedByType string `json:"created_by_type"`
CreatedByID pgtype.UUID `json:"created_by_id"`
Description pgtype.Text `json:"description"`
IssueTitleTemplate pgtype.Text `json:"issue_title_template"`
ProjectID pgtype.UUID `json:"project_id"`
}
func (q *Queries) CreateAutopilot(ctx context.Context, arg CreateAutopilotParams) (Autopilot, error) {
row := q.db.QueryRow(ctx, createAutopilot,
arg.WorkspaceID,
arg.Title,
arg.AssigneeType,
arg.AssigneeID,
arg.Status,
arg.ExecutionMode,
arg.CreatedByType,
arg.CreatedByID,
arg.Description,
arg.IssueTitleTemplate,
arg.ProjectID,
)
var i Autopilot
err := row.Scan(
&i.ID,
&i.WorkspaceID,
&i.Title,
&i.Description,
&i.AssigneeID,
&i.Status,
&i.ExecutionMode,
&i.IssueTitleTemplate,
&i.CreatedByType,
&i.CreatedByID,
&i.LastRunAt,
&i.CreatedAt,
&i.UpdatedAt,
&i.AssigneeType,
&i.ProjectID,
)
return i, err
}
const createAutopilotRun = `-- name: CreateAutopilotRun :one
INSERT INTO autopilot_run (
autopilot_id, trigger_id, source, status, trigger_payload, squad_id, planned_at
) VALUES (
$1, $4, $2, $3, $5,
$6, $7
) RETURNING id, autopilot_id, trigger_id, source, status, issue_id, task_id, triggered_at, completed_at, failure_reason, trigger_payload, result, created_at, squad_id, planned_at
`
type CreateAutopilotRunParams struct {
AutopilotID pgtype.UUID `json:"autopilot_id"`
Source string `json:"source"`
Status string `json:"status"`
TriggerID pgtype.UUID `json:"trigger_id"`
TriggerPayload []byte `json:"trigger_payload"`
SquadID pgtype.UUID `json:"squad_id"`
PlannedAt pgtype.Timestamptz `json:"planned_at"`
}
// =====================
// Autopilot Run Management
// =====================
// squad_id is an attribution hook: set to the assignee squad when the
// parent autopilot has assignee_type='squad', NULL otherwise. The executing
// agent_id on agent_task_queue still records who actually ran the work
// (the squad leader); squad_id lets reports group by squad without a join.
//
// planned_at carries the canonical UTC fire time for scheduled triggers
// (source='schedule'); it stays NULL for manual / webhook / api sources
// which have no canonical occurrence. Combined with the partial unique
// index uq_autopilot_run_trigger_planned, this gives dispatch-layer
// idempotency: a stale-steal retry at the same plan_time cannot create
// a second run for the same (trigger_id, planned_at) pair (MUL-3551).
func (q *Queries) CreateAutopilotRun(ctx context.Context, arg CreateAutopilotRunParams) (AutopilotRun, error) {
row := q.db.QueryRow(ctx, createAutopilotRun,
arg.AutopilotID,
arg.Source,
arg.Status,
arg.TriggerID,
arg.TriggerPayload,
arg.SquadID,
arg.PlannedAt,
)
var i AutopilotRun
err := row.Scan(
&i.ID,
&i.AutopilotID,
&i.TriggerID,
&i.Source,
&i.Status,
&i.IssueID,
&i.TaskID,
&i.TriggeredAt,
&i.CompletedAt,
&i.FailureReason,
&i.TriggerPayload,
&i.Result,
&i.CreatedAt,
&i.SquadID,
&i.PlannedAt,
)
return i, err
}
const createAutopilotTask = `-- name: CreateAutopilotTask :one
INSERT INTO agent_task_queue (agent_id, runtime_id, issue_id, status, priority, autopilot_run_id, trigger_summary)
VALUES ($1, $2, NULL, 'queued', $3, $4, $5)
RETURNING id, agent_id, issue_id, status, priority, dispatched_at, started_at, completed_at, result, error, created_at, context, runtime_id, session_id, work_dir, trigger_comment_id, chat_session_id, autopilot_run_id, attempt, max_attempts, parent_task_id, failure_reason, trigger_summary, force_fresh_session, is_leader_task, wait_reason, initiator_user_id, handoff_note, prepare_lease_expires_at, squad_id, runtime_mcp_overlay, escalation_for_task_id, fire_at, originator_user_id, runtime_connected_apps
`
type CreateAutopilotTaskParams struct {
AgentID pgtype.UUID `json:"agent_id"`
RuntimeID pgtype.UUID `json:"runtime_id"`
Priority int32 `json:"priority"`
AutopilotRunID pgtype.UUID `json:"autopilot_run_id"`
TriggerSummary pgtype.Text `json:"trigger_summary"`
}
// =====================
// Task Queue (run_only mode)
// =====================
func (q *Queries) CreateAutopilotTask(ctx context.Context, arg CreateAutopilotTaskParams) (AgentTaskQueue, error) {
row := q.db.QueryRow(ctx, createAutopilotTask,
arg.AgentID,
arg.RuntimeID,
arg.Priority,
arg.AutopilotRunID,
arg.TriggerSummary,
)
var i AgentTaskQueue
err := row.Scan(
&i.ID,
&i.AgentID,
&i.IssueID,
&i.Status,
&i.Priority,
&i.DispatchedAt,
&i.StartedAt,
&i.CompletedAt,
&i.Result,
&i.Error,
&i.CreatedAt,
&i.Context,
&i.RuntimeID,
&i.SessionID,
&i.WorkDir,
&i.TriggerCommentID,
&i.ChatSessionID,
&i.AutopilotRunID,
&i.Attempt,
&i.MaxAttempts,
&i.ParentTaskID,
&i.FailureReason,
&i.TriggerSummary,
&i.ForceFreshSession,
&i.IsLeaderTask,
&i.WaitReason,
&i.InitiatorUserID,
&i.HandoffNote,
&i.PrepareLeaseExpiresAt,
&i.SquadID,
&i.RuntimeMcpOverlay,
&i.EscalationForTaskID,
&i.FireAt,
&i.OriginatorUserID,
&i.RuntimeConnectedApps,
)
return i, err
}
const createAutopilotTrigger = `-- name: CreateAutopilotTrigger :one
INSERT INTO autopilot_trigger (
autopilot_id, kind, enabled, cron_expression, timezone,
next_run_at, webhook_token, label, provider, event_filters
) VALUES (
$1, $2, $3, $4, $5,
$6, $7, $8,
COALESCE($9::text, 'generic'),
$10
) RETURNING id, autopilot_id, kind, enabled, cron_expression, timezone, next_run_at, webhook_token, label, last_fired_at, created_at, updated_at, provider, signing_secret, event_filters
`
type CreateAutopilotTriggerParams struct {
AutopilotID pgtype.UUID `json:"autopilot_id"`
Kind string `json:"kind"`
Enabled bool `json:"enabled"`
CronExpression pgtype.Text `json:"cron_expression"`
Timezone pgtype.Text `json:"timezone"`
NextRunAt pgtype.Timestamptz `json:"next_run_at"`
WebhookToken pgtype.Text `json:"webhook_token"`
Label pgtype.Text `json:"label"`
Provider pgtype.Text `json:"provider"`
EventFilters []byte `json:"event_filters"`
}
func (q *Queries) CreateAutopilotTrigger(ctx context.Context, arg CreateAutopilotTriggerParams) (AutopilotTrigger, error) {
row := q.db.QueryRow(ctx, createAutopilotTrigger,
arg.AutopilotID,
arg.Kind,
arg.Enabled,
arg.CronExpression,
arg.Timezone,
arg.NextRunAt,
arg.WebhookToken,
arg.Label,
arg.Provider,
arg.EventFilters,
)
var i AutopilotTrigger
err := row.Scan(
&i.ID,
&i.AutopilotID,
&i.Kind,
&i.Enabled,
&i.CronExpression,
&i.Timezone,
&i.NextRunAt,
&i.WebhookToken,
&i.Label,
&i.LastFiredAt,
&i.CreatedAt,
&i.UpdatedAt,
&i.Provider,
&i.SigningSecret,
&i.EventFilters,
)
return i, err
}
const deleteAutopilot = `-- name: DeleteAutopilot :exec
DELETE FROM autopilot WHERE id = $1
`
func (q *Queries) DeleteAutopilot(ctx context.Context, id pgtype.UUID) error {
_, err := q.db.Exec(ctx, deleteAutopilot, id)
return err
}
const deleteAutopilotCollaborator = `-- name: DeleteAutopilotCollaborator :exec
DELETE FROM autopilot_collaborator
WHERE autopilot_id = $1 AND user_type = $2 AND user_id = $3
`
type DeleteAutopilotCollaboratorParams struct {
AutopilotID pgtype.UUID `json:"autopilot_id"`
UserType string `json:"user_type"`
UserID pgtype.UUID `json:"user_id"`
}
func (q *Queries) DeleteAutopilotCollaborator(ctx context.Context, arg DeleteAutopilotCollaboratorParams) error {
_, err := q.db.Exec(ctx, deleteAutopilotCollaborator, arg.AutopilotID, arg.UserType, arg.UserID)
return err
}
const deleteAutopilotCollaboratorsForAutopilot = `-- name: DeleteAutopilotCollaboratorsForAutopilot :exec
DELETE FROM autopilot_collaborator
WHERE autopilot_id = $1
`
// Application-layer cleanup run inside the autopilot delete transaction.
func (q *Queries) DeleteAutopilotCollaboratorsForAutopilot(ctx context.Context, autopilotID pgtype.UUID) error {
_, err := q.db.Exec(ctx, deleteAutopilotCollaboratorsForAutopilot, autopilotID)
return err
}
const deleteAutopilotSubscribersForAutopilot = `-- name: DeleteAutopilotSubscribersForAutopilot :exec
DELETE FROM autopilot_subscriber
WHERE autopilot_id = $1
`
// Paired with a re-insert loop to implement full-replace PATCH semantics.
func (q *Queries) DeleteAutopilotSubscribersForAutopilot(ctx context.Context, autopilotID pgtype.UUID) error {
_, err := q.db.Exec(ctx, deleteAutopilotSubscribersForAutopilot, autopilotID)
return err
}
const deleteAutopilotTrigger = `-- name: DeleteAutopilotTrigger :exec
DELETE FROM autopilot_trigger WHERE id = $1
`
func (q *Queries) DeleteAutopilotTrigger(ctx context.Context, id pgtype.UUID) error {
_, err := q.db.Exec(ctx, deleteAutopilotTrigger, id)
return err
}
const failAutopilotRunsByIssue = `-- name: FailAutopilotRunsByIssue :exec
UPDATE autopilot_run
SET status = 'failed', completed_at = now(), failure_reason = 'linked issue was deleted'
WHERE issue_id = $1
AND status IN ('issue_created', 'running')
`
// Fails active autopilot runs linked to a given issue.
// Must be called BEFORE issue deletion (ON DELETE SET NULL clears issue_id).
func (q *Queries) FailAutopilotRunsByIssue(ctx context.Context, issueID pgtype.UUID) error {
_, err := q.db.Exec(ctx, failAutopilotRunsByIssue, issueID)
return err
}
const getAutopilot = `-- name: GetAutopilot :one
SELECT id, workspace_id, title, description, assignee_id, status, execution_mode, issue_title_template, created_by_type, created_by_id, last_run_at, created_at, updated_at, assignee_type, project_id FROM autopilot
WHERE id = $1
`
func (q *Queries) GetAutopilot(ctx context.Context, id pgtype.UUID) (Autopilot, error) {
row := q.db.QueryRow(ctx, getAutopilot, id)
var i Autopilot
err := row.Scan(
&i.ID,
&i.WorkspaceID,
&i.Title,
&i.Description,
&i.AssigneeID,
&i.Status,
&i.ExecutionMode,
&i.IssueTitleTemplate,
&i.CreatedByType,
&i.CreatedByID,
&i.LastRunAt,
&i.CreatedAt,
&i.UpdatedAt,
&i.AssigneeType,
&i.ProjectID,
)
return i, err
}
const getAutopilotInWorkspace = `-- name: GetAutopilotInWorkspace :one
SELECT id, workspace_id, title, description, assignee_id, status, execution_mode, issue_title_template, created_by_type, created_by_id, last_run_at, created_at, updated_at, assignee_type, project_id FROM autopilot
WHERE id = $1 AND workspace_id = $2
`
type GetAutopilotInWorkspaceParams struct {
ID pgtype.UUID `json:"id"`
WorkspaceID pgtype.UUID `json:"workspace_id"`
}
func (q *Queries) GetAutopilotInWorkspace(ctx context.Context, arg GetAutopilotInWorkspaceParams) (Autopilot, error) {
row := q.db.QueryRow(ctx, getAutopilotInWorkspace, arg.ID, arg.WorkspaceID)
var i Autopilot
err := row.Scan(
&i.ID,
&i.WorkspaceID,
&i.Title,
&i.Description,
&i.AssigneeID,
&i.Status,
&i.ExecutionMode,
&i.IssueTitleTemplate,
&i.CreatedByType,
&i.CreatedByID,
&i.LastRunAt,
&i.CreatedAt,
&i.UpdatedAt,
&i.AssigneeType,
&i.ProjectID,
)
return i, err
}
const getAutopilotRun = `-- name: GetAutopilotRun :one
SELECT id, autopilot_id, trigger_id, source, status, issue_id, task_id, triggered_at, completed_at, failure_reason, trigger_payload, result, created_at, squad_id, planned_at FROM autopilot_run
WHERE id = $1
`
func (q *Queries) GetAutopilotRun(ctx context.Context, id pgtype.UUID) (AutopilotRun, error) {
row := q.db.QueryRow(ctx, getAutopilotRun, id)
var i AutopilotRun
err := row.Scan(
&i.ID,
&i.AutopilotID,
&i.TriggerID,
&i.Source,
&i.Status,
&i.IssueID,
&i.TaskID,
&i.TriggeredAt,
&i.CompletedAt,
&i.FailureReason,
&i.TriggerPayload,
&i.Result,
&i.CreatedAt,
&i.SquadID,
&i.PlannedAt,
)
return i, err
}
const getAutopilotRunByIssue = `-- name: GetAutopilotRunByIssue :one
SELECT id, autopilot_id, trigger_id, source, status, issue_id, task_id, triggered_at, completed_at, failure_reason, trigger_payload, result, created_at, squad_id, planned_at FROM autopilot_run
WHERE issue_id = $1 AND status IN ('issue_created', 'running')
LIMIT 1
`
// =====================
// Run lookup by linked entities
// =====================
func (q *Queries) GetAutopilotRunByIssue(ctx context.Context, issueID pgtype.UUID) (AutopilotRun, error) {
row := q.db.QueryRow(ctx, getAutopilotRunByIssue, issueID)
var i AutopilotRun
err := row.Scan(
&i.ID,
&i.AutopilotID,
&i.TriggerID,
&i.Source,
&i.Status,
&i.IssueID,
&i.TaskID,
&i.TriggeredAt,
&i.CompletedAt,
&i.FailureReason,
&i.TriggerPayload,
&i.Result,
&i.CreatedAt,
&i.SquadID,
&i.PlannedAt,
)
return i, err
}
const getAutopilotRunByTriggerAndPlanned = `-- name: GetAutopilotRunByTriggerAndPlanned :one
SELECT id, autopilot_id, trigger_id, source, status, issue_id, task_id, triggered_at, completed_at, failure_reason, trigger_payload, result, created_at, squad_id, planned_at FROM autopilot_run
WHERE trigger_id = $1
AND planned_at = $2
LIMIT 1
`
type GetAutopilotRunByTriggerAndPlannedParams struct {
TriggerID pgtype.UUID `json:"trigger_id"`
PlannedAt pgtype.Timestamptz `json:"planned_at"`
}
// Idempotent lookup used by DispatchAutopilotForPlan to detect a
// crash-during-dispatch retry: if a row already exists for this
// (trigger_id, planned_at), the caller reuses it instead of creating a
// duplicate. The partial unique index covers the same key, so a race
// between "look up then insert" still resolves to a single row — this
// query is just the fast path that lets us skip the INSERT when we
// can see the prior row clearly. Returns no rows for the (much more
// common) first-time dispatch.
func (q *Queries) GetAutopilotRunByTriggerAndPlanned(ctx context.Context, arg GetAutopilotRunByTriggerAndPlannedParams) (AutopilotRun, error) {
row := q.db.QueryRow(ctx, getAutopilotRunByTriggerAndPlanned, arg.TriggerID, arg.PlannedAt)
var i AutopilotRun
err := row.Scan(
&i.ID,
&i.AutopilotID,
&i.TriggerID,
&i.Source,
&i.Status,
&i.IssueID,
&i.TaskID,
&i.TriggeredAt,
&i.CompletedAt,
&i.FailureReason,
&i.TriggerPayload,
&i.Result,
&i.CreatedAt,
&i.SquadID,
&i.PlannedAt,
)
return i, err
}
const getAutopilotTrigger = `-- name: GetAutopilotTrigger :one
SELECT id, autopilot_id, kind, enabled, cron_expression, timezone, next_run_at, webhook_token, label, last_fired_at, created_at, updated_at, provider, signing_secret, event_filters FROM autopilot_trigger
WHERE id = $1
`
func (q *Queries) GetAutopilotTrigger(ctx context.Context, id pgtype.UUID) (AutopilotTrigger, error) {
row := q.db.QueryRow(ctx, getAutopilotTrigger, id)
var i AutopilotTrigger
err := row.Scan(
&i.ID,
&i.AutopilotID,
&i.Kind,
&i.Enabled,
&i.CronExpression,
&i.Timezone,
&i.NextRunAt,
&i.WebhookToken,
&i.Label,
&i.LastFiredAt,
&i.CreatedAt,
&i.UpdatedAt,
&i.Provider,
&i.SigningSecret,
&i.EventFilters,
)
return i, err
}
const getWebhookTriggerByToken = `-- name: GetWebhookTriggerByToken :one
SELECT t.id, t.autopilot_id, t.kind, t.enabled, t.cron_expression, t.timezone, t.next_run_at, t.webhook_token, t.label, t.last_fired_at, t.created_at, t.updated_at, t.provider, t.signing_secret, t.event_filters, a.workspace_id AS autopilot_workspace_id
FROM autopilot_trigger t
JOIN autopilot a ON a.id = t.autopilot_id
WHERE t.kind = 'webhook'
AND t.webhook_token = $1
`
type GetWebhookTriggerByTokenRow struct {
ID pgtype.UUID `json:"id"`
AutopilotID pgtype.UUID `json:"autopilot_id"`
Kind string `json:"kind"`
Enabled bool `json:"enabled"`
CronExpression pgtype.Text `json:"cron_expression"`
Timezone pgtype.Text `json:"timezone"`
NextRunAt pgtype.Timestamptz `json:"next_run_at"`
WebhookToken pgtype.Text `json:"webhook_token"`
Label pgtype.Text `json:"label"`
LastFiredAt pgtype.Timestamptz `json:"last_fired_at"`
CreatedAt pgtype.Timestamptz `json:"created_at"`
UpdatedAt pgtype.Timestamptz `json:"updated_at"`
Provider string `json:"provider"`
SigningSecret pgtype.Text `json:"signing_secret"`
EventFilters []byte `json:"event_filters"`
AutopilotWorkspaceID pgtype.UUID `json:"autopilot_workspace_id"`
}
// Look up a webhook trigger by its public bearer token. Joined to autopilot
// so the webhook handler can derive the workspace from the trigger's parent
// without trusting any request header. The handler still re-loads the
// Autopilot via GetAutopilot and cross-checks WorkspaceID matches the row's
// autopilot_workspace_id.
func (q *Queries) GetWebhookTriggerByToken(ctx context.Context, webhookToken pgtype.Text) (GetWebhookTriggerByTokenRow, error) {
row := q.db.QueryRow(ctx, getWebhookTriggerByToken, webhookToken)
var i GetWebhookTriggerByTokenRow
err := row.Scan(
&i.ID,
&i.AutopilotID,
&i.Kind,
&i.Enabled,
&i.CronExpression,
&i.Timezone,
&i.NextRunAt,
&i.WebhookToken,
&i.Label,
&i.LastFiredAt,
&i.CreatedAt,
&i.UpdatedAt,
&i.Provider,
&i.SigningSecret,
&i.EventFilters,
&i.AutopilotWorkspaceID,
)
return i, err
}
const isAutopilotCollaborator = `-- name: IsAutopilotCollaborator :one
SELECT EXISTS (
SELECT 1 FROM autopilot_collaborator
WHERE autopilot_id = $1 AND user_type = 'member' AND user_id = $2
) AS is_collaborator
`
type IsAutopilotCollaboratorParams struct {
AutopilotID pgtype.UUID `json:"autopilot_id"`
UserID pgtype.UUID `json:"user_id"`
}
func (q *Queries) IsAutopilotCollaborator(ctx context.Context, arg IsAutopilotCollaboratorParams) (bool, error) {
row := q.db.QueryRow(ctx, isAutopilotCollaborator, arg.AutopilotID, arg.UserID)
var is_collaborator bool
err := row.Scan(&is_collaborator)
return is_collaborator, err
}
const listAutopilotCollaborators = `-- name: ListAutopilotCollaborators :many
SELECT autopilot_id, user_type, user_id, granted_by, created_at FROM autopilot_collaborator
WHERE autopilot_id = $1
ORDER BY created_at ASC, user_id ASC
`
// =====================
// Autopilot Collaborators
// =====================
// ORDER BY created_at keeps row rendering stable across refreshes.
func (q *Queries) ListAutopilotCollaborators(ctx context.Context, autopilotID pgtype.UUID) ([]AutopilotCollaborator, error) {
rows, err := q.db.Query(ctx, listAutopilotCollaborators, autopilotID)
if err != nil {
return nil, err
}
defer rows.Close()
items := []AutopilotCollaborator{}
for rows.Next() {
var i AutopilotCollaborator
if err := rows.Scan(
&i.AutopilotID,
&i.UserType,
&i.UserID,
&i.GrantedBy,
&i.CreatedAt,
); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const listAutopilotIDsForCollaborator = `-- name: ListAutopilotIDsForCollaborator :many
SELECT autopilot_id FROM autopilot_collaborator
WHERE user_type = 'member' AND user_id = $1
`
// Powers the per-row can_write flag on the list endpoint without an N+1.
func (q *Queries) ListAutopilotIDsForCollaborator(ctx context.Context, userID pgtype.UUID) ([]pgtype.UUID, error) {
rows, err := q.db.Query(ctx, listAutopilotIDsForCollaborator, userID)
if err != nil {
return nil, err
}
defer rows.Close()
items := []pgtype.UUID{}
for rows.Next() {
var autopilot_id pgtype.UUID
if err := rows.Scan(&autopilot_id); err != nil {
return nil, err
}
items = append(items, autopilot_id)
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const listAutopilotRuns = `-- name: ListAutopilotRuns :many
SELECT id, autopilot_id, trigger_id, source, status, issue_id, task_id, triggered_at, completed_at, failure_reason, trigger_payload, result, created_at, squad_id, planned_at FROM autopilot_run
WHERE autopilot_id = $1
ORDER BY created_at DESC
LIMIT $2 OFFSET $3
`
type ListAutopilotRunsParams struct {
AutopilotID pgtype.UUID `json:"autopilot_id"`
Limit int32 `json:"limit"`
Offset int32 `json:"offset"`
}
func (q *Queries) ListAutopilotRuns(ctx context.Context, arg ListAutopilotRunsParams) ([]AutopilotRun, error) {
rows, err := q.db.Query(ctx, listAutopilotRuns, arg.AutopilotID, arg.Limit, arg.Offset)
if err != nil {
return nil, err
}
defer rows.Close()
items := []AutopilotRun{}
for rows.Next() {
var i AutopilotRun
if err := rows.Scan(
&i.ID,
&i.AutopilotID,
&i.TriggerID,
&i.Source,
&i.Status,
&i.IssueID,
&i.TaskID,
&i.TriggeredAt,
&i.CompletedAt,
&i.FailureReason,
&i.TriggerPayload,
&i.Result,
&i.CreatedAt,
&i.SquadID,
&i.PlannedAt,
); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const listAutopilotSubscribers = `-- name: ListAutopilotSubscribers :many
SELECT autopilot_id, user_type, user_id, created_at FROM autopilot_subscriber
WHERE autopilot_id = $1
ORDER BY created_at ASC, user_id ASC
`
// =====================
// Autopilot Subscribers
// =====================
// ORDER BY created_at keeps chip rendering stable across refreshes.
func (q *Queries) ListAutopilotSubscribers(ctx context.Context, autopilotID pgtype.UUID) ([]AutopilotSubscriber, error) {
rows, err := q.db.Query(ctx, listAutopilotSubscribers, autopilotID)
if err != nil {
return nil, err
}
defer rows.Close()
items := []AutopilotSubscriber{}
for rows.Next() {
var i AutopilotSubscriber
if err := rows.Scan(
&i.AutopilotID,
&i.UserType,
&i.UserID,
&i.CreatedAt,
); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const listAutopilotTriggers = `-- name: ListAutopilotTriggers :many
SELECT id, autopilot_id, kind, enabled, cron_expression, timezone, next_run_at, webhook_token, label, last_fired_at, created_at, updated_at, provider, signing_secret, event_filters FROM autopilot_trigger
WHERE autopilot_id = $1
ORDER BY created_at ASC
`
// =====================
// Autopilot Trigger CRUD
// =====================
func (q *Queries) ListAutopilotTriggers(ctx context.Context, autopilotID pgtype.UUID) ([]AutopilotTrigger, error) {
rows, err := q.db.Query(ctx, listAutopilotTriggers, autopilotID)
if err != nil {
return nil, err
}
defer rows.Close()
items := []AutopilotTrigger{}
for rows.Next() {
var i AutopilotTrigger
if err := rows.Scan(
&i.ID,
&i.AutopilotID,
&i.Kind,
&i.Enabled,
&i.CronExpression,
&i.Timezone,
&i.NextRunAt,
&i.WebhookToken,
&i.Label,
&i.LastFiredAt,
&i.CreatedAt,
&i.UpdatedAt,
&i.Provider,
&i.SigningSecret,
&i.EventFilters,
); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const listAutopilots = `-- name: ListAutopilots :many
SELECT
a.id, a.workspace_id, a.title, a.description, a.assignee_id, a.status, a.execution_mode, a.issue_title_template, a.created_by_type, a.created_by_id, a.last_run_at, a.created_at, a.updated_at, a.assignee_type, a.project_id,
(
SELECT array_agg(DISTINCT t.kind ORDER BY t.kind)
FROM autopilot_trigger t
WHERE t.autopilot_id = a.id AND t.enabled
)::text[] AS trigger_kinds,
(
SELECT min(t.next_run_at)
FROM autopilot_trigger t
WHERE t.autopilot_id = a.id AND t.enabled AND t.kind = 'schedule'
)::timestamptz AS next_run_at,
COALESCE((
SELECT r.status
FROM autopilot_run r
WHERE r.autopilot_id = a.id
ORDER BY r.triggered_at DESC
LIMIT 1
), '')::text AS last_run_status
FROM autopilot a
WHERE a.workspace_id = $1
AND ($2::text IS NULL OR a.status = $2)
ORDER BY a.created_at DESC
`
type ListAutopilotsParams struct {
WorkspaceID pgtype.UUID `json:"workspace_id"`
Status pgtype.Text `json:"status"`
}
type ListAutopilotsRow struct {
Autopilot Autopilot `json:"autopilot"`
TriggerKinds []string `json:"trigger_kinds"`
NextRunAt pgtype.Timestamptz `json:"next_run_at"`
LastRunStatus string `json:"last_run_status"`
}
// =====================
// Autopilot CRUD
// =====================
// List rows carry three derived columns the list UI needs (trigger badges,
// next run, last-run outcome) so the page never has to N+1 into the detail
// endpoint. trigger_kinds/next_run_at only consider ENABLED triggers — the
// columns answer "how does this fire today", not "what is configured".
// last_run_status is COALESCEd to ” (never ran) because sqlc cannot infer
// nullability through a scalar subquery; the handler maps ” back to omitted.
func (q *Queries) ListAutopilots(ctx context.Context, arg ListAutopilotsParams) ([]ListAutopilotsRow, error) {
rows, err := q.db.Query(ctx, listAutopilots, arg.WorkspaceID, arg.Status)
if err != nil {
return nil, err
}
defer rows.Close()
items := []ListAutopilotsRow{}
for rows.Next() {
var i ListAutopilotsRow
if err := rows.Scan(
&i.Autopilot.ID,
&i.Autopilot.WorkspaceID,
&i.Autopilot.Title,
&i.Autopilot.Description,
&i.Autopilot.AssigneeID,
&i.Autopilot.Status,
&i.Autopilot.ExecutionMode,
&i.Autopilot.IssueTitleTemplate,
&i.Autopilot.CreatedByType,
&i.Autopilot.CreatedByID,
&i.Autopilot.LastRunAt,
&i.Autopilot.CreatedAt,
&i.Autopilot.UpdatedAt,
&i.Autopilot.AssigneeType,
&i.Autopilot.ProjectID,
&i.TriggerKinds,
&i.NextRunAt,
&i.LastRunStatus,
); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const listSchedulableAutopilotTriggers = `-- name: ListSchedulableAutopilotTriggers :many
SELECT t.id, t.autopilot_id, t.cron_expression, t.timezone, t.created_at, t.last_fired_at
FROM autopilot_trigger t
JOIN autopilot a ON a.id = t.autopilot_id
WHERE t.kind = 'schedule'
AND t.enabled = TRUE
AND a.status = 'active'
AND t.cron_expression IS NOT NULL
AND t.cron_expression <> ''
ORDER BY t.id
`
type ListSchedulableAutopilotTriggersRow struct {
ID pgtype.UUID `json:"id"`
AutopilotID pgtype.UUID `json:"autopilot_id"`
CronExpression pgtype.Text `json:"cron_expression"`
Timezone pgtype.Text `json:"timezone"`
CreatedAt pgtype.Timestamptz `json:"created_at"`
LastFiredAt pgtype.Timestamptz `json:"last_fired_at"`
}
// =====================
// Scheduler Queries
// =====================
// Lists every schedule trigger the autopilot_schedule_dispatch JobSpec
// should consider this tick. Returns just the columns the scheduler's
// scope provider + PlansForScope hook need; the full trigger row is
// re-loaded by the handler so a trigger update between scope-list and
// handler-run sees the latest enabled / cron values.
//
// last_fired_at is read so the planner hook can anchor cold-start
// enumeration on the most recent successful fire (set by either the
// legacy goroutine before the new scheduler took over, or the new
// scheduler's own post-dispatch advance — AdvanceTriggerNextRun, falling
// back to TouchAutopilotTriggerFiredAt on a cron parse error). Without it,
// a trigger that was created days ago and fired by the legacy code
// looks like a brand-new trigger to the new scheduler on first tick
// and the half-open `(created_at, now]` enumeration replays the most
// recent already-fired occurrence — exactly the post-deploy
// spurious-fire reported on MUL-3551 dev.
//
// Filters out webhook / api triggers, disabled triggers, paused/archived
// autopilots, and any trigger missing its cron expression. ORDER BY id
// keeps the per-tick scope list stable across replicas.
func (q *Queries) ListSchedulableAutopilotTriggers(ctx context.Context) ([]ListSchedulableAutopilotTriggersRow, error) {
rows, err := q.db.Query(ctx, listSchedulableAutopilotTriggers)
if err != nil {
return nil, err
}
defer rows.Close()
items := []ListSchedulableAutopilotTriggersRow{}
for rows.Next() {
var i ListSchedulableAutopilotTriggersRow
if err := rows.Scan(
&i.ID,
&i.AutopilotID,
&i.CronExpression,
&i.Timezone,
&i.CreatedAt,
&i.LastFiredAt,
); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const recoverPartialAutopilotRun = `-- name: RecoverPartialAutopilotRun :exec
UPDATE autopilot_run
SET status = 'failed',
completed_at = now(),
failure_reason = 'recovered partial dispatch (crashed before downstream creation)',
planned_at = NULL
WHERE id = $1
`
// Recovers a partial-state autopilot_run from a crashed first attempt
// (the runner wrote the run row but died before creating the downstream
// issue/task) so that a subsequent DispatchAutopilotForPlan call can
// create a fresh run at the same (trigger_id, planned_at).
//
// Setting planned_at = NULL clears the partial-unique slot held by
// uq_autopilot_run_trigger_planned, letting the new INSERT proceed.
// The row stays in autopilot_run as a FAILED record (with a recovery
// reason) so ops still see the abandoned attempt in the run history —
// it is not silently deleted.
func (q *Queries) RecoverPartialAutopilotRun(ctx context.Context, id pgtype.UUID) error {
_, err := q.db.Exec(ctx, recoverPartialAutopilotRun, id)
return err
}
const rotateAutopilotTriggerWebhookToken = `-- name: RotateAutopilotTriggerWebhookToken :one
UPDATE autopilot_trigger
SET webhook_token = $2,
updated_at = now()
WHERE id = $1
AND kind = 'webhook'
RETURNING id, autopilot_id, kind, enabled, cron_expression, timezone, next_run_at, webhook_token, label, last_fired_at, created_at, updated_at, provider, signing_secret, event_filters
`
type RotateAutopilotTriggerWebhookTokenParams struct {
ID pgtype.UUID `json:"id"`
WebhookToken pgtype.Text `json:"webhook_token"`
}
// Rotates the bearer token for a webhook trigger. Restricted to kind='webhook'
// so an accidental call against a schedule/api trigger is a no-op (returns no
// rows) rather than corrupting unrelated state.
func (q *Queries) RotateAutopilotTriggerWebhookToken(ctx context.Context, arg RotateAutopilotTriggerWebhookTokenParams) (AutopilotTrigger, error) {
row := q.db.QueryRow(ctx, rotateAutopilotTriggerWebhookToken, arg.ID, arg.WebhookToken)
var i AutopilotTrigger
err := row.Scan(
&i.ID,
&i.AutopilotID,
&i.Kind,
&i.Enabled,
&i.CronExpression,
&i.Timezone,
&i.NextRunAt,
&i.WebhookToken,
&i.Label,
&i.LastFiredAt,
&i.CreatedAt,
&i.UpdatedAt,
&i.Provider,
&i.SigningSecret,
&i.EventFilters,
)
return i, err
}
const selectAutopilotsExceedingFailureThreshold = `-- name: SelectAutopilotsExceedingFailureThreshold :many
WITH stats AS (
SELECT autopilot_id,
count(*) FILTER (WHERE status IN ('completed', 'failed')) AS total,
count(*) FILTER (WHERE status = 'failed') AS failed
FROM autopilot_run
WHERE created_at >= $3::timestamptz
GROUP BY autopilot_id
)
SELECT a.id, a.workspace_id, a.title, a.assignee_id,
a.created_by_type, a.created_by_id,
s.total::bigint AS total_runs,
s.failed::bigint AS failed_runs
FROM autopilot a
JOIN stats s ON s.autopilot_id = a.id
WHERE a.status = 'active'
AND s.total >= $1::bigint
AND s.failed::float8 / NULLIF(s.total, 0)::float8 >= $2::float8
ORDER BY s.failed DESC, a.id ASC
`
type SelectAutopilotsExceedingFailureThresholdParams struct {
MinRuns int64 `json:"min_runs"`
FailRatioThreshold float64 `json:"fail_ratio_threshold"`
Since pgtype.Timestamptz `json:"since"`
}
type SelectAutopilotsExceedingFailureThresholdRow struct {
ID pgtype.UUID `json:"id"`
WorkspaceID pgtype.UUID `json:"workspace_id"`
Title string `json:"title"`
AssigneeID pgtype.UUID `json:"assignee_id"`
CreatedByType string `json:"created_by_type"`
CreatedByID pgtype.UUID `json:"created_by_id"`
TotalRuns int64 `json:"total_runs"`
FailedRuns int64 `json:"failed_runs"`
}
// =====================
// Failure-rate auto-pause
// =====================
// Find active autopilots whose recent run failure rate exceeds the threshold.
// Counts only "real" terminal runs (completed | failed). 'skipped' is
// excluded from BOTH numerator and denominator: an admission-skipped run
// (e.g. assignee runtime offline at dispatch time, MUL-1899) is neither a
// success nor a failure, so it must not dilute the failure ratio (which
// would let a 100%-failing autopilot mask itself behind a wall of skips)
// nor inflate it. issue_created/running are still excluded so in-flight
// work isn't penalised.
// Used by the failure monitor to auto-pause sustained-failure autopilots
// (the canonical example from MUL-1336 was an autopilot scheduled every 5 min
// that 100% failed for days, burning ~1.5k useless tasks per week).
func (q *Queries) SelectAutopilotsExceedingFailureThreshold(ctx context.Context, arg SelectAutopilotsExceedingFailureThresholdParams) ([]SelectAutopilotsExceedingFailureThresholdRow, error) {
rows, err := q.db.Query(ctx, selectAutopilotsExceedingFailureThreshold, arg.MinRuns, arg.FailRatioThreshold, arg.Since)
if err != nil {
return nil, err
}
defer rows.Close()
items := []SelectAutopilotsExceedingFailureThresholdRow{}
for rows.Next() {
var i SelectAutopilotsExceedingFailureThresholdRow
if err := rows.Scan(
&i.ID,
&i.WorkspaceID,
&i.Title,
&i.AssigneeID,
&i.CreatedByType,
&i.CreatedByID,
&i.TotalRuns,
&i.FailedRuns,
); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const setAutopilotTriggerSigningSecret = `-- name: SetAutopilotTriggerSigningSecret :one
UPDATE autopilot_trigger
SET signing_secret = $2,
updated_at = now()
WHERE id = $1
AND kind = 'webhook'
RETURNING id, autopilot_id, kind, enabled, cron_expression, timezone, next_run_at, webhook_token, label, last_fired_at, created_at, updated_at, provider, signing_secret, event_filters
`
type SetAutopilotTriggerSigningSecretParams struct {
ID pgtype.UUID `json:"id"`
SigningSecret pgtype.Text `json:"signing_secret"`
}
// Writes the signing secret for a webhook trigger. Kept as a dedicated query
// (not a field on UpdateAutopilotTrigger) so the request body for the
// write-only endpoint only ever carries the secret value, with no risk of an
// accidental log line leaking it alongside other fields. Restricted to
// webhook triggers to avoid corrupting unrelated state.
func (q *Queries) SetAutopilotTriggerSigningSecret(ctx context.Context, arg SetAutopilotTriggerSigningSecretParams) (AutopilotTrigger, error) {
row := q.db.QueryRow(ctx, setAutopilotTriggerSigningSecret, arg.ID, arg.SigningSecret)
var i AutopilotTrigger
err := row.Scan(
&i.ID,
&i.AutopilotID,
&i.Kind,
&i.Enabled,
&i.CronExpression,
&i.Timezone,
&i.NextRunAt,
&i.WebhookToken,
&i.Label,
&i.LastFiredAt,
&i.CreatedAt,
&i.UpdatedAt,
&i.Provider,
&i.SigningSecret,
&i.EventFilters,
)
return i, err
}
const setAutopilotTriggerWebhookToken = `-- name: SetAutopilotTriggerWebhookToken :one
UPDATE autopilot_trigger
SET webhook_token = $2,
updated_at = now()
WHERE id = $1
RETURNING id, autopilot_id, kind, enabled, cron_expression, timezone, next_run_at, webhook_token, label, last_fired_at, created_at, updated_at, provider, signing_secret, event_filters
`
type SetAutopilotTriggerWebhookTokenParams struct {
ID pgtype.UUID `json:"id"`
WebhookToken pgtype.Text `json:"webhook_token"`
}
// Sets the webhook token at creation time. CreateAutopilotTrigger inserts the
// row first (using its full 8-arg signature), then this query attaches the
// token. Splitting the create + token-set keeps the existing CreateAutopilotTrigger
// query usable by the schedule path without forcing every caller to think
// about webhook_token.
func (q *Queries) SetAutopilotTriggerWebhookToken(ctx context.Context, arg SetAutopilotTriggerWebhookTokenParams) (AutopilotTrigger, error) {
row := q.db.QueryRow(ctx, setAutopilotTriggerWebhookToken, arg.ID, arg.WebhookToken)
var i AutopilotTrigger
err := row.Scan(
&i.ID,
&i.AutopilotID,
&i.Kind,
&i.Enabled,
&i.CronExpression,
&i.Timezone,
&i.NextRunAt,
&i.WebhookToken,
&i.Label,
&i.LastFiredAt,
&i.CreatedAt,
&i.UpdatedAt,
&i.Provider,
&i.SigningSecret,
&i.EventFilters,
)
return i, err
}
const systemPauseAutopilot = `-- name: SystemPauseAutopilot :one
UPDATE autopilot
SET status = 'paused', updated_at = now()
WHERE id = $1 AND status = 'active'
RETURNING id, workspace_id, title, description, assignee_id, status, execution_mode, issue_title_template, created_by_type, created_by_id, last_run_at, created_at, updated_at, assignee_type, project_id
`
// Atomically pauses an autopilot only if it is currently active. Returns no
// rows when the autopilot was already paused/archived (or another worker
// raced first), letting the caller treat that as a benign no-op rather than
// an error.
func (q *Queries) SystemPauseAutopilot(ctx context.Context, id pgtype.UUID) (Autopilot, error) {
row := q.db.QueryRow(ctx, systemPauseAutopilot, id)
var i Autopilot
err := row.Scan(
&i.ID,
&i.WorkspaceID,
&i.Title,
&i.Description,
&i.AssigneeID,
&i.Status,
&i.ExecutionMode,
&i.IssueTitleTemplate,
&i.CreatedByType,
&i.CreatedByID,
&i.LastRunAt,
&i.CreatedAt,
&i.UpdatedAt,
&i.AssigneeType,
&i.ProjectID,
)
return i, err
}
const touchAutopilotTriggerFiredAt = `-- name: TouchAutopilotTriggerFiredAt :exec
UPDATE autopilot_trigger
SET last_fired_at = now(),
updated_at = now()
WHERE id = $1
`
// Bumps last_fired_at after a webhook fires, regardless of whether the
// dispatch succeeded, was admission-skipped, or even if Autopilot status
// transitioned to paused/disabled at exactly the wrong moment. Disabled /
// paused early-return paths in the handler never call this.
func (q *Queries) TouchAutopilotTriggerFiredAt(ctx context.Context, id pgtype.UUID) error {
_, err := q.db.Exec(ctx, touchAutopilotTriggerFiredAt, id)
return err
}
const updateAutopilot = `-- name: UpdateAutopilot :one
UPDATE autopilot SET
title = COALESCE($2, title),
description = COALESCE($3, description),
assignee_type = COALESCE($4, assignee_type),
assignee_id = COALESCE($5::uuid, assignee_id),
status = COALESCE($6, status),
execution_mode = COALESCE($7, execution_mode),
issue_title_template = $8,
project_id = $9,
updated_at = now()
WHERE id = $1
RETURNING id, workspace_id, title, description, assignee_id, status, execution_mode, issue_title_template, created_by_type, created_by_id, last_run_at, created_at, updated_at, assignee_type, project_id
`
type UpdateAutopilotParams struct {
ID pgtype.UUID `json:"id"`
Title pgtype.Text `json:"title"`
Description pgtype.Text `json:"description"`
AssigneeType pgtype.Text `json:"assignee_type"`
AssigneeID pgtype.UUID `json:"assignee_id"`
Status pgtype.Text `json:"status"`
ExecutionMode pgtype.Text `json:"execution_mode"`
IssueTitleTemplate pgtype.Text `json:"issue_title_template"`
ProjectID pgtype.UUID `json:"project_id"`
}
func (q *Queries) UpdateAutopilot(ctx context.Context, arg UpdateAutopilotParams) (Autopilot, error) {
row := q.db.QueryRow(ctx, updateAutopilot,
arg.ID,
arg.Title,
arg.Description,
arg.AssigneeType,
arg.AssigneeID,
arg.Status,
arg.ExecutionMode,
arg.IssueTitleTemplate,
arg.ProjectID,
)
var i Autopilot
err := row.Scan(
&i.ID,
&i.WorkspaceID,
&i.Title,
&i.Description,
&i.AssigneeID,
&i.Status,
&i.ExecutionMode,
&i.IssueTitleTemplate,
&i.CreatedByType,
&i.CreatedByID,
&i.LastRunAt,
&i.CreatedAt,
&i.UpdatedAt,
&i.AssigneeType,
&i.ProjectID,
)
return i, err
}
const updateAutopilotLastRunAt = `-- name: UpdateAutopilotLastRunAt :exec
UPDATE autopilot SET last_run_at = now(), updated_at = now()
WHERE id = $1
`
func (q *Queries) UpdateAutopilotLastRunAt(ctx context.Context, id pgtype.UUID) error {
_, err := q.db.Exec(ctx, updateAutopilotLastRunAt, id)
return err
}
const updateAutopilotRunCompleted = `-- name: UpdateAutopilotRunCompleted :one
UPDATE autopilot_run
SET status = 'completed', completed_at = now(), result = $2
WHERE id = $1
RETURNING id, autopilot_id, trigger_id, source, status, issue_id, task_id, triggered_at, completed_at, failure_reason, trigger_payload, result, created_at, squad_id, planned_at
`
type UpdateAutopilotRunCompletedParams struct {
ID pgtype.UUID `json:"id"`
Result []byte `json:"result"`
}
func (q *Queries) UpdateAutopilotRunCompleted(ctx context.Context, arg UpdateAutopilotRunCompletedParams) (AutopilotRun, error) {
row := q.db.QueryRow(ctx, updateAutopilotRunCompleted, arg.ID, arg.Result)
var i AutopilotRun
err := row.Scan(
&i.ID,
&i.AutopilotID,
&i.TriggerID,
&i.Source,
&i.Status,
&i.IssueID,
&i.TaskID,
&i.TriggeredAt,
&i.CompletedAt,
&i.FailureReason,
&i.TriggerPayload,
&i.Result,
&i.CreatedAt,
&i.SquadID,
&i.PlannedAt,
)
return i, err
}
const updateAutopilotRunFailed = `-- name: UpdateAutopilotRunFailed :one
UPDATE autopilot_run
SET status = 'failed', completed_at = now(), failure_reason = $2
WHERE id = $1
RETURNING id, autopilot_id, trigger_id, source, status, issue_id, task_id, triggered_at, completed_at, failure_reason, trigger_payload, result, created_at, squad_id, planned_at
`
type UpdateAutopilotRunFailedParams struct {
ID pgtype.UUID `json:"id"`
FailureReason pgtype.Text `json:"failure_reason"`
}
func (q *Queries) UpdateAutopilotRunFailed(ctx context.Context, arg UpdateAutopilotRunFailedParams) (AutopilotRun, error) {
row := q.db.QueryRow(ctx, updateAutopilotRunFailed, arg.ID, arg.FailureReason)
var i AutopilotRun
err := row.Scan(
&i.ID,
&i.AutopilotID,
&i.TriggerID,
&i.Source,
&i.Status,
&i.IssueID,
&i.TaskID,
&i.TriggeredAt,
&i.CompletedAt,
&i.FailureReason,
&i.TriggerPayload,
&i.Result,
&i.CreatedAt,
&i.SquadID,
&i.PlannedAt,
)
return i, err
}
const updateAutopilotRunIssueCreated = `-- name: UpdateAutopilotRunIssueCreated :one
UPDATE autopilot_run
SET status = 'issue_created', issue_id = $2
WHERE id = $1
RETURNING id, autopilot_id, trigger_id, source, status, issue_id, task_id, triggered_at, completed_at, failure_reason, trigger_payload, result, created_at, squad_id, planned_at
`
type UpdateAutopilotRunIssueCreatedParams struct {
ID pgtype.UUID `json:"id"`
IssueID pgtype.UUID `json:"issue_id"`
}
func (q *Queries) UpdateAutopilotRunIssueCreated(ctx context.Context, arg UpdateAutopilotRunIssueCreatedParams) (AutopilotRun, error) {
row := q.db.QueryRow(ctx, updateAutopilotRunIssueCreated, arg.ID, arg.IssueID)
var i AutopilotRun
err := row.Scan(
&i.ID,
&i.AutopilotID,
&i.TriggerID,
&i.Source,
&i.Status,
&i.IssueID,
&i.TaskID,
&i.TriggeredAt,
&i.CompletedAt,
&i.FailureReason,
&i.TriggerPayload,
&i.Result,
&i.CreatedAt,
&i.SquadID,
&i.PlannedAt,
)
return i, err
}
const updateAutopilotRunRunning = `-- name: UpdateAutopilotRunRunning :one
UPDATE autopilot_run
SET status = 'running', task_id = $2
WHERE id = $1
RETURNING id, autopilot_id, trigger_id, source, status, issue_id, task_id, triggered_at, completed_at, failure_reason, trigger_payload, result, created_at, squad_id, planned_at
`
type UpdateAutopilotRunRunningParams struct {
ID pgtype.UUID `json:"id"`
TaskID pgtype.UUID `json:"task_id"`
}
func (q *Queries) UpdateAutopilotRunRunning(ctx context.Context, arg UpdateAutopilotRunRunningParams) (AutopilotRun, error) {
row := q.db.QueryRow(ctx, updateAutopilotRunRunning, arg.ID, arg.TaskID)
var i AutopilotRun
err := row.Scan(
&i.ID,
&i.AutopilotID,
&i.TriggerID,
&i.Source,
&i.Status,
&i.IssueID,
&i.TaskID,
&i.TriggeredAt,
&i.CompletedAt,
&i.FailureReason,
&i.TriggerPayload,
&i.Result,
&i.CreatedAt,
&i.SquadID,
&i.PlannedAt,
)
return i, err
}
const updateAutopilotRunSkipped = `-- name: UpdateAutopilotRunSkipped :one
UPDATE autopilot_run
SET status = 'skipped', completed_at = now(), failure_reason = $2
WHERE id = $1
RETURNING id, autopilot_id, trigger_id, source, status, issue_id, task_id, triggered_at, completed_at, failure_reason, trigger_payload, result, created_at, squad_id, planned_at
`
type UpdateAutopilotRunSkippedParams struct {
ID pgtype.UUID `json:"id"`
FailureReason pgtype.Text `json:"failure_reason"`
}
// Marks an autopilot_run as skipped without enqueueing any task. Used by the
// pre-flight admission check when the assignee agent's runtime is offline:
// creating an issue / task in that state would just pile a doomed job onto
// agent_task_queue (the canonical "持续给离线 local agent 入队" symptom from
// MUL-1899). Recording the skip + reason gives the UI / failure monitor / ops
// a paper trail without polluting the failure ratio.
func (q *Queries) UpdateAutopilotRunSkipped(ctx context.Context, arg UpdateAutopilotRunSkippedParams) (AutopilotRun, error) {
row := q.db.QueryRow(ctx, updateAutopilotRunSkipped, arg.ID, arg.FailureReason)
var i AutopilotRun
err := row.Scan(
&i.ID,
&i.AutopilotID,
&i.TriggerID,
&i.Source,
&i.Status,
&i.IssueID,
&i.TaskID,
&i.TriggeredAt,
&i.CompletedAt,
&i.FailureReason,
&i.TriggerPayload,
&i.Result,
&i.CreatedAt,
&i.SquadID,
&i.PlannedAt,
)
return i, err
}
const updateAutopilotRunSkippedWithResult = `-- name: UpdateAutopilotRunSkippedWithResult :one
UPDATE autopilot_run
SET status = 'skipped',
completed_at = now(),
failure_reason = $2,
result = $3
WHERE id = $1
RETURNING id, autopilot_id, trigger_id, source, status, issue_id, task_id, triggered_at, completed_at, failure_reason, trigger_payload, result, created_at, squad_id, planned_at
`
type UpdateAutopilotRunSkippedWithResultParams struct {
ID pgtype.UUID `json:"id"`
FailureReason pgtype.Text `json:"failure_reason"`
Result []byte `json:"result"`
}
func (q *Queries) UpdateAutopilotRunSkippedWithResult(ctx context.Context, arg UpdateAutopilotRunSkippedWithResultParams) (AutopilotRun, error) {
row := q.db.QueryRow(ctx, updateAutopilotRunSkippedWithResult, arg.ID, arg.FailureReason, arg.Result)
var i AutopilotRun
err := row.Scan(
&i.ID,
&i.AutopilotID,
&i.TriggerID,
&i.Source,
&i.Status,
&i.IssueID,
&i.TaskID,
&i.TriggeredAt,
&i.CompletedAt,
&i.FailureReason,
&i.TriggerPayload,
&i.Result,
&i.CreatedAt,
&i.SquadID,
&i.PlannedAt,
)
return i, err
}
const updateAutopilotTrigger = `-- name: UpdateAutopilotTrigger :one
UPDATE autopilot_trigger SET
enabled = COALESCE($2::boolean, enabled),
cron_expression = COALESCE($3, cron_expression),
timezone = COALESCE($4, timezone),
next_run_at = $5,
label = COALESCE($6, label),
event_filters = COALESCE($7, event_filters),
updated_at = now()
WHERE id = $1
RETURNING id, autopilot_id, kind, enabled, cron_expression, timezone, next_run_at, webhook_token, label, last_fired_at, created_at, updated_at, provider, signing_secret, event_filters
`
type UpdateAutopilotTriggerParams struct {
ID pgtype.UUID `json:"id"`
Enabled pgtype.Bool `json:"enabled"`
CronExpression pgtype.Text `json:"cron_expression"`
Timezone pgtype.Text `json:"timezone"`
NextRunAt pgtype.Timestamptz `json:"next_run_at"`
Label pgtype.Text `json:"label"`
EventFilters []byte `json:"event_filters"`
}
func (q *Queries) UpdateAutopilotTrigger(ctx context.Context, arg UpdateAutopilotTriggerParams) (AutopilotTrigger, error) {
row := q.db.QueryRow(ctx, updateAutopilotTrigger,
arg.ID,
arg.Enabled,
arg.CronExpression,
arg.Timezone,
arg.NextRunAt,
arg.Label,
arg.EventFilters,
)
var i AutopilotTrigger
err := row.Scan(
&i.ID,
&i.AutopilotID,
&i.Kind,
&i.Enabled,
&i.CronExpression,
&i.Timezone,
&i.NextRunAt,
&i.WebhookToken,
&i.Label,
&i.LastFiredAt,
&i.CreatedAt,
&i.UpdatedAt,
&i.Provider,
&i.SigningSecret,
&i.EventFilters,
)
return i, err
}