mirror of
https://github.com/multica-ai/multica.git
synced 2026-07-05 13:29:44 +02:00
* 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>
1820 lines
63 KiB
Go
1820 lines
63 KiB
Go
package handler
|
||
|
||
import (
|
||
"context"
|
||
"encoding/json"
|
||
"fmt"
|
||
"io"
|
||
"net/http"
|
||
"strconv"
|
||
"strings"
|
||
"time"
|
||
|
||
"github.com/go-chi/chi/v5"
|
||
"github.com/jackc/pgx/v5/pgtype"
|
||
"github.com/multica-ai/multica/server/internal/analytics"
|
||
obsmetrics "github.com/multica-ai/multica/server/internal/metrics"
|
||
"github.com/multica-ai/multica/server/internal/service"
|
||
"github.com/multica-ai/multica/server/internal/util"
|
||
db "github.com/multica-ai/multica/server/pkg/db/generated"
|
||
"github.com/multica-ai/multica/server/pkg/protocol"
|
||
)
|
||
|
||
// computeNextRun delegates to the shared cron helper in the service package.
|
||
func computeNextRun(cronExpr, timezone string) (time.Time, error) {
|
||
return service.ComputeNextRun(cronExpr, timezone)
|
||
}
|
||
|
||
// ── Response types ──────────────────────────────────────────────────────────
|
||
|
||
type AutopilotResponse struct {
|
||
ID string `json:"id"`
|
||
WorkspaceID string `json:"workspace_id"`
|
||
Title string `json:"title"`
|
||
Description *string `json:"description"`
|
||
ProjectID *string `json:"project_id"`
|
||
// AssigneeType is "agent" or "squad". Path A from MUL-2429: when set
|
||
// to "squad", AssigneeID points at squad(id) rather than agent(id) and
|
||
// dispatch resolves to squad.leader_id at run time.
|
||
AssigneeType string `json:"assignee_type"`
|
||
AssigneeID string `json:"assignee_id"`
|
||
Status string `json:"status"`
|
||
ExecutionMode string `json:"execution_mode"`
|
||
IssueTitleTemplate *string `json:"issue_title_template"`
|
||
CreatedByType string `json:"created_by_type"`
|
||
CreatedByID string `json:"created_by_id"`
|
||
LastRunAt *string `json:"last_run_at"`
|
||
CreatedAt string `json:"created_at"`
|
||
UpdatedAt string `json:"updated_at"`
|
||
|
||
// List-endpoint-only derived fields (absent on the detail/create/update
|
||
// responses and on older servers — clients must treat them as optional).
|
||
// Enabled triggers only; last_run_status is the most recent run's status.
|
||
TriggerKinds []string `json:"trigger_kinds,omitempty"`
|
||
NextRunAt *string `json:"next_run_at,omitempty"`
|
||
LastRunStatus *string `json:"last_run_status,omitempty"`
|
||
|
||
// Always non-nil (empty slice when no subscribers configured) so
|
||
// frontend optional-chain rules can treat the field as authoritative.
|
||
Subscribers []AutopilotSubscriberEntry `json:"subscribers"`
|
||
|
||
// CanWrite reports whether the requesting caller may perform write/execute
|
||
// operations on this autopilot — editing, deleting, triggering, and
|
||
// managing triggers/webhook secrets (creator, workspace owner/admin, or an
|
||
// explicit collaborator). Nil on responses built without a caller in
|
||
// context (older servers omit it; clients must treat absence as "unknown"
|
||
// and fall back to attempting the action). See MUL-3807.
|
||
CanWrite *bool `json:"can_write,omitempty"`
|
||
|
||
// CanManageAccess reports whether the caller may manage the collaborator
|
||
// (access) list — a narrower right held only by the creator and workspace
|
||
// owners/admins, NOT by granted collaborators (who can write but cannot
|
||
// re-grant). Nil when built without a caller in context. See MUL-3807.
|
||
CanManageAccess *bool `json:"can_manage_access,omitempty"`
|
||
}
|
||
|
||
// AutopilotCollaboratorEntry is a member explicitly granted write access to an
|
||
// autopilot, surfaced on the detail response and the collaborator endpoints.
|
||
type AutopilotCollaboratorEntry struct {
|
||
UserType string `json:"user_type"`
|
||
UserID string `json:"user_id"`
|
||
GrantedBy string `json:"granted_by"`
|
||
CreatedAt string `json:"created_at"`
|
||
}
|
||
|
||
func collaboratorToEntry(c db.AutopilotCollaborator) AutopilotCollaboratorEntry {
|
||
return AutopilotCollaboratorEntry{
|
||
UserType: c.UserType,
|
||
UserID: uuidToString(c.UserID),
|
||
GrantedBy: uuidToString(c.GrantedBy),
|
||
CreatedAt: timestampToString(c.CreatedAt),
|
||
}
|
||
}
|
||
|
||
// user_type is restricted to "member" at the DB layer; the field is kept on
|
||
// the wire so a future expansion to agents/squads is additive, not breaking.
|
||
type AutopilotSubscriberEntry struct {
|
||
UserType string `json:"user_type"`
|
||
UserID string `json:"user_id"`
|
||
CreatedAt string `json:"created_at"`
|
||
}
|
||
|
||
type AutopilotTriggerResponse struct {
|
||
ID string `json:"id"`
|
||
AutopilotID string `json:"autopilot_id"`
|
||
Kind string `json:"kind"`
|
||
Enabled bool `json:"enabled"`
|
||
CronExpression *string `json:"cron_expression"`
|
||
Timezone *string `json:"timezone"`
|
||
NextRunAt *string `json:"next_run_at"`
|
||
WebhookToken *string `json:"webhook_token"`
|
||
// WebhookPath is computed from webhook_token. Always present for webhook
|
||
// triggers; nil for schedule/api. Not stored — see triggerToResponse.
|
||
WebhookPath *string `json:"webhook_path"`
|
||
// WebhookURL is the absolute URL composed from the server's
|
||
// MULTICA_PUBLIC_URL setting. Nil when the server has no public URL
|
||
// configured; clients then build the URL themselves from webhook_path
|
||
// plus their API base / current origin.
|
||
WebhookURL *string `json:"webhook_url"`
|
||
// Provider names the per-endpoint signing/dedupe convention. For now:
|
||
// "generic" (bearer URL only, Idempotency-Key for dedupe) or "github"
|
||
// (X-Hub-Signature-256 + X-GitHub-Delivery). Omitted for non-webhook
|
||
// triggers.
|
||
Provider *string `json:"provider"`
|
||
// HasSigningSecret indicates whether a signing secret is configured on
|
||
// the trigger. The secret itself is never returned — it is set via a
|
||
// dedicated write-only endpoint. Always false for non-webhook triggers.
|
||
HasSigningSecret bool `json:"has_signing_secret"`
|
||
// SigningSecretHint is the last 4 characters of the configured secret,
|
||
// surfaced to help operators tell two secrets apart in the UI. Nil when
|
||
// no secret is configured.
|
||
SigningSecretHint *string `json:"signing_secret_hint"`
|
||
Label *string `json:"label"`
|
||
LastFiredAt *string `json:"last_fired_at"`
|
||
CreatedAt string `json:"created_at"`
|
||
UpdatedAt string `json:"updated_at"`
|
||
// EventFilters is the declared event scope. Only present for webhook
|
||
// triggers; omitted when the trigger accepts all events. Serializes as
|
||
// a JSON array of {event, actions?} objects — never as a base64 string
|
||
// (which is what []byte would produce through encoding/json).
|
||
EventFilters []WebhookEventFilter `json:"event_filters,omitempty"`
|
||
}
|
||
|
||
type AutopilotRunResponse struct {
|
||
ID string `json:"id"`
|
||
AutopilotID string `json:"autopilot_id"`
|
||
TriggerID *string `json:"trigger_id"`
|
||
Source string `json:"source"`
|
||
Status string `json:"status"`
|
||
IssueID *string `json:"issue_id"`
|
||
TaskID *string `json:"task_id"`
|
||
TriggeredAt string `json:"triggered_at"`
|
||
CompletedAt *string `json:"completed_at"`
|
||
FailureReason *string `json:"failure_reason"`
|
||
TriggerPayload any `json:"trigger_payload"`
|
||
Result any `json:"result"`
|
||
CreatedAt string `json:"created_at"`
|
||
}
|
||
|
||
// ── Converters ──────────────────────────────────────────────────────────────
|
||
|
||
func autopilotToResponse(a db.Autopilot, subscribers []db.AutopilotSubscriber) AutopilotResponse {
|
||
assigneeType := a.AssigneeType
|
||
if assigneeType == "" {
|
||
// Older rows pre-MUL-2429 may surface as "" against an out-of-date
|
||
// schema view; default to "agent" so the API contract stays
|
||
// non-null.
|
||
assigneeType = "agent"
|
||
}
|
||
subResp := make([]AutopilotSubscriberEntry, len(subscribers))
|
||
for i, s := range subscribers {
|
||
subResp[i] = AutopilotSubscriberEntry{
|
||
UserType: s.UserType,
|
||
UserID: uuidToString(s.UserID),
|
||
CreatedAt: timestampToString(s.CreatedAt),
|
||
}
|
||
}
|
||
return AutopilotResponse{
|
||
ID: uuidToString(a.ID),
|
||
WorkspaceID: uuidToString(a.WorkspaceID),
|
||
Title: a.Title,
|
||
Description: textToPtr(a.Description),
|
||
ProjectID: uuidToPtr(a.ProjectID),
|
||
AssigneeType: assigneeType,
|
||
AssigneeID: uuidToString(a.AssigneeID),
|
||
Status: a.Status,
|
||
ExecutionMode: a.ExecutionMode,
|
||
IssueTitleTemplate: textToPtr(a.IssueTitleTemplate),
|
||
CreatedByType: a.CreatedByType,
|
||
CreatedByID: uuidToString(a.CreatedByID),
|
||
LastRunAt: timestampToPtr(a.LastRunAt),
|
||
CreatedAt: timestampToString(a.CreatedAt),
|
||
UpdatedAt: timestampToString(a.UpdatedAt),
|
||
Subscribers: subResp,
|
||
}
|
||
}
|
||
|
||
func (h *Handler) triggerToResponse(t db.AutopilotTrigger) AutopilotTriggerResponse {
|
||
resp := AutopilotTriggerResponse{
|
||
ID: uuidToString(t.ID),
|
||
AutopilotID: uuidToString(t.AutopilotID),
|
||
Kind: t.Kind,
|
||
Enabled: t.Enabled,
|
||
CronExpression: textToPtr(t.CronExpression),
|
||
Timezone: textToPtr(t.Timezone),
|
||
NextRunAt: timestampToPtr(t.NextRunAt),
|
||
WebhookToken: textToPtr(t.WebhookToken),
|
||
Label: textToPtr(t.Label),
|
||
LastFiredAt: timestampToPtr(t.LastFiredAt),
|
||
CreatedAt: timestampToString(t.CreatedAt),
|
||
UpdatedAt: timestampToString(t.UpdatedAt),
|
||
}
|
||
if t.Kind == "webhook" && t.WebhookToken.Valid && t.WebhookToken.String != "" {
|
||
path := webhookPathForToken(t.WebhookToken.String)
|
||
resp.WebhookPath = &path
|
||
if h.cfg.PublicURL != "" {
|
||
full := h.cfg.PublicURL + path
|
||
resp.WebhookURL = &full
|
||
}
|
||
provider := t.Provider
|
||
if provider == "" {
|
||
provider = "generic"
|
||
}
|
||
resp.Provider = &provider
|
||
if t.SigningSecret.Valid && t.SigningSecret.String != "" {
|
||
resp.HasSigningSecret = true
|
||
hint := signingSecretHint(t.SigningSecret.String)
|
||
resp.SigningSecretHint = &hint
|
||
}
|
||
if len(t.EventFilters) > 0 {
|
||
var filters []WebhookEventFilter
|
||
if err := json.Unmarshal(t.EventFilters, &filters); err == nil {
|
||
resp.EventFilters = filters
|
||
}
|
||
// On unmarshal error we deliberately drop the field instead of
|
||
// surfacing raw bytes or 500ing — strict write-time validation
|
||
// is supposed to make this branch unreachable, and the matcher
|
||
// fails closed if a corrupt row ever slips through.
|
||
}
|
||
}
|
||
return resp
|
||
}
|
||
|
||
// signingSecretHint returns the last 4 characters of the signing secret so a
|
||
// configured-vs-rotated state is visible in the UI without exposing the
|
||
// secret itself. Truncating below 4 chars (which the validator already
|
||
// rejects) just returns an empty string.
|
||
func signingSecretHint(secret string) string {
|
||
if len(secret) < 4 {
|
||
return ""
|
||
}
|
||
return secret[len(secret)-4:]
|
||
}
|
||
|
||
// webhookPathForToken composes the path used by the public ingress route.
|
||
// Kept as a free function (no Handler receiver) so test code that builds
|
||
// expected URLs without instantiating a Handler can call it.
|
||
func webhookPathForToken(token string) string {
|
||
return "/api/webhooks/autopilots/" + token
|
||
}
|
||
|
||
func runToResponse(r db.AutopilotRun) AutopilotRunResponse {
|
||
var payload any
|
||
if r.TriggerPayload != nil {
|
||
json.Unmarshal(r.TriggerPayload, &payload)
|
||
}
|
||
var result any
|
||
if r.Result != nil {
|
||
json.Unmarshal(r.Result, &result)
|
||
}
|
||
return AutopilotRunResponse{
|
||
ID: uuidToString(r.ID),
|
||
AutopilotID: uuidToString(r.AutopilotID),
|
||
TriggerID: uuidToPtr(r.TriggerID),
|
||
Source: r.Source,
|
||
Status: r.Status,
|
||
IssueID: uuidToPtr(r.IssueID),
|
||
TaskID: uuidToPtr(r.TaskID),
|
||
TriggeredAt: timestampToString(r.TriggeredAt),
|
||
CompletedAt: timestampToPtr(r.CompletedAt),
|
||
FailureReason: textToPtr(r.FailureReason),
|
||
TriggerPayload: payload,
|
||
Result: result,
|
||
CreatedAt: timestampToString(r.CreatedAt),
|
||
}
|
||
}
|
||
|
||
// runToResponseSlim mirrors runToResponse but omits TriggerPayload, intended
|
||
// for list endpoints where echoing the full webhook envelope (up to
|
||
// 256 KiB × N rows) would dominate response size. Clients fetch the full
|
||
// payload via GET /api/autopilots/{id}/runs/{runId} when the user opens
|
||
// the run detail dialog.
|
||
func runToResponseSlim(r db.AutopilotRun) AutopilotRunResponse {
|
||
resp := runToResponse(r)
|
||
resp.TriggerPayload = nil
|
||
return resp
|
||
}
|
||
|
||
// ── Request types ───────────────────────────────────────────────────────────
|
||
|
||
type CreateAutopilotRequest struct {
|
||
Title string `json:"title"`
|
||
Description *string `json:"description"`
|
||
ProjectID *string `json:"project_id"`
|
||
// AssigneeType is optional and defaults to "agent" — preserves backward
|
||
// compatibility with desktop clients shipped before MUL-2429.
|
||
AssigneeType *string `json:"assignee_type"`
|
||
AssigneeID string `json:"assignee_id"`
|
||
ExecutionMode string `json:"execution_mode"`
|
||
IssueTitleTemplate *string `json:"issue_title_template"`
|
||
Subscribers []SubscriberInput `json:"subscribers"`
|
||
}
|
||
|
||
type UpdateAutopilotRequest struct {
|
||
Title *string `json:"title"`
|
||
Description *string `json:"description"`
|
||
ProjectID *string `json:"project_id"`
|
||
AssigneeType *string `json:"assignee_type"`
|
||
AssigneeID *string `json:"assignee_id"`
|
||
Status *string `json:"status"`
|
||
ExecutionMode *string `json:"execution_mode"`
|
||
IssueTitleTemplate *string `json:"issue_title_template"`
|
||
// Wholesale replacement when present; omit to leave subscribers untouched.
|
||
Subscribers []SubscriberInput `json:"subscribers"`
|
||
}
|
||
|
||
type SubscriberInput struct {
|
||
UserType string `json:"user_type"`
|
||
UserID string `json:"user_id"`
|
||
}
|
||
|
||
type CreateAutopilotTriggerRequest struct {
|
||
Kind string `json:"kind"`
|
||
CronExpression *string `json:"cron_expression"`
|
||
Timezone *string `json:"timezone"`
|
||
Label *string `json:"label"`
|
||
// Provider is currently only meaningful for kind=webhook. Allowed
|
||
// values: "generic" (default) or "github". Unset → "generic".
|
||
Provider *string `json:"provider"`
|
||
// EventFilters is an optional list of {event, actions?} scopes. Only
|
||
// meaningful for webhook triggers. nil/empty means "accept all events".
|
||
EventFilters []WebhookEventFilter `json:"event_filters,omitempty"`
|
||
}
|
||
|
||
// SetSigningSecretRequest is the body shape for PUT
|
||
// /api/autopilots/{id}/triggers/{triggerId}/signing-secret. Lives in its own
|
||
// type so the secret never appears alongside other fields on the trigger
|
||
// update path — handlers that log request bodies for debugging cannot pick it
|
||
// up by accident.
|
||
type SetSigningSecretRequest struct {
|
||
// SigningSecret is the new HMAC key. Sending an empty string explicitly
|
||
// clears the secret (disables signature verification). Pass any
|
||
// reasonably entropic value — GitHub's docs recommend at least 32 random
|
||
// characters; we enforce a 16-char minimum on non-empty input.
|
||
SigningSecret string `json:"signing_secret"`
|
||
}
|
||
|
||
type UpdateAutopilotTriggerRequest struct {
|
||
Enabled *bool `json:"enabled"`
|
||
CronExpression *string `json:"cron_expression"`
|
||
Timezone *string `json:"timezone"`
|
||
Label *string `json:"label"`
|
||
// EventFilters is the desired event-filter set with tri-state PATCH
|
||
// semantics:
|
||
//
|
||
// - omitted / explicit null (nil pointer) → leave the existing value
|
||
// untouched.
|
||
// - explicit [] (non-nil, length 0) → clear filters (the trigger
|
||
// reverts to "accept all events").
|
||
// - explicit [...] → replace with the supplied
|
||
// list.
|
||
//
|
||
// This is why the pointer matters: with a plain []WebhookEventFilter
|
||
// there is no way to tell "field absent from the PATCH body" from "field
|
||
// present but empty", and the user can never clear filters once set.
|
||
EventFilters *[]WebhookEventFilter `json:"event_filters,omitempty"`
|
||
}
|
||
|
||
// ── Handlers ────────────────────────────────────────────────────────────────
|
||
|
||
func (h *Handler) ListAutopilots(w http.ResponseWriter, r *http.Request) {
|
||
workspaceID := h.resolveWorkspaceID(r)
|
||
|
||
var statusFilter pgtype.Text
|
||
if s := r.URL.Query().Get("status"); s != "" {
|
||
statusFilter = pgtype.Text{String: s, Valid: true}
|
||
}
|
||
|
||
autopilots, err := h.Queries.ListAutopilots(r.Context(), db.ListAutopilotsParams{
|
||
WorkspaceID: parseUUID(workspaceID),
|
||
Status: statusFilter,
|
||
})
|
||
if err != nil {
|
||
writeError(w, http.StatusInternalServerError, "failed to list autopilots")
|
||
return
|
||
}
|
||
|
||
// Resolve the caller's write access for per-row can_write. The collaborator
|
||
// grants are fetched once as a set (keyed by autopilot id) so the flag
|
||
// costs no per-row query. A missing member (shouldn't happen behind the
|
||
// workspace-member middleware) just yields can_write=false everywhere.
|
||
caller, callerErr := h.getWorkspaceMember(r.Context(), requestUserID(r), workspaceID)
|
||
collabSet := map[string]struct{}{}
|
||
if callerErr == nil {
|
||
if ids, err := h.Queries.ListAutopilotIDsForCollaborator(r.Context(), caller.UserID); err == nil {
|
||
for _, id := range ids {
|
||
collabSet[uuidToString(id)] = struct{}{}
|
||
}
|
||
}
|
||
}
|
||
|
||
resp := make([]AutopilotResponse, len(autopilots))
|
||
for i, row := range autopilots {
|
||
// Omit subscribers to avoid an N+1; GET /api/autopilots/{id} is
|
||
// the source of truth for the populated template.
|
||
r := autopilotToResponse(row.Autopilot, nil)
|
||
r.TriggerKinds = row.TriggerKinds
|
||
if row.NextRunAt.Valid {
|
||
r.NextRunAt = timestampToPtr(row.NextRunAt)
|
||
}
|
||
if row.LastRunStatus != "" {
|
||
s := row.LastRunStatus
|
||
r.LastRunStatus = &s
|
||
}
|
||
if callerErr == nil {
|
||
_, isCollaborator := collabSet[uuidToString(row.Autopilot.ID)]
|
||
cw := autopilotWriteByOwnership(row.Autopilot, caller) || isCollaborator
|
||
r.CanWrite = &cw
|
||
}
|
||
resp[i] = r
|
||
}
|
||
writeJSON(w, http.StatusOK, map[string]any{"autopilots": resp, "total": len(resp)})
|
||
}
|
||
|
||
func (h *Handler) GetAutopilot(w http.ResponseWriter, r *http.Request) {
|
||
id := chi.URLParam(r, "id")
|
||
workspaceID := h.resolveWorkspaceID(r)
|
||
|
||
autopilot, ok := h.loadAutopilotInWorkspace(w, r, id, workspaceID)
|
||
if !ok {
|
||
return
|
||
}
|
||
|
||
subs, err := h.Queries.ListAutopilotSubscribers(r.Context(), autopilot.ID)
|
||
if err != nil {
|
||
// Don't 500 the detail fetch over template metadata.
|
||
subs = nil
|
||
}
|
||
resp := autopilotToResponse(autopilot, subs)
|
||
|
||
// Resolve the caller's write access once: it both stamps can_write and
|
||
// gates webhook-secret exposure. Webhook tokens are trigger-granting
|
||
// secrets (anyone who reads the token can fire the autopilot from outside
|
||
// the permission system), so only writers — the creator, a workspace
|
||
// owner/admin, or a granted collaborator — get the live token/URL; every
|
||
// other member sees the trigger metadata with the secret fields stripped
|
||
// (MUL-3807).
|
||
canWrite := false
|
||
canManageAccess := false
|
||
if member, err := h.getWorkspaceMember(r.Context(), requestUserID(r), workspaceID); err == nil {
|
||
canWrite = h.memberCanWriteAutopilot(r.Context(), autopilot, member)
|
||
// Managing the access list is narrower than write: collaborators can
|
||
// write but cannot re-grant (MUL-3807).
|
||
canManageAccess = autopilotWriteByOwnership(autopilot, member)
|
||
}
|
||
resp.CanWrite = &canWrite
|
||
resp.CanManageAccess = &canManageAccess
|
||
|
||
// Include triggers.
|
||
triggers, err := h.Queries.ListAutopilotTriggers(r.Context(), autopilot.ID)
|
||
if err != nil {
|
||
triggers = nil
|
||
}
|
||
triggerResp := make([]AutopilotTriggerResponse, len(triggers))
|
||
for i, t := range triggers {
|
||
tr := h.triggerToResponse(t)
|
||
if !canWrite {
|
||
tr.WebhookToken = nil
|
||
tr.WebhookPath = nil
|
||
tr.WebhookURL = nil
|
||
}
|
||
triggerResp[i] = tr
|
||
}
|
||
|
||
// Include the explicit collaborator grants so the "manage access" UI can
|
||
// render the current list without a second round-trip.
|
||
collaborators, err := h.Queries.ListAutopilotCollaborators(r.Context(), autopilot.ID)
|
||
if err != nil {
|
||
collaborators = nil
|
||
}
|
||
collabResp := make([]AutopilotCollaboratorEntry, len(collaborators))
|
||
for i, c := range collaborators {
|
||
collabResp[i] = collaboratorToEntry(c)
|
||
}
|
||
|
||
writeJSON(w, http.StatusOK, map[string]any{
|
||
"autopilot": resp,
|
||
"triggers": triggerResp,
|
||
"collaborators": collabResp,
|
||
})
|
||
}
|
||
|
||
func (h *Handler) loadAutopilotInWorkspace(w http.ResponseWriter, r *http.Request, autopilotID, workspaceID string) (db.Autopilot, bool) {
|
||
autopilotUUID, ok := parseUUIDOrBadRequest(w, autopilotID, "autopilot id")
|
||
if !ok {
|
||
return db.Autopilot{}, false
|
||
}
|
||
wsUUID, ok := parseUUIDOrBadRequest(w, workspaceID, "workspace id")
|
||
if !ok {
|
||
return db.Autopilot{}, false
|
||
}
|
||
|
||
autopilot, err := h.Queries.GetAutopilotInWorkspace(r.Context(), db.GetAutopilotInWorkspaceParams{
|
||
ID: autopilotUUID,
|
||
WorkspaceID: wsUUID,
|
||
})
|
||
if err != nil {
|
||
writeError(w, http.StatusNotFound, "autopilot not found")
|
||
return db.Autopilot{}, false
|
||
}
|
||
return autopilot, true
|
||
}
|
||
|
||
// autopilotWriteByOwnership is the implicit, query-free part of the write
|
||
// predicate: the autopilot's creator and workspace owners/admins always have
|
||
// write access. Explicit collaborator grants (memberCanWriteAutopilot) layer
|
||
// on top of this (MUL-3807).
|
||
func autopilotWriteByOwnership(ap db.Autopilot, member db.Member) bool {
|
||
if roleAllowed(member.Role, "owner", "admin") {
|
||
return true
|
||
}
|
||
return ap.CreatedByType == "member" && uuidToString(ap.CreatedByID) == uuidToString(member.UserID)
|
||
}
|
||
|
||
// memberCanWriteAutopilot reports whether the given member may perform write or
|
||
// execute operations on the autopilot — editing it, deleting it, triggering
|
||
// runs, replaying deliveries, and managing its triggers, webhook secrets, and
|
||
// access list. Write access is held by the autopilot's creator, by workspace
|
||
// owners/admins, and by members explicitly granted as collaborators. The same
|
||
// predicate also gates whether webhook secrets are exposed on the read path,
|
||
// since seeing a webhook token is equivalent to being able to trigger.
|
||
func (h *Handler) memberCanWriteAutopilot(ctx context.Context, ap db.Autopilot, member db.Member) bool {
|
||
if autopilotWriteByOwnership(ap, member) {
|
||
return true
|
||
}
|
||
granted, err := h.Queries.IsAutopilotCollaborator(ctx, db.IsAutopilotCollaboratorParams{
|
||
AutopilotID: ap.ID,
|
||
UserID: member.UserID,
|
||
})
|
||
return err == nil && granted
|
||
}
|
||
|
||
// requireAutopilotWrite enforces memberCanWriteAutopilot for a mutating/
|
||
// executing request. On failure it writes the response (404 when the caller is
|
||
// not a member of the workspace, 403 otherwise) and returns false; the caller
|
||
// must return early. On success it returns true.
|
||
func (h *Handler) requireAutopilotWrite(w http.ResponseWriter, r *http.Request, ap db.Autopilot, workspaceID string) bool {
|
||
member, ok := h.workspaceMember(w, r, workspaceID)
|
||
if !ok {
|
||
return false
|
||
}
|
||
if !h.memberCanWriteAutopilot(r.Context(), ap, member) {
|
||
writeError(w, http.StatusForbidden, "only the autopilot creator, a workspace admin, or a granted collaborator can manage this autopilot")
|
||
return false
|
||
}
|
||
return true
|
||
}
|
||
|
||
// requireAutopilotAccessManagement enforces the narrower predicate used by the
|
||
// collaborator (access list) endpoints: only the autopilot's creator or a
|
||
// workspace owner/admin may grant or revoke access. A granted collaborator
|
||
// keeps its own write/execute rights (edit, trigger, manage triggers/secrets)
|
||
// but cannot manage the access list — this stops a collaborator from
|
||
// re-granting access to others or revoking peers (privilege escalation).
|
||
// See MUL-3807.
|
||
func (h *Handler) requireAutopilotAccessManagement(w http.ResponseWriter, r *http.Request, ap db.Autopilot, workspaceID string) bool {
|
||
member, ok := h.workspaceMember(w, r, workspaceID)
|
||
if !ok {
|
||
return false
|
||
}
|
||
if !autopilotWriteByOwnership(ap, member) {
|
||
writeError(w, http.StatusForbidden, "only the autopilot creator or a workspace admin can manage access")
|
||
return false
|
||
}
|
||
return true
|
||
}
|
||
|
||
func (h *Handler) CreateAutopilot(w http.ResponseWriter, r *http.Request) {
|
||
var req CreateAutopilotRequest
|
||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||
writeError(w, http.StatusBadRequest, "invalid request body")
|
||
return
|
||
}
|
||
if req.Title == "" {
|
||
writeError(w, http.StatusBadRequest, "title is required")
|
||
return
|
||
}
|
||
if req.AssigneeID == "" {
|
||
writeError(w, http.StatusBadRequest, "assignee_id is required")
|
||
return
|
||
}
|
||
if req.ExecutionMode == "" {
|
||
writeError(w, http.StatusBadRequest, "execution_mode is required")
|
||
return
|
||
}
|
||
if req.ExecutionMode != "create_issue" && req.ExecutionMode != "run_only" {
|
||
writeError(w, http.StatusBadRequest, "execution_mode must be create_issue or run_only")
|
||
return
|
||
}
|
||
if req.IssueTitleTemplate != nil {
|
||
if err := service.ValidateIssueTitleTemplate(*req.IssueTitleTemplate); err != nil {
|
||
writeError(w, http.StatusBadRequest, err.Error())
|
||
return
|
||
}
|
||
}
|
||
|
||
workspaceID := h.resolveWorkspaceID(r)
|
||
userID, ok := requireUserID(w, r)
|
||
if !ok {
|
||
return
|
||
}
|
||
|
||
assigneeUUID, ok := parseUUIDOrBadRequest(w, req.AssigneeID, "assignee_id")
|
||
if !ok {
|
||
return
|
||
}
|
||
wsUUID, ok := parseUUIDOrBadRequest(w, workspaceID, "workspace id")
|
||
if !ok {
|
||
return
|
||
}
|
||
|
||
assigneeType := "agent"
|
||
if req.AssigneeType != nil && *req.AssigneeType != "" {
|
||
assigneeType = *req.AssigneeType
|
||
}
|
||
if !isValidAutopilotAssigneeType(assigneeType) {
|
||
writeError(w, http.StatusBadRequest, "assignee_type must be agent or squad")
|
||
return
|
||
}
|
||
if !h.validateAutopilotAssignee(w, r, assigneeType, assigneeUUID, wsUUID) {
|
||
return
|
||
}
|
||
projectID, ok := h.parseAutopilotProjectID(w, r, req.ProjectID, wsUUID)
|
||
if !ok {
|
||
return
|
||
}
|
||
|
||
// Validate before insert so a bad payload doesn't half-create the row.
|
||
subscriberUUIDs, ok := h.validateAutopilotSubscribers(w, r, req.Subscribers, workspaceID)
|
||
if !ok {
|
||
return
|
||
}
|
||
|
||
tx, err := h.TxStarter.Begin(r.Context())
|
||
if err != nil {
|
||
writeError(w, http.StatusInternalServerError, "failed to create autopilot")
|
||
return
|
||
}
|
||
defer tx.Rollback(r.Context())
|
||
qtx := h.Queries.WithTx(tx)
|
||
|
||
autopilot, err := qtx.CreateAutopilot(r.Context(), db.CreateAutopilotParams{
|
||
WorkspaceID: wsUUID,
|
||
Title: req.Title,
|
||
AssigneeType: assigneeType,
|
||
AssigneeID: assigneeUUID,
|
||
Status: "active",
|
||
ExecutionMode: req.ExecutionMode,
|
||
CreatedByType: "member",
|
||
CreatedByID: parseUUID(userID),
|
||
Description: ptrToText(req.Description),
|
||
IssueTitleTemplate: ptrToText(req.IssueTitleTemplate),
|
||
ProjectID: projectID,
|
||
})
|
||
if err != nil {
|
||
writeError(w, http.StatusInternalServerError, "failed to create autopilot")
|
||
return
|
||
}
|
||
|
||
for _, uid := range subscriberUUIDs {
|
||
if err := qtx.AddAutopilotSubscriber(r.Context(), db.AddAutopilotSubscriberParams{
|
||
AutopilotID: autopilot.ID,
|
||
UserType: "member",
|
||
UserID: uid,
|
||
}); err != nil {
|
||
writeError(w, http.StatusInternalServerError, "failed to add autopilot subscriber")
|
||
return
|
||
}
|
||
}
|
||
if err := tx.Commit(r.Context()); err != nil {
|
||
writeError(w, http.StatusInternalServerError, "failed to create autopilot")
|
||
return
|
||
}
|
||
subs, err := h.Queries.ListAutopilotSubscribers(r.Context(), autopilot.ID)
|
||
if err != nil {
|
||
subs = nil
|
||
}
|
||
|
||
resp := autopilotToResponse(autopilot, subs)
|
||
h.publish(protocol.EventAutopilotCreated, workspaceID, "member", userID, map[string]any{"autopilot": resp})
|
||
obsmetrics.RecordEvent(h.Analytics, h.Metrics, analytics.AutopilotCreated(
|
||
userID,
|
||
workspaceID,
|
||
uuidToString(autopilot.ID),
|
||
"manual",
|
||
"manual",
|
||
))
|
||
writeJSON(w, http.StatusCreated, resp)
|
||
}
|
||
|
||
// Writes an HTTP error and returns ok=false on the first invalid entry.
|
||
// Returns (nil, true) when raw is empty — caller distinguishes "leave alone"
|
||
// from "replace with empty" via the raw-fields map, not this return.
|
||
func (h *Handler) validateAutopilotSubscribers(
|
||
w http.ResponseWriter,
|
||
r *http.Request,
|
||
raw []SubscriberInput,
|
||
workspaceID string,
|
||
) ([]pgtype.UUID, bool) {
|
||
if len(raw) == 0 {
|
||
return nil, true
|
||
}
|
||
out := make([]pgtype.UUID, 0, len(raw))
|
||
seen := make(map[string]bool, len(raw))
|
||
for i, entry := range raw {
|
||
if entry.UserType != "member" {
|
||
writeError(w, http.StatusBadRequest, fmt.Sprintf("subscribers[%d].user_type must be 'member'", i))
|
||
return nil, false
|
||
}
|
||
if entry.UserID == "" {
|
||
writeError(w, http.StatusBadRequest, fmt.Sprintf("subscribers[%d].user_id is required", i))
|
||
return nil, false
|
||
}
|
||
uid, ok := parseUUIDOrBadRequest(w, entry.UserID, fmt.Sprintf("subscribers[%d].user_id", i))
|
||
if !ok {
|
||
return nil, false
|
||
}
|
||
if seen[entry.UserID] {
|
||
continue
|
||
}
|
||
seen[entry.UserID] = true
|
||
if !h.isWorkspaceEntity(r.Context(), entry.UserType, entry.UserID, workspaceID) {
|
||
writeError(w, http.StatusBadRequest, fmt.Sprintf("subscribers[%d] is not a member of this workspace", i))
|
||
return nil, false
|
||
}
|
||
out = append(out, uid)
|
||
}
|
||
return out, true
|
||
}
|
||
|
||
func (h *Handler) UpdateAutopilot(w http.ResponseWriter, r *http.Request) {
|
||
id := chi.URLParam(r, "id")
|
||
workspaceID := h.resolveWorkspaceID(r)
|
||
|
||
prev, ok := h.loadAutopilotInWorkspace(w, r, id, workspaceID)
|
||
if !ok {
|
||
return
|
||
}
|
||
if !h.requireAutopilotWrite(w, r, prev, workspaceID) {
|
||
return
|
||
}
|
||
|
||
userID, ok := requireUserID(w, r)
|
||
if !ok {
|
||
return
|
||
}
|
||
|
||
bodyBytes, err := io.ReadAll(r.Body)
|
||
if err != nil {
|
||
writeError(w, http.StatusBadRequest, "failed to read request body")
|
||
return
|
||
}
|
||
var req UpdateAutopilotRequest
|
||
if err := json.Unmarshal(bodyBytes, &req); err != nil {
|
||
writeError(w, http.StatusBadRequest, "invalid request body")
|
||
return
|
||
}
|
||
var rawFields map[string]json.RawMessage
|
||
json.Unmarshal(bodyBytes, &rawFields)
|
||
|
||
params := db.UpdateAutopilotParams{
|
||
ID: prev.ID,
|
||
Description: prev.Description,
|
||
AssigneeID: prev.AssigneeID,
|
||
IssueTitleTemplate: prev.IssueTitleTemplate,
|
||
ProjectID: prev.ProjectID,
|
||
}
|
||
if req.Title != nil {
|
||
params.Title = pgtype.Text{String: *req.Title, Valid: true}
|
||
}
|
||
if req.Status != nil {
|
||
params.Status = pgtype.Text{String: *req.Status, Valid: true}
|
||
}
|
||
if req.ExecutionMode != nil {
|
||
params.ExecutionMode = pgtype.Text{String: *req.ExecutionMode, Valid: true}
|
||
}
|
||
if _, ok := rawFields["description"]; ok {
|
||
params.Description = ptrToText(req.Description)
|
||
}
|
||
if _, ok := rawFields["issue_title_template"]; ok {
|
||
if req.IssueTitleTemplate != nil {
|
||
if err := service.ValidateIssueTitleTemplate(*req.IssueTitleTemplate); err != nil {
|
||
writeError(w, http.StatusBadRequest, err.Error())
|
||
return
|
||
}
|
||
}
|
||
params.IssueTitleTemplate = ptrToText(req.IssueTitleTemplate)
|
||
}
|
||
if _, ok := rawFields["project_id"]; ok {
|
||
projectID, ok := h.parseAutopilotProjectID(w, r, req.ProjectID, prev.WorkspaceID)
|
||
if !ok {
|
||
return
|
||
}
|
||
params.ProjectID = projectID
|
||
}
|
||
// assignee_type and assignee_id are validated as a pair: switching
|
||
// between agent and squad without supplying a new id would leave the
|
||
// row pointing at the wrong table. The client is expected to send both
|
||
// fields on any change; partial updates that change only one are
|
||
// rejected.
|
||
_, typeSent := rawFields["assignee_type"]
|
||
_, idSent := rawFields["assignee_id"]
|
||
if typeSent || idSent {
|
||
nextType := prev.AssigneeType
|
||
if typeSent && req.AssigneeType != nil && *req.AssigneeType != "" {
|
||
nextType = *req.AssigneeType
|
||
}
|
||
if !isValidAutopilotAssigneeType(nextType) {
|
||
writeError(w, http.StatusBadRequest, "assignee_type must be agent or squad")
|
||
return
|
||
}
|
||
nextID := prev.AssigneeID
|
||
if idSent {
|
||
if req.AssigneeID == nil {
|
||
writeError(w, http.StatusBadRequest, "assignee_id cannot be null")
|
||
return
|
||
}
|
||
parsed, ok := parseUUIDOrBadRequest(w, *req.AssigneeID, "assignee_id")
|
||
if !ok {
|
||
return
|
||
}
|
||
nextID = parsed
|
||
}
|
||
// Reject the agent↔squad switch without a paired id, otherwise the
|
||
// row would address agent(id) under assignee_type='squad' or vice
|
||
// versa.
|
||
if typeSent && !idSent && nextType != prev.AssigneeType {
|
||
writeError(w, http.StatusBadRequest, "assignee_id is required when changing assignee_type")
|
||
return
|
||
}
|
||
if !h.validateAutopilotAssignee(w, r, nextType, nextID, prev.WorkspaceID) {
|
||
return
|
||
}
|
||
if typeSent {
|
||
params.AssigneeType = pgtype.Text{String: nextType, Valid: true}
|
||
}
|
||
if idSent {
|
||
params.AssigneeID = nextID
|
||
}
|
||
}
|
||
|
||
// Subscribers are validated up-front (before any write) so a bad payload
|
||
// doesn't leave the autopilot row updated but the template stale.
|
||
var (
|
||
subscriberUUIDs []pgtype.UUID
|
||
replaceSubscribers bool
|
||
)
|
||
if _, sent := rawFields["subscribers"]; sent {
|
||
replaceSubscribers = true
|
||
validated, vok := h.validateAutopilotSubscribers(w, r, req.Subscribers, workspaceID)
|
||
if !vok {
|
||
return
|
||
}
|
||
subscriberUUIDs = validated
|
||
}
|
||
|
||
tx, err := h.TxStarter.Begin(r.Context())
|
||
if err != nil {
|
||
writeError(w, http.StatusInternalServerError, "failed to update autopilot")
|
||
return
|
||
}
|
||
defer tx.Rollback(r.Context())
|
||
qtx := h.Queries.WithTx(tx)
|
||
|
||
autopilot, err := qtx.UpdateAutopilot(r.Context(), params)
|
||
if err != nil {
|
||
writeError(w, http.StatusInternalServerError, "failed to update autopilot")
|
||
return
|
||
}
|
||
|
||
if replaceSubscribers {
|
||
if err := qtx.DeleteAutopilotSubscribersForAutopilot(r.Context(), autopilot.ID); err != nil {
|
||
writeError(w, http.StatusInternalServerError, "failed to update subscribers")
|
||
return
|
||
}
|
||
for _, uid := range subscriberUUIDs {
|
||
if err := qtx.AddAutopilotSubscriber(r.Context(), db.AddAutopilotSubscriberParams{
|
||
AutopilotID: autopilot.ID,
|
||
UserType: "member",
|
||
UserID: uid,
|
||
}); err != nil {
|
||
writeError(w, http.StatusInternalServerError, "failed to add autopilot subscriber")
|
||
return
|
||
}
|
||
}
|
||
}
|
||
|
||
if err := tx.Commit(r.Context()); err != nil {
|
||
writeError(w, http.StatusInternalServerError, "failed to update autopilot")
|
||
return
|
||
}
|
||
|
||
subs, err := h.Queries.ListAutopilotSubscribers(r.Context(), autopilot.ID)
|
||
if err != nil {
|
||
subs = nil
|
||
}
|
||
resp := autopilotToResponse(autopilot, subs)
|
||
h.publish(protocol.EventAutopilotUpdated, workspaceID, "member", userID, map[string]any{"autopilot": resp})
|
||
writeJSON(w, http.StatusOK, resp)
|
||
}
|
||
|
||
func (h *Handler) parseAutopilotProjectID(
|
||
w http.ResponseWriter,
|
||
r *http.Request,
|
||
raw *string,
|
||
workspaceID pgtype.UUID,
|
||
) (pgtype.UUID, bool) {
|
||
if raw == nil || *raw == "" {
|
||
return pgtype.UUID{}, true
|
||
}
|
||
projectID, ok := parseUUIDOrBadRequest(w, *raw, "project_id")
|
||
if !ok {
|
||
return pgtype.UUID{}, false
|
||
}
|
||
if _, err := h.Queries.GetProjectInWorkspace(r.Context(), db.GetProjectInWorkspaceParams{
|
||
ID: projectID,
|
||
WorkspaceID: workspaceID,
|
||
}); err != nil {
|
||
writeError(w, http.StatusBadRequest, "project_id must reference a project in this workspace")
|
||
return pgtype.UUID{}, false
|
||
}
|
||
return projectID, true
|
||
}
|
||
|
||
func (h *Handler) DeleteAutopilot(w http.ResponseWriter, r *http.Request) {
|
||
id := chi.URLParam(r, "id")
|
||
workspaceID := h.resolveWorkspaceID(r)
|
||
|
||
idUUID, ok := parseUUIDOrBadRequest(w, id, "autopilot id")
|
||
if !ok {
|
||
return
|
||
}
|
||
wsUUID, ok := parseUUIDOrBadRequest(w, workspaceID, "workspace id")
|
||
if !ok {
|
||
return
|
||
}
|
||
|
||
ap, err := h.Queries.GetAutopilotInWorkspace(r.Context(), db.GetAutopilotInWorkspaceParams{
|
||
ID: idUUID,
|
||
WorkspaceID: wsUUID,
|
||
})
|
||
if err != nil {
|
||
writeError(w, http.StatusNotFound, "autopilot not found")
|
||
return
|
||
}
|
||
if !h.requireAutopilotWrite(w, r, ap, workspaceID) {
|
||
return
|
||
}
|
||
|
||
userID, ok := requireUserID(w, r)
|
||
if !ok {
|
||
return
|
||
}
|
||
|
||
// autopilot_subscriber carries no DB-level foreign key/cascade (repo rule:
|
||
// referential cleanup lives in the application layer), so delete the
|
||
// subscriber template alongside the autopilot in one transaction. Without
|
||
// this, deleting an autopilot would orphan its subscriber rows.
|
||
tx, err := h.TxStarter.Begin(r.Context())
|
||
if err != nil {
|
||
writeError(w, http.StatusInternalServerError, "failed to delete autopilot")
|
||
return
|
||
}
|
||
defer tx.Rollback(r.Context())
|
||
qtx := h.Queries.WithTx(tx)
|
||
|
||
if err := qtx.DeleteAutopilotSubscribersForAutopilot(r.Context(), idUUID); err != nil {
|
||
writeError(w, http.StatusInternalServerError, "failed to delete autopilot")
|
||
return
|
||
}
|
||
if err := qtx.DeleteAutopilotCollaboratorsForAutopilot(r.Context(), idUUID); err != nil {
|
||
writeError(w, http.StatusInternalServerError, "failed to delete autopilot")
|
||
return
|
||
}
|
||
if err := qtx.DeleteAutopilot(r.Context(), idUUID); err != nil {
|
||
writeError(w, http.StatusInternalServerError, "failed to delete autopilot")
|
||
return
|
||
}
|
||
if err := tx.Commit(r.Context()); err != nil {
|
||
writeError(w, http.StatusInternalServerError, "failed to delete autopilot")
|
||
return
|
||
}
|
||
|
||
h.publish(protocol.EventAutopilotDeleted, workspaceID, "member", userID, map[string]any{"autopilot_id": uuidToString(idUUID)})
|
||
w.WriteHeader(http.StatusNoContent)
|
||
}
|
||
|
||
// ── Collaborator (access grant) management ───────────────────────────────────
|
||
|
||
type AutopilotCollaboratorRequest struct {
|
||
UserID string `json:"user_id"`
|
||
}
|
||
|
||
func (h *Handler) writeAutopilotCollaborators(w http.ResponseWriter, r *http.Request, autopilotID pgtype.UUID, status int) {
|
||
collaborators, err := h.Queries.ListAutopilotCollaborators(r.Context(), autopilotID)
|
||
if err != nil {
|
||
writeError(w, http.StatusInternalServerError, "failed to load collaborators")
|
||
return
|
||
}
|
||
resp := make([]AutopilotCollaboratorEntry, len(collaborators))
|
||
for i, c := range collaborators {
|
||
resp[i] = collaboratorToEntry(c)
|
||
}
|
||
writeJSON(w, status, map[string]any{"collaborators": resp})
|
||
}
|
||
|
||
// AddAutopilotCollaborator grants a workspace member explicit write access to
|
||
// the autopilot. Only the autopilot's creator or a workspace owner/admin can
|
||
// manage the access list; a granted collaborator cannot re-grant to others
|
||
// (privilege escalation). See MUL-3807.
|
||
func (h *Handler) AddAutopilotCollaborator(w http.ResponseWriter, r *http.Request) {
|
||
id := chi.URLParam(r, "id")
|
||
workspaceID := h.resolveWorkspaceID(r)
|
||
|
||
ap, ok := h.loadAutopilotInWorkspace(w, r, id, workspaceID)
|
||
if !ok {
|
||
return
|
||
}
|
||
if !h.requireAutopilotAccessManagement(w, r, ap, workspaceID) {
|
||
return
|
||
}
|
||
|
||
var req AutopilotCollaboratorRequest
|
||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||
writeError(w, http.StatusBadRequest, "invalid request body")
|
||
return
|
||
}
|
||
if req.UserID == "" {
|
||
writeError(w, http.StatusBadRequest, "user_id is required")
|
||
return
|
||
}
|
||
targetUUID, ok := parseUUIDOrBadRequest(w, req.UserID, "user_id")
|
||
if !ok {
|
||
return
|
||
}
|
||
// Only workspace members can be granted access — agents already reach
|
||
// autopilots through their own dispatch path, not this grant list.
|
||
if !h.isWorkspaceEntity(r.Context(), "member", req.UserID, workspaceID) {
|
||
writeError(w, http.StatusBadRequest, "user_id must be a member of this workspace")
|
||
return
|
||
}
|
||
|
||
grantedBy, ok := requireUserID(w, r)
|
||
if !ok {
|
||
return
|
||
}
|
||
grantedByUUID, ok := parseUUIDOrBadRequest(w, grantedBy, "granted_by")
|
||
if !ok {
|
||
return
|
||
}
|
||
|
||
if _, err := h.Queries.AddAutopilotCollaborator(r.Context(), db.AddAutopilotCollaboratorParams{
|
||
AutopilotID: ap.ID,
|
||
UserType: "member",
|
||
UserID: targetUUID,
|
||
GrantedBy: grantedByUUID,
|
||
}); err != nil {
|
||
writeError(w, http.StatusInternalServerError, "failed to grant access")
|
||
return
|
||
}
|
||
|
||
h.publish(protocol.EventAutopilotUpdated, workspaceID, "member", grantedBy, map[string]any{
|
||
"autopilot_id": uuidToString(ap.ID),
|
||
})
|
||
h.writeAutopilotCollaborators(w, r, ap.ID, http.StatusCreated)
|
||
}
|
||
|
||
// RemoveAutopilotCollaborator revokes a member's explicit write grant. Only the
|
||
// autopilot's creator or a workspace owner/admin can manage the access list; a
|
||
// collaborator cannot revoke peers. Implicit writers (creator / owner / admin)
|
||
// are unaffected — there is no row to remove.
|
||
func (h *Handler) RemoveAutopilotCollaborator(w http.ResponseWriter, r *http.Request) {
|
||
id := chi.URLParam(r, "id")
|
||
userID := chi.URLParam(r, "userId")
|
||
workspaceID := h.resolveWorkspaceID(r)
|
||
|
||
ap, ok := h.loadAutopilotInWorkspace(w, r, id, workspaceID)
|
||
if !ok {
|
||
return
|
||
}
|
||
if !h.requireAutopilotAccessManagement(w, r, ap, workspaceID) {
|
||
return
|
||
}
|
||
targetUUID, ok := parseUUIDOrBadRequest(w, userID, "user id")
|
||
if !ok {
|
||
return
|
||
}
|
||
|
||
if err := h.Queries.DeleteAutopilotCollaborator(r.Context(), db.DeleteAutopilotCollaboratorParams{
|
||
AutopilotID: ap.ID,
|
||
UserType: "member",
|
||
UserID: targetUUID,
|
||
}); err != nil {
|
||
writeError(w, http.StatusInternalServerError, "failed to revoke access")
|
||
return
|
||
}
|
||
|
||
actor := requestUserID(r)
|
||
h.publish(protocol.EventAutopilotUpdated, workspaceID, "member", actor, map[string]any{
|
||
"autopilot_id": uuidToString(ap.ID),
|
||
})
|
||
h.writeAutopilotCollaborators(w, r, ap.ID, http.StatusOK)
|
||
}
|
||
|
||
// ── Trigger management ──────────────────────────────────────────────────────
|
||
|
||
func (h *Handler) CreateAutopilotTrigger(w http.ResponseWriter, r *http.Request) {
|
||
autopilotID := chi.URLParam(r, "id")
|
||
workspaceID := h.resolveWorkspaceID(r)
|
||
|
||
ap, ok := h.loadAutopilotInWorkspace(w, r, autopilotID, workspaceID)
|
||
if !ok {
|
||
return
|
||
}
|
||
if !h.requireAutopilotWrite(w, r, ap, workspaceID) {
|
||
return
|
||
}
|
||
|
||
var req CreateAutopilotTriggerRequest
|
||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||
writeError(w, http.StatusBadRequest, "invalid request body")
|
||
return
|
||
}
|
||
if req.Kind == "" {
|
||
writeError(w, http.StatusBadRequest, "kind is required")
|
||
return
|
||
}
|
||
if req.Kind != "schedule" && req.Kind != "webhook" {
|
||
// "api" kind is deprecated: it was reserved-but-inert (no scheduler,
|
||
// no ingress route), and the only way to actually fire one was via
|
||
// the manual /trigger endpoint — which already works regardless of
|
||
// trigger kind. Surface stragglers with 400 so callers move to
|
||
// schedule or webhook.
|
||
writeError(w, http.StatusBadRequest, "kind must be schedule or webhook")
|
||
return
|
||
}
|
||
if req.Kind == "schedule" && (req.CronExpression == nil || *req.CronExpression == "") {
|
||
writeError(w, http.StatusBadRequest, "cron_expression is required for schedule triggers")
|
||
return
|
||
}
|
||
if req.Kind == "webhook" && req.Timezone != nil && *req.Timezone != "" {
|
||
// Webhook triggers fire on demand from external POSTs — they have no
|
||
// next_run_at to compute, so a timezone is meaningless. Reject loudly
|
||
// instead of silently dropping the field.
|
||
writeError(w, http.StatusBadRequest, "timezone is not valid for webhook triggers")
|
||
return
|
||
}
|
||
if req.Kind != "webhook" && len(req.EventFilters) > 0 {
|
||
// event_filters narrows webhook ingress — it has no meaning for a
|
||
// schedule trigger and would otherwise be silently dropped.
|
||
writeError(w, http.StatusBadRequest, "event_filters is only valid for webhook triggers")
|
||
return
|
||
}
|
||
if err := validateWebhookEventFilters(req.EventFilters); err != nil {
|
||
writeError(w, http.StatusBadRequest, err.Error())
|
||
return
|
||
}
|
||
// Provider only applies to webhook triggers and the value space is
|
||
// closed — reject unknowns early so a typo on create doesn't quietly
|
||
// degrade into a "generic" trigger that bypasses provider-specific
|
||
// dedupe / signature behaviour.
|
||
provider := "generic"
|
||
if req.Provider != nil && *req.Provider != "" {
|
||
if req.Kind != "webhook" {
|
||
writeError(w, http.StatusBadRequest, "provider is only valid for webhook triggers")
|
||
return
|
||
}
|
||
if !isAllowedWebhookProvider(*req.Provider) {
|
||
writeError(w, http.StatusBadRequest, "provider must be generic or github")
|
||
return
|
||
}
|
||
provider = *req.Provider
|
||
}
|
||
|
||
if req.Timezone != nil && *req.Timezone != "" {
|
||
if err := service.ValidateTimezone(*req.Timezone); err != nil {
|
||
writeError(w, http.StatusBadRequest, err.Error())
|
||
return
|
||
}
|
||
}
|
||
|
||
// kind-specific normalization. Webhook triggers ignore cron/timezone/
|
||
// next_run_at — they're fired on demand.
|
||
var (
|
||
nextRunAt pgtype.Timestamptz
|
||
cronText pgtype.Text
|
||
tzText pgtype.Text
|
||
webhookToken pgtype.Text
|
||
)
|
||
switch req.Kind {
|
||
case "schedule":
|
||
cronText = ptrToText(req.CronExpression)
|
||
tzText = ptrToText(req.Timezone)
|
||
tz := "UTC"
|
||
if req.Timezone != nil && *req.Timezone != "" {
|
||
tz = *req.Timezone
|
||
}
|
||
t, err := computeNextRun(*req.CronExpression, tz)
|
||
if err != nil {
|
||
writeError(w, http.StatusBadRequest, err.Error())
|
||
return
|
||
}
|
||
nextRunAt = pgtype.Timestamptz{Time: t, Valid: true}
|
||
case "webhook":
|
||
// Mint the token BEFORE the INSERT so the row never exists in a
|
||
// half-written kind=webhook + webhook_token=NULL state. If the
|
||
// random token happens to collide with an existing unique-index
|
||
// entry (vanishingly unlikely with 256 bits but the retry keeps
|
||
// the failure mode obvious if RNG is degraded), we re-generate
|
||
// and re-INSERT — never UPDATE.
|
||
eventFiltersBytes, err := encodeWebhookEventFilters(req.EventFilters)
|
||
if err != nil {
|
||
writeError(w, http.StatusInternalServerError, "failed to encode event_filters")
|
||
return
|
||
}
|
||
trigger, err := h.createWebhookTriggerWithMintedToken(r, ap.ID, ptrToText(req.Label), provider, eventFiltersBytes)
|
||
if err != nil {
|
||
writeError(w, http.StatusInternalServerError, "failed to create trigger")
|
||
return
|
||
}
|
||
resp := h.triggerToResponse(trigger)
|
||
userID, _ := requireUserID(w, r)
|
||
h.publish(protocol.EventAutopilotUpdated, workspaceID, "member", userID, map[string]any{
|
||
"autopilot_id": uuidToString(ap.ID),
|
||
"trigger": resp,
|
||
})
|
||
writeJSON(w, http.StatusCreated, resp)
|
||
return
|
||
}
|
||
|
||
trigger, err := h.Queries.CreateAutopilotTrigger(r.Context(), db.CreateAutopilotTriggerParams{
|
||
AutopilotID: ap.ID,
|
||
Kind: req.Kind,
|
||
Enabled: true,
|
||
CronExpression: cronText,
|
||
Timezone: tzText,
|
||
NextRunAt: nextRunAt,
|
||
Label: ptrToText(req.Label),
|
||
WebhookToken: webhookToken,
|
||
})
|
||
if err != nil {
|
||
writeError(w, http.StatusInternalServerError, "failed to create trigger")
|
||
return
|
||
}
|
||
|
||
resp := h.triggerToResponse(trigger)
|
||
userID, _ := requireUserID(w, r)
|
||
h.publish(protocol.EventAutopilotUpdated, workspaceID, "member", userID, map[string]any{
|
||
"autopilot_id": uuidToString(ap.ID),
|
||
"trigger": resp,
|
||
})
|
||
writeJSON(w, http.StatusCreated, resp)
|
||
}
|
||
|
||
// createWebhookTriggerWithMintedToken atomically creates a webhook trigger
|
||
// with a freshly minted bearer token in the same INSERT. Avoids the older
|
||
// two-step (INSERT then UPDATE webhook_token) pattern which could leave a
|
||
// kind=webhook row with NULL webhook_token visible in the UI if the second
|
||
// statement failed.
|
||
//
|
||
// Retries on the unique-index collision case so a vanishingly-rare RNG
|
||
// collision turns into a clean retry rather than a 500.
|
||
func (h *Handler) createWebhookTriggerWithMintedToken(
|
||
r *http.Request,
|
||
autopilotID pgtype.UUID,
|
||
label pgtype.Text,
|
||
provider string,
|
||
eventFilters []byte,
|
||
) (db.AutopilotTrigger, error) {
|
||
for attempt := 0; attempt < 3; attempt++ {
|
||
token, err := generateWebhookToken()
|
||
if err != nil {
|
||
return db.AutopilotTrigger{}, err
|
||
}
|
||
trigger, err := h.Queries.CreateAutopilotTrigger(r.Context(), db.CreateAutopilotTriggerParams{
|
||
AutopilotID: autopilotID,
|
||
Kind: "webhook",
|
||
Enabled: true,
|
||
Label: label,
|
||
WebhookToken: pgtype.Text{String: token, Valid: true},
|
||
Provider: pgtype.Text{String: provider, Valid: provider != ""},
|
||
EventFilters: eventFilters,
|
||
})
|
||
if err == nil {
|
||
return trigger, nil
|
||
}
|
||
if !isUniqueViolation(err) {
|
||
return db.AutopilotTrigger{}, err
|
||
}
|
||
}
|
||
return db.AutopilotTrigger{}, fmt.Errorf("could not mint unique webhook token")
|
||
}
|
||
|
||
func isAllowedWebhookProvider(p string) bool {
|
||
switch p {
|
||
case "generic", "github":
|
||
return true
|
||
default:
|
||
return false
|
||
}
|
||
}
|
||
|
||
func isValidAutopilotAssigneeType(t string) bool {
|
||
switch t {
|
||
case "agent", "squad":
|
||
return true
|
||
default:
|
||
return false
|
||
}
|
||
}
|
||
|
||
// validateAutopilotAssignee checks that the assignee (agent or squad) exists
|
||
// in the given workspace, and for squad assignees that the squad's leader
|
||
// agent is in a workable state at create / update time. Writes an HTTP error
|
||
// and returns false on any failure.
|
||
//
|
||
// At dispatch time the same checks (resolveAutopilotLeader + AgentReadiness)
|
||
// run again — they live there to handle "leader was online at save time but
|
||
// went offline by trigger time". Save-time validation exists so the user gets
|
||
// immediate feedback ("can't pick this squad because its leader is archived")
|
||
// instead of discovering the autopilot is dead at the next schedule tick.
|
||
func (h *Handler) validateAutopilotAssignee(w http.ResponseWriter, r *http.Request, assigneeType string, assigneeID, workspaceID pgtype.UUID) bool {
|
||
switch assigneeType {
|
||
case "agent":
|
||
if _, err := h.Queries.GetAgentInWorkspace(r.Context(), db.GetAgentInWorkspaceParams{
|
||
ID: assigneeID,
|
||
WorkspaceID: workspaceID,
|
||
}); err != nil {
|
||
writeError(w, http.StatusBadRequest, "assignee must be a valid agent in this workspace")
|
||
return false
|
||
}
|
||
return true
|
||
case "squad":
|
||
squad, err := h.Queries.GetSquadInWorkspace(r.Context(), db.GetSquadInWorkspaceParams{
|
||
ID: assigneeID,
|
||
WorkspaceID: workspaceID,
|
||
})
|
||
if err != nil {
|
||
writeError(w, http.StatusBadRequest, "assignee must be a valid squad in this workspace")
|
||
return false
|
||
}
|
||
// Archived squads must be rejected at save time: the dispatcher will
|
||
// otherwise produce an unbroken stream of skipped runs against a
|
||
// squad that can never be revived without an explicit un-archive.
|
||
// Pair with TransferSquadAutopilotsToLeader on DeleteSquad so any
|
||
// autopilot that survives the archive flips to assignee_type='agent'
|
||
// (the leader) and stops referencing the dead squad row.
|
||
if squad.ArchivedAt.Valid {
|
||
writeError(w, http.StatusUnprocessableEntity, "squad is archived; pick a different squad")
|
||
return false
|
||
}
|
||
leader, err := h.Queries.GetAgent(r.Context(), squad.LeaderID)
|
||
if err != nil {
|
||
writeError(w, http.StatusBadRequest, "squad leader agent not found")
|
||
return false
|
||
}
|
||
if leader.ArchivedAt.Valid {
|
||
writeError(w, http.StatusUnprocessableEntity, "squad leader is archived; pick a different squad or rotate the leader before assigning autopilot")
|
||
return false
|
||
}
|
||
// Private-leader gate: the member configuring the autopilot must have
|
||
// access to the private leader, same as validateAssigneePair.
|
||
actorType, actorID := h.resolveActor(r, requestUserID(r), util.UUIDToString(workspaceID))
|
||
if !h.canInvokeAgent(r.Context(), leader, actorType, actorID, h.invokeOriginatorFromRequest(r, actorType, actorID), util.UUIDToString(workspaceID)) {
|
||
writeError(w, http.StatusForbidden, "cannot assign autopilot to squad with private leader")
|
||
return false
|
||
}
|
||
return true
|
||
default:
|
||
writeError(w, http.StatusBadRequest, "assignee_type must be agent or squad")
|
||
return false
|
||
}
|
||
}
|
||
|
||
func (h *Handler) UpdateAutopilotTrigger(w http.ResponseWriter, r *http.Request) {
|
||
autopilotID := chi.URLParam(r, "id")
|
||
triggerID := chi.URLParam(r, "triggerId")
|
||
workspaceID := h.resolveWorkspaceID(r)
|
||
|
||
ap, ok := h.loadAutopilotInWorkspace(w, r, autopilotID, workspaceID)
|
||
if !ok {
|
||
return
|
||
}
|
||
if !h.requireAutopilotWrite(w, r, ap, workspaceID) {
|
||
return
|
||
}
|
||
|
||
triggerUUID, ok := parseUUIDOrBadRequest(w, triggerID, "trigger id")
|
||
if !ok {
|
||
return
|
||
}
|
||
|
||
prev, err := h.Queries.GetAutopilotTrigger(r.Context(), triggerUUID)
|
||
if err != nil || uuidToString(prev.AutopilotID) != uuidToString(ap.ID) {
|
||
writeError(w, http.StatusNotFound, "trigger not found")
|
||
return
|
||
}
|
||
|
||
var req UpdateAutopilotTriggerRequest
|
||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||
writeError(w, http.StatusBadRequest, "invalid request body")
|
||
return
|
||
}
|
||
|
||
// Kind-specific validation. Mirrors the create-path discipline: cron
|
||
// and timezone only make sense on schedule triggers, so reject loudly
|
||
// rather than persisting fields that no code path reads. enabled and
|
||
// label remain valid on every kind.
|
||
if prev.Kind != "schedule" {
|
||
if req.CronExpression != nil {
|
||
writeError(w, http.StatusBadRequest, "cron_expression is only valid for schedule triggers")
|
||
return
|
||
}
|
||
if req.Timezone != nil {
|
||
writeError(w, http.StatusBadRequest, "timezone is only valid for schedule triggers")
|
||
return
|
||
}
|
||
}
|
||
|
||
params := db.UpdateAutopilotTriggerParams{
|
||
ID: prev.ID,
|
||
CronExpression: prev.CronExpression,
|
||
Timezone: prev.Timezone,
|
||
NextRunAt: prev.NextRunAt,
|
||
Label: prev.Label,
|
||
}
|
||
if req.Enabled != nil {
|
||
params.Enabled = pgtype.Bool{Bool: *req.Enabled, Valid: true}
|
||
}
|
||
if req.CronExpression != nil {
|
||
params.CronExpression = pgtype.Text{String: *req.CronExpression, Valid: true}
|
||
}
|
||
if req.Timezone != nil {
|
||
if *req.Timezone != "" {
|
||
if err := service.ValidateTimezone(*req.Timezone); err != nil {
|
||
writeError(w, http.StatusBadRequest, err.Error())
|
||
return
|
||
}
|
||
}
|
||
params.Timezone = pgtype.Text{String: *req.Timezone, Valid: true}
|
||
}
|
||
if req.Label != nil {
|
||
params.Label = pgtype.Text{String: *req.Label, Valid: true}
|
||
}
|
||
// Tri-state PATCH for event_filters. A nil pointer (field omitted or
|
||
// JSON null) leaves the existing row untouched — params.EventFilters
|
||
// stays unset and the COALESCE in the UPDATE preserves the previous
|
||
// value. A non-nil pointer is authoritative: an empty slice clears
|
||
// filters (encoded as the JSONB literal `[]` so COALESCE replaces
|
||
// rather than preserves), a populated slice replaces.
|
||
if req.EventFilters != nil {
|
||
if prev.Kind != "webhook" {
|
||
writeError(w, http.StatusBadRequest, "event_filters is only valid for webhook triggers")
|
||
return
|
||
}
|
||
if err := validateWebhookEventFilters(*req.EventFilters); err != nil {
|
||
writeError(w, http.StatusBadRequest, err.Error())
|
||
return
|
||
}
|
||
encoded, err := encodeWebhookEventFiltersAlways(*req.EventFilters)
|
||
if err != nil {
|
||
writeError(w, http.StatusInternalServerError, "failed to encode event_filters")
|
||
return
|
||
}
|
||
params.EventFilters = encoded
|
||
}
|
||
|
||
// Recompute next_run_at if cron or timezone changed.
|
||
cronExpr := prev.CronExpression.String
|
||
if req.CronExpression != nil {
|
||
cronExpr = *req.CronExpression
|
||
}
|
||
tz := "UTC"
|
||
if prev.Timezone.Valid {
|
||
tz = prev.Timezone.String
|
||
}
|
||
if req.Timezone != nil {
|
||
tz = *req.Timezone
|
||
}
|
||
if prev.Kind == "schedule" && cronExpr != "" {
|
||
t, err := computeNextRun(cronExpr, tz)
|
||
if err != nil {
|
||
writeError(w, http.StatusBadRequest, err.Error())
|
||
return
|
||
}
|
||
params.NextRunAt = pgtype.Timestamptz{Time: t, Valid: true}
|
||
}
|
||
|
||
trigger, err := h.Queries.UpdateAutopilotTrigger(r.Context(), params)
|
||
if err != nil {
|
||
writeError(w, http.StatusInternalServerError, "failed to update trigger")
|
||
return
|
||
}
|
||
|
||
resp := h.triggerToResponse(trigger)
|
||
userID, _ := requireUserID(w, r)
|
||
h.publish(protocol.EventAutopilotUpdated, workspaceID, "member", userID, map[string]any{
|
||
"autopilot_id": uuidToString(ap.ID),
|
||
"trigger": resp,
|
||
})
|
||
writeJSON(w, http.StatusOK, resp)
|
||
}
|
||
|
||
func (h *Handler) DeleteAutopilotTrigger(w http.ResponseWriter, r *http.Request) {
|
||
autopilotID := chi.URLParam(r, "id")
|
||
triggerID := chi.URLParam(r, "triggerId")
|
||
workspaceID := h.resolveWorkspaceID(r)
|
||
|
||
autopilotUUID, ok := parseUUIDOrBadRequest(w, autopilotID, "autopilot id")
|
||
if !ok {
|
||
return
|
||
}
|
||
triggerUUID, ok := parseUUIDOrBadRequest(w, triggerID, "trigger id")
|
||
if !ok {
|
||
return
|
||
}
|
||
wsUUID, ok := parseUUIDOrBadRequest(w, workspaceID, "workspace id")
|
||
if !ok {
|
||
return
|
||
}
|
||
|
||
ap, err := h.Queries.GetAutopilotInWorkspace(r.Context(), db.GetAutopilotInWorkspaceParams{
|
||
ID: autopilotUUID,
|
||
WorkspaceID: wsUUID,
|
||
})
|
||
if err != nil {
|
||
writeError(w, http.StatusNotFound, "autopilot not found")
|
||
return
|
||
}
|
||
if !h.requireAutopilotWrite(w, r, ap, workspaceID) {
|
||
return
|
||
}
|
||
|
||
trigger, err := h.Queries.GetAutopilotTrigger(r.Context(), triggerUUID)
|
||
if err != nil || uuidToString(trigger.AutopilotID) != uuidToString(autopilotUUID) {
|
||
writeError(w, http.StatusNotFound, "trigger not found")
|
||
return
|
||
}
|
||
|
||
userID, ok := requireUserID(w, r)
|
||
if !ok {
|
||
return
|
||
}
|
||
|
||
if err := h.Queries.DeleteAutopilotTrigger(r.Context(), triggerUUID); err != nil {
|
||
writeError(w, http.StatusInternalServerError, "failed to delete trigger")
|
||
return
|
||
}
|
||
|
||
h.publish(protocol.EventAutopilotUpdated, workspaceID, "member", userID, map[string]any{
|
||
"autopilot_id": uuidToString(autopilotUUID),
|
||
"trigger_id": uuidToString(triggerUUID),
|
||
})
|
||
w.WriteHeader(http.StatusNoContent)
|
||
}
|
||
|
||
// RotateAutopilotTriggerWebhookToken issues a fresh bearer token for an
|
||
// existing webhook trigger. The old token stops working immediately because
|
||
// the unique-index lookup in the public ingress route is keyed on the
|
||
// current row value.
|
||
func (h *Handler) RotateAutopilotTriggerWebhookToken(w http.ResponseWriter, r *http.Request) {
|
||
autopilotID := chi.URLParam(r, "id")
|
||
triggerID := chi.URLParam(r, "triggerId")
|
||
workspaceID := h.resolveWorkspaceID(r)
|
||
|
||
ap, ok := h.loadAutopilotInWorkspace(w, r, autopilotID, workspaceID)
|
||
if !ok {
|
||
return
|
||
}
|
||
if !h.requireAutopilotWrite(w, r, ap, workspaceID) {
|
||
return
|
||
}
|
||
|
||
triggerUUID, ok := parseUUIDOrBadRequest(w, triggerID, "trigger id")
|
||
if !ok {
|
||
return
|
||
}
|
||
prev, err := h.Queries.GetAutopilotTrigger(r.Context(), triggerUUID)
|
||
if err != nil || uuidToString(prev.AutopilotID) != uuidToString(ap.ID) {
|
||
writeError(w, http.StatusNotFound, "trigger not found")
|
||
return
|
||
}
|
||
if prev.Kind != "webhook" {
|
||
writeError(w, http.StatusBadRequest, "trigger is not a webhook trigger")
|
||
return
|
||
}
|
||
|
||
var rotated db.AutopilotTrigger
|
||
for attempt := 0; attempt < 3; attempt++ {
|
||
token, terr := generateWebhookToken()
|
||
if terr != nil {
|
||
writeError(w, http.StatusInternalServerError, "failed to generate webhook token")
|
||
return
|
||
}
|
||
rotated, err = h.Queries.RotateAutopilotTriggerWebhookToken(r.Context(), db.RotateAutopilotTriggerWebhookTokenParams{
|
||
ID: triggerUUID,
|
||
WebhookToken: pgtype.Text{String: token, Valid: true},
|
||
})
|
||
if err == nil {
|
||
break
|
||
}
|
||
if !isUniqueViolation(err) {
|
||
writeError(w, http.StatusInternalServerError, "failed to rotate webhook token")
|
||
return
|
||
}
|
||
}
|
||
if err != nil {
|
||
writeError(w, http.StatusInternalServerError, "failed to rotate webhook token")
|
||
return
|
||
}
|
||
|
||
resp := h.triggerToResponse(rotated)
|
||
userID, _ := requireUserID(w, r)
|
||
h.publish(protocol.EventAutopilotUpdated, workspaceID, "member", userID, map[string]any{
|
||
"autopilot_id": uuidToString(ap.ID),
|
||
"trigger": resp,
|
||
})
|
||
writeJSON(w, http.StatusOK, resp)
|
||
}
|
||
|
||
// SetAutopilotTriggerSigningSecret sets (or clears) the HMAC signing secret
|
||
// for a webhook trigger. Lives on its own endpoint so the secret value never
|
||
// shares a request body with any other field — keeping it out of generic
|
||
// request-body logs and audit captures that may include patch payloads.
|
||
//
|
||
// Empty body / empty `signing_secret` clears the secret and reverts the
|
||
// trigger to bearer-token-only authentication. The response carries
|
||
// `has_signing_secret` + `signing_secret_hint`; the secret itself is never
|
||
// echoed back, matching the GitHub / Stripe industry pattern.
|
||
func (h *Handler) SetAutopilotTriggerSigningSecret(w http.ResponseWriter, r *http.Request) {
|
||
autopilotID := chi.URLParam(r, "id")
|
||
triggerID := chi.URLParam(r, "triggerId")
|
||
workspaceID := h.resolveWorkspaceID(r)
|
||
|
||
ap, ok := h.loadAutopilotInWorkspace(w, r, autopilotID, workspaceID)
|
||
if !ok {
|
||
return
|
||
}
|
||
if !h.requireAutopilotWrite(w, r, ap, workspaceID) {
|
||
return
|
||
}
|
||
triggerUUID, ok := parseUUIDOrBadRequest(w, triggerID, "trigger id")
|
||
if !ok {
|
||
return
|
||
}
|
||
prev, err := h.Queries.GetAutopilotTrigger(r.Context(), triggerUUID)
|
||
if err != nil || uuidToString(prev.AutopilotID) != uuidToString(ap.ID) {
|
||
writeError(w, http.StatusNotFound, "trigger not found")
|
||
return
|
||
}
|
||
if prev.Kind != "webhook" {
|
||
writeError(w, http.StatusBadRequest, "trigger is not a webhook trigger")
|
||
return
|
||
}
|
||
|
||
var req SetSigningSecretRequest
|
||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||
writeError(w, http.StatusBadRequest, "invalid request body")
|
||
return
|
||
}
|
||
secret := strings.TrimSpace(req.SigningSecret)
|
||
// 16 chars is the floor: enough to make brute force impractical for the
|
||
// SHA-256 HMAC but low enough not to reject providers that mint shorter
|
||
// keys (Slack signing secrets are 32 hex chars; GitHub recommends 32).
|
||
if secret != "" && len(secret) < 16 {
|
||
writeError(w, http.StatusBadRequest, "signing_secret must be at least 16 characters")
|
||
return
|
||
}
|
||
|
||
param := db.SetAutopilotTriggerSigningSecretParams{ID: triggerUUID}
|
||
if secret != "" {
|
||
param.SigningSecret = pgtype.Text{String: secret, Valid: true}
|
||
}
|
||
updated, err := h.Queries.SetAutopilotTriggerSigningSecret(r.Context(), param)
|
||
if err != nil {
|
||
writeError(w, http.StatusInternalServerError, "failed to update signing secret")
|
||
return
|
||
}
|
||
|
||
resp := h.triggerToResponse(updated)
|
||
userID, _ := requireUserID(w, r)
|
||
// Publish the trigger update so the UI can refresh the has_signing_secret
|
||
// badge in real time. The event payload only carries the response shape,
|
||
// which excludes the secret.
|
||
h.publish(protocol.EventAutopilotUpdated, workspaceID, "member", userID, map[string]any{
|
||
"autopilot_id": uuidToString(ap.ID),
|
||
"trigger": resp,
|
||
})
|
||
writeJSON(w, http.StatusOK, resp)
|
||
}
|
||
|
||
// ── Runs ────────────────────────────────────────────────────────────────────
|
||
|
||
func (h *Handler) ListAutopilotRuns(w http.ResponseWriter, r *http.Request) {
|
||
autopilotID := chi.URLParam(r, "id")
|
||
workspaceID := h.resolveWorkspaceID(r)
|
||
|
||
autopilot, ok := h.loadAutopilotInWorkspace(w, r, autopilotID, workspaceID)
|
||
if !ok {
|
||
return
|
||
}
|
||
|
||
limit := int32(20)
|
||
offset := int32(0)
|
||
if l := r.URL.Query().Get("limit"); l != "" {
|
||
if v, err := strconv.Atoi(l); err == nil && v > 0 {
|
||
limit = int32(v)
|
||
}
|
||
}
|
||
if limit > 100 {
|
||
limit = 100
|
||
}
|
||
if o := r.URL.Query().Get("offset"); o != "" {
|
||
if v, err := strconv.Atoi(o); err == nil && v >= 0 {
|
||
offset = int32(v)
|
||
}
|
||
}
|
||
|
||
runs, err := h.Queries.ListAutopilotRuns(r.Context(), db.ListAutopilotRunsParams{
|
||
AutopilotID: autopilot.ID,
|
||
Limit: limit,
|
||
Offset: offset,
|
||
})
|
||
if err != nil {
|
||
writeError(w, http.StatusInternalServerError, "failed to list runs")
|
||
return
|
||
}
|
||
|
||
resp := make([]AutopilotRunResponse, len(runs))
|
||
for i, run := range runs {
|
||
// Omit trigger_payload in the list response — a webhook envelope
|
||
// can be up to 256 KiB and `limit` defaults to 20, so the full
|
||
// list would be a ~5 MB worst case. Detail dialog fetches the
|
||
// full payload from GetAutopilotRun.
|
||
resp[i] = runToResponseSlim(run)
|
||
}
|
||
writeJSON(w, http.StatusOK, map[string]any{"runs": resp, "total": len(resp)})
|
||
}
|
||
|
||
// GetAutopilotRun returns a single run including its full trigger_payload.
|
||
// Workspace scoping is enforced via loadAutopilotInWorkspace; the run is
|
||
// then re-checked to belong to that autopilot so a guessed runId from
|
||
// another workspace cannot leak data.
|
||
func (h *Handler) GetAutopilotRun(w http.ResponseWriter, r *http.Request) {
|
||
autopilotID := chi.URLParam(r, "id")
|
||
runID := chi.URLParam(r, "runId")
|
||
workspaceID := h.resolveWorkspaceID(r)
|
||
|
||
autopilot, ok := h.loadAutopilotInWorkspace(w, r, autopilotID, workspaceID)
|
||
if !ok {
|
||
return
|
||
}
|
||
|
||
runUUID, ok := parseUUIDOrBadRequest(w, runID, "run id")
|
||
if !ok {
|
||
return
|
||
}
|
||
|
||
run, err := h.Queries.GetAutopilotRun(r.Context(), runUUID)
|
||
if err != nil {
|
||
writeError(w, http.StatusNotFound, "run not found")
|
||
return
|
||
}
|
||
if uuidToString(run.AutopilotID) != uuidToString(autopilot.ID) {
|
||
// Guard against a runId from another autopilot being requested via
|
||
// this autopilot's URL — fail closed with 404 so the response shape
|
||
// matches the "not found" case and no information is leaked.
|
||
writeError(w, http.StatusNotFound, "run not found")
|
||
return
|
||
}
|
||
|
||
writeJSON(w, http.StatusOK, runToResponse(run))
|
||
}
|
||
|
||
// ── Manual trigger ──────────────────────────────────────────────────────────
|
||
|
||
func (h *Handler) TriggerAutopilot(w http.ResponseWriter, r *http.Request) {
|
||
id := chi.URLParam(r, "id")
|
||
workspaceID := h.resolveWorkspaceID(r)
|
||
|
||
autopilot, ok := h.loadAutopilotInWorkspace(w, r, id, workspaceID)
|
||
if !ok {
|
||
return
|
||
}
|
||
if !h.requireAutopilotWrite(w, r, autopilot, workspaceID) {
|
||
return
|
||
}
|
||
if autopilot.Status != "active" {
|
||
writeError(w, http.StatusBadRequest, "autopilot is not active")
|
||
return
|
||
}
|
||
|
||
run, err := h.AutopilotService.DispatchAutopilot(r.Context(), autopilot, pgtype.UUID{}, "manual", nil)
|
||
if err != nil {
|
||
writeError(w, http.StatusInternalServerError, "failed to trigger autopilot: "+err.Error())
|
||
return
|
||
}
|
||
|
||
writeJSON(w, http.StatusOK, runToResponse(*run))
|
||
}
|