Files
multica/server/internal/handler/autopilot.go
Bohan Jiang 4fac8d772f feat(attribution): Human Attribution Phase 1 (MUL-4302) (#5150)
* feat(attribution): Phase 1 foundation — provenance schema + resolver (MUL-4302)

Human Attribution, Phase 1 (地基) first increment. Every agent run must be
traceable to exactly one accountable human AND record at which waterfall level
that human was resolved, so a NULL originator can be told apart from a genuine
'no human in the chain'.

- migration 149: add originator_source (waterfall label) + delegation/retry/
  rerun/rule-version lineage + kind-tagged trigger evidence to agent_task_queue.
  No FK, no cascade, no CHECK on the source enum (MUL-4302 §7); nullable ADD
  COLUMNs = fast metadata-only change on the hot queue table.
- internal/attribution: the accountable-human vocabulary (Source, EvidenceKind,
  TriggerKind) + pure, unit-tested classification rules (ClassifyComment/
  ClassifyDirect). No DB, no authorization — provenance labeling only.
- service: attributionFor{IssueTask,TriggerComment} gather facts and delegate to
  the pure classifier; the legacy originator resolvers now delegate here so
  there is one source of truth. originator_user_id's VALUE is unchanged, so the
  Composio-overlay and canInvokeAgent A2A authorization boundaries are
  byte-for-byte preserved (MUL-4302 §1.3).
- enqueueIssueTask / enqueueMentionTask stamp originator_source + evidence;
  CreateRetryTask carries the parent attribution forward and records
  retry_of_task_id so retry and manual rerun stay separable (MUL-4302 §5).

Verified: go build ./..., go vet, gofmt clean; new attribution unit tests +
enqueue stamping integration test green; existing resolve_originator tests
unchanged.

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

* feat(attribution): split accountable_user_id from originator, close enqueue bypasses (MUL-4302)

Phase 1, per Bohan's decision on the MUL-4302 thread: audit and authorization
answer different questions and get different columns.

- Migration 151 adds agent_task_queue.accountable_user_id (no FK, no cascade).
  Authorization keeps reading ONLY originator_user_id (canInvokeAgent A2A gate,
  Composio overlay); audit/UI/usage read accountable_user_id + source + evidence.
- Invariant (finalizeAttribution, single chokepoint + §11 tests): originator
  non-null ⟹ accountable equals it. The two diverge only when originator is null
  (autopilot / degraded fallback), which is the deferred rule_owner/owner_fallback
  increment; this lands the column + mirror-write so that split has a home.

- Close the NULL-source enqueue bypasses Elon flagged: chat, quick-create,
  deferred-fallback and run_only-autopilot now stamp originator_source + evidence
  (+ accountable where a human exists). Autopilot stays unattributed until the
  rule-version snapshot table lands, but is no longer a silent NULL-source row.
  Retry inherits accountable_user_id like the rest of the attribution lineage.

- Fix assign/promote attribution (§4): a member who assigns/promotes an existing
  issue is now the accountable human (and, by the invariant, originator) ahead of
  the issue creator. Threaded as an OPTIONAL actor override, so comment/rerun/
  autopilot paths keep today's resolution and create-with-assignee (creator ==
  actor) is unchanged. The squad leader gate already judged the same member.

Also merges origin/main: renumbers the attribution migration 149→150 (main took
149 for issue_origin_agent_create) and folds agent_create into ClassifyDirect's
origin inheritance.

go build/vet/gofmt clean; attribution unit tests + service stamp/actor tests +
handler suite pass on a fresh DB migrated through 151.

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

* docs(attribution): fix accountable NULL semantics + close chat/quick-create evidence boundary (MUL-4302)

Addresses Elon's 2nd-round review on PR #5150 (pre-merge doc/evidence items):

- Migration 151 no longer overclaims NULL. accountable_user_id is NULL not only
  on pre-migration rows but on NEW rows whose audit source resolved no human yet
  (run_only autopilot writes originator_source='unattributed' with NULL
  accountable until rule_owner lands). Header + COMMENT ON COLUMN reworded so a
  schema reader does not misjudge the invariant.

- Chat now uses the UNIFORM evidence pair (kind=chat, ref=chat_session_id), like
  autopilot_run/issue_assignment, instead of relying only on the dedicated
  chat_session_id column — new EvidenceChat kind. Added a service test asserting
  chat stamps direct_human + chat evidence.

- Quick-create is documented as the ONE intentional no-antecedent-row path: no
  comment/issue/session/run exists at enqueue time (the run creates the issue), so
  trigger_evidence_kind/ref stay NULL while the human rides originator/accountable
  and source is direct_human — not a NULL-source bypass.

No authorization behavior change. attribution + service + handler suites pass on a
DB migrated through 151.

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

* chore(attribution): renumber migrations 150/151 → 157/158 after merging main (MUL-4302)

main's #5162 ("unblock release migrations") renumbered the chat migrations and
took 150 (agent_task_coalesced_comments) and 151 (chat_read_cursor), colliding
with this branch's attribution migrations. Renumber them above main's new highest
(156) so TestMigrationNumericPrefixesStayUniqueAfterLegacySet passes:

- 150_agent_task_attribution      → 157_agent_task_attribution
- 151_agent_task_accountable_user → 158_agent_task_accountable_user

Fixed the internal "migration 150" references in 158's header to 157. Migrations
apply cleanly through 158 on a fresh DB; migration lint green.

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

* feat(attribution): autopilot rule_owner — accountable = rule version publisher (MUL-4302)

Implements rule_owner (MUL-4302 §3.4), the first attribution source where the
accountable human diverges from the (NULL) authorization originator.

- Migration 159 adds the append-only autopilot_rule_version snapshot table (no FK,
  no cascade); migration 160 adds its CONCURRENTLY lookup index.
- Write-on-publish: CreateAutopilot appends v1 (publisher = creator); UpdateAutopilot
  appends a new version when a SUBSTANTIVE autopilot-row field changes (assignee /
  status / execution_mode) — cosmetic edits (title/description/template) write none.
  Both run inside the existing handler tx (atomic with the autopilot write).
- Dispatch resolution: both autopilot execution modes now resolve the active rule
  version and stamp originator_source='rule_owner', accountable_user_id=publisher,
  rule_version_id=<snapshot>, with originator_user_id left NULL (authorization
  unchanged). run_only stamps CreateAutopilotTask directly; create_issue resolves in
  attributionForIssueTask so both modes attribute identically. A missing version /
  non-member publisher degrades to unattributed — never fabricates a human.
- finalizeAttribution now enforces the invariant ONE-WAY: it mirrors originator onto
  accountable only when originator is valid, leaving an explicitly-set accountable
  (rule_owner / future owner_fallback) intact when originator is NULL. Added
  rule_version_id to CreateAgentTask so the create_issue path persists it too.

Also merges origin/main and renumbers this branch's attribution migrations
150/151 → 157/158 (main's #5162 took 150/151); rule_version table is 159/160.

Tests: attribution unit RuleOwner + one-way invariant table; service integration
tests proving an autopilot-origin issue stamps rule_owner + rule_version_id (and
degrades to unattributed with no version). Full service/attribution/handler/
migration suites pass on a DB migrated through 160; build/vet/gofmt clean.

Deferred (same PR): trigger-table republish (cron/webhook/event_filters) and
system-pause/archive versioning; owner_fallback + fail-closed; manual rerun.

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

* fix(attribution): manual autopilot trigger → direct_human to the triggering member (MUL-4302)

Elon's blocking finding: a member manually triggering an autopilot was attributed
rule_owner (accountable = rule publisher, originator NULL) like a schedule/webhook
run, so member B triggering member A's autopilot landed accountable=A and carried
no originator authorization context for the run. Per MUL-4302 §4 a manual "run now"
is a direct human action and must attribute direct_human to the triggering member.

- Thread the triggering member from TriggerAutopilot into dispatch: new
  DispatchAutopilotManual carries actorUserID (resolved via resolveActor +
  memberActorUserID, so only a member actor is a human; an A2A agent actor falls
  back to rule_owner). DispatchAutopilot / DispatchAutopilotForPlan keep their
  public signatures (pass an invalid actor); only the internal dispatchAutopilot /
  dispatchCreateIssue / dispatchRunOnly gained the param, so the many existing
  callers are untouched.
- run_only: dispatchRunOnly stamps direct_human (originator == accountable ==
  actor, no rule_version) for a manual actor, else rule_owner. CreateAutopilotTask
  gains an originator_user_id param for the manual case.
- create_issue: dispatchCreateIssue enqueues a manual trigger via the actor-carrying
  *WithHandoff entry points; attributionForIssueTask's autopilot-origin rule_owner
  branch is now guarded on !actorUserID.Valid, so a valid actor falls through to the
  direct_human override. Both execution modes attribute identically.
- schedule / webhook keep rule_owner (no actor). Trigger-table + system-pause/archive
  versioning remain the pre-merge follow-ups.

Tests: the run_only row assertion Elon asked for (schedule → rule_owner row on
CreateAutopilotTask), plus manual direct_human on BOTH modes (run_only and
create_issue), including a manual actor distinct from the rule publisher. Full
service/attribution/handler/migration/scheduler/cmd suites pass on a DB migrated
through 160; build/vet/gofmt clean.

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

* feat(attribution): owner_fallback + fail-closed policy, and manual-rerun direct_human (MUL-4302)

Two of the three remaining Phase 1 items (trigger-table / system-pause versioning
is deferred — see PR description).

owner_fallback + fail-closed (§1/§3.5) — the never-null accountable guarantee:
- attribution.OwnerFallback degrades an UNATTRIBUTED result to owner_fallback:
  accountable = agent owner, originator stays NULL (audit-only, authz untouched),
  Source.Precise()==false. finalizeAttribution's one-way invariant already allows
  accountable-set / originator-NULL divergence, so nothing else changes.
- Migration 161 adds workspace.attribution_fail_closed (default FALSE) + a lean
  GetWorkspaceAttributionFailClosed read. (Also added the column to ListWorkspaces'
  explicit column list so its row type stays db.Workspace.)
- applyAttributionFallback is applied at every enqueue boundary (issue, mention,
  chat, quick-create, deferred-fallback, autopilot run_only): unattributed →
  owner_fallback (agent owner) by default, or ErrAttributionFailClosed when the
  workspace is fail-closed, which the caller surfaces to refuse the enqueue (the
  run does not start). So no run is left without an accountable human, and a
  compliance workspace can block unattributable runs instead.

manual rerun (§5) — a rerun is a NEW direct_human trigger to the rerunning member:
- RerunIssue threads the acting member (resolved in the handler via resolveActor)
  down to enqueueRerunTask, and attributionForIssueTask is now actor-first so the
  actor wins over an INHERITED trigger comment (a rerun keeps the comment for the
  daemon's prompt context but must attribute to whoever clicked rerun, not the
  original comment's human).
- rerun_of_task_id lineage is recorded via a targeted SetAgentTaskRerunOf update on
  the rerun path only (keeping the shared CreateAgentTask insert untouched), so
  system retry (retry_of_task_id) and human rerun stay separable in reporting.

Tests: OwnerFallback unit test; owner_fallback + fail-closed-refusal + manual-rerun
(direct_human + rerun_of_task_id) service tests; the prior "degrades to unattributed"
test updated to owner_fallback. Full service/attribution/handler/migration/scheduler/
cmd suites pass on a DB migrated through 161; build/vet/gofmt clean.

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

* fix(attribution): close fail-open holes + move rerun_of_task_id into creation snapshot (MUL-4302)

Addresses Elon's two must-fixes on PR #5150.

1. accountable-never-null / fail-closed had fail-open holes. applyAttributionFallback
   now, for an UNATTRIBUTED run, refuses the enqueue (ErrAttributionFailClosed) in
   THREE cases instead of silently degrading to a runnable NULL-accountable task:
   - workspace policy read fails (or no workspace) → fail closed; we cannot confirm
     fallback is permitted, so we don't run an unattributable task on a DB hiccup.
     (Only the rare unattributed path pays this; precise runs never read the policy.)
   - workspace is fail-closed → refuse (unchanged).
   - owner_fallback has no valid agent owner → refuse rather than enqueue a task with
     a NULL accountable_user_id.
   ErrAttributionFailClosed's doc now covers all three "cannot guarantee an
   accountable human" refusals. Added missing-owner / policy-read-failure /
   precise-passthrough tests.

2. manual rerun rerun_of_task_id was a post-notify UPDATE (race: the queued event /
   daemon claim could see rerun_of_task_id = NULL, and a failed update degraded the
   run to a plain direct_human). It now rides the CreateAgentTask insert — threaded
   through enqueueIssueTask / enqueueMentionTask as a creation param (like
   retry_of_task_id) so it is written in the same statement before the daemon is
   notified. Removed the SetAgentTaskRerunOf follow-up query.

Also merges origin/main (unrelated CLI fix #5167, no conflict). Full service /
attribution / handler / migration / scheduler / cmd suites pass on a DB migrated
through 161; build / vet / gofmt clean.

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

* feat(attribution): rule_owner versioning on trigger edits + system-pause/archive (MUL-4302)

The final remaining Phase 1 item: substantive publishes beyond the autopilot row now
republish the rule version, so a run's rule_owner accountable follows whoever last
changed what the rule does.

- Extracted the config-summary + insert into service.RecordAutopilotRuleVersion so
  the handler and the (different-package) failure monitor share one writer; the
  handler's recordAutopilotRuleVersion is now a thin wrapper.
- Trigger edits: UpdateAutopilotTrigger and DeleteAutopilotTrigger republish the rule
  version with the acting member as publisher, ATOMICALLY (tx-wrapped mutation +
  version write, mirroring CreateAutopilot/UpdateAutopilot). CreateAutopilotTrigger
  republishes best-effort — the webhook path mints its token with a retry loop that
  cannot share one tx, and a create is usually initial setup already covered by v1;
  a failed write there is benign (active version stays the current publisher, the new
  trigger fires under it, no immediate daemon claim rides it).
- Archive (DeleteAutopilot) republishes (member, status=archived), tx-wrapped.
- System auto-pause (failure monitor) republishes with a 'system' publisher,
  best-effort — a background sweep to a non-dispatching state (a paused autopilot
  never dispatches; a later member resume supersedes).
- RotateWebhookToken / SetSigningSecret deliberately do NOT version: they rotate
  credentials, not the rule's behavior (not §3.4 substantive).

Semantics: a system-published (no-member) active version degrades dispatch to
unattributed → owner_fallback, never fabricating a human.

Tests: republish-reattributes (member A → member B supersedes → dispatch resolves to
B; system publisher → unattributed). Full service/attribution/handler/migration/
scheduler/cmd suites pass on a DB migrated through 161; build/vet/gofmt clean.
Also merges origin/main (unrelated frontend feature #5074).

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

* fix(attribution): make trigger-create rule-version republish atomic (MUL-4302)

Addresses Elon's final Phase 1 blocking finding: CreateAutopilotTrigger recorded the
rule-version republish best-effort AFTER the trigger insert. If member B added a
schedule/webhook trigger to member A's autopilot and the version write failed, future
schedule/webhook dispatches would keep attributing to A — violating the rule_owner
invariant that the last member to substantively change the rule owns future runs
("no immediate daemon claim" doesn't save it, since the miss surfaces at the LATER
trigger firing).

Both create paths now write the version in the SAME tx as the trigger INSERT:
- schedule create: wrap CreateAutopilotTrigger + recordAutopilotRuleVersion in one tx.
- webhook create: each mint-with-retry attempt runs in its own tx (insert + version
  commit together; a token collision rolls that attempt back and retries with a fresh
  token; a version-write failure rolls the trigger back). Passes ap + the acting
  member id into the helper.
- removed the best-effort recordTriggerRuleVersionBestEffort helper (and the now-unused
  slog import).

Test: TestCreateTrigger_RepublishesRuleVersionAtomically drives both create paths
through the handler and asserts a rule version is published by the acting member.
Existing webhook/trigger/archive handler tests still pass. Also merges origin/main
(unrelated avatar feature #5074). Full service/attribution/handler/migration/
scheduler/cmd suites pass on a DB migrated through 161; build/vet/gofmt clean.

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

* feat(attribution): Phase 2.1 — surface run attribution on the task API (MUL-4302 §9)

First Phase 2 (visibility) increment: the agent-task API now returns the resolved
accountable-human provenance so the UI can render an "on behalf of" badge.

- AgentTaskResponse gains an `attribution` object: source label (never blank —
  pre-migration NULL renders "unattributed") + `precise` flag (false for the degraded
  owner_fallback / backfill / unattributed sources), the initiator (accountable) and
  originator (authorization) user refs, the evidence {kind, ref_id} pointer, and the
  rule_version / delegated / retry / rerun lineage ids.
- The label + evidence + raw ids are built in the PURE taskToResponse (no DB), so
  every task response carries them. Names are hydrated separately, only on the
  user-facing surfaces (ListAgentTasks, ListWorkspaceAgentTaskSnapshot, RerunIssue,
  CancelTaskByUser) — daemon-claim paths stay lean.
- Hydration resolves initiator/originator from the GLOBAL user table (departed-member
  safe) via a new batch GetUsersByIDs query (no N+1); best-effort, so a lookup hiccup
  leaves the raw ids intact.

Tests: pure taskAttributionBase (direct_human / rule_owner NULL-originator /
owner_fallback degraded / pre-migration→unattributed) + DB hydration (fills known
ref, leaves unknown id un-filled, skips nil). Full handler/service/attribution/
migration/scheduler/cmd suites pass on a DB migrated through 161; build/vet/gofmt
clean. The field is additive — the frontend's parseWithFallback ignores unknown keys,
so nothing breaks until the UI increment consumes it.

Also merges origin/main (unrelated editor feature #5090).

Remaining Phase 2 (next increments, same PR): frontend zod schema + "on behalf of"
badge + evidence-chain jump; append-only correction events (write + display).

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

* feat(attribution): Phase 2.2 — on-behalf-of badge in the execution log (MUL-4302 §9)

Surface the accountable human on every agent run row:
- AttributionBadge composes Badge + ActorAvatar, shows "on behalf of <member>"
  with the resolution source as a tooltip; degraded (non-precise) attribution
  gets a warning tone, and an unresolved initiator renders an explicit
  "no responsible member" chip.
- Wire the badge into both active and past rows of the execution log.
- Mirror the attribution shape into AgentTaskResponseSchema (defensive, .loose())
  so the cancel-task path carries it through zod; add parse tests.
- Export TaskAttribution/AttributionUser/TaskEvidence from @multica/core/types
  and add the attribution block to all four issues.json locales.

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

* fix(attribution): hydrate initiator names on issue-facing task endpoints + bound the badge (MUL-4302 §9)

Address Elon's PR #5150 review:
- ListTasksByIssue (the execution-log data source), GetActiveTaskForIssue and the
  issue-scoped CancelTask now call hydrateTaskAttributions, so the "on behalf of
  <member>" badge shows the real member name on issue detail instead of falling
  back to "someone". Mirrors the existing ListAgentTasks / snapshot behavior.
- AttributionBadge: cap width (max-w-40, min-w-0) and truncate the name span so a
  long name / narrow right column can't squeeze out trigger/status/actions; keep
  the avatar shrink-0.
- Add a handler test asserting the issue task list returns a hydrated
  attribution.initiator.name.

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

* fix(attribution): use semantic AvatarSize 'xs' for the badge avatar

main refactored ActorAvatar.size from a raw pixel number to the semantic
AvatarSize union (packages/ui/lib/avatar-size). Switch the on-behalf-of badge
avatar from size={14} to size="xs" (16px) after merging main.

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

* fix(attribution): stage-cascade falls back to parent-issue provenance, not agent owner (MUL-4302)

When closing the last sub-issue in a Stage wakes the parent's assignee agent, the
run was enqueued via a system-authored child-done comment with no actor, which the
resolver classified as unattributed and then degraded to owner_fallback (the agent's
own owner). That is the wrong accountable human: the woken run should be accountable
to whoever caused the parent issue to exist.

attributionForIssueTask now detects a system-authored trigger comment and falls
through to the parent issue's own provenance — the same creator / agent_create-origin
/ autopilot-origin chain a direct enqueue resolves (so an agent-decomposed parent
attributes via delegation to the human who drove it; a member-created parent to that
member; an autopilot parent to the rule publisher). owner_fallback is now only the
last resort when the parent provenance itself has no human.

- Extract attributionFromComment so attributionForIssueTask can inspect author_type
  without a second GetComment; authorization resolution stays byte-identical.
- Add a DB-backed test asserting a system child-done comment resolves to the parent
  issue's origin human (delegation), not owner_fallback.

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

* feat(attribution): autopilot runs attribute to the firing trigger's creator (MUL-4302)

Per Bohan: an autopilot schedule/webhook run should be accountable to the human
who created the SPECIFIC trigger that fired it, not the rule publisher. (Manual
triggers already attribute to the invoking member via direct_human — unchanged.)

- Migration 162: add autopilot_trigger.created_by_type/created_by_id (nullable, no
  FK/cascade). Capture the creating member at both trigger-create sites (schedule +
  webhook).
- New precise source trigger_owner: originator stays NULL (an autonomous fire
  carries no human authorization — same authz-safe divergence as rule_owner),
  accountable = the trigger's member creator.
- triggerOwnerAttribution resolves run.trigger_id → creator; wired into run_only
  dispatch and the create_issue path (bridging issue → active run → trigger_id).
  Legacy triggers with no recorded creator, and agent-created triggers, degrade to
  rule_owner then owner_fallback — nothing regresses.
- Frontend: trigger_owner source label in all four locales + badge switch case.
- Tests: attribution TriggerOwner unit + Precise/invariant; DB-backed resolver
  tests (member creator → trigger_owner; creatorless → rule_owner fallback).

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

* chore(attribution): re-trigger CI (dropped synchronize event on 249090260)

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

* fix(attribution): mirror accountable_user_id on comment-coalescing merge (MUL-4302)

The one-way invariant is 'originator_user_id IS NOT NULL ⟹ accountable_user_id =
originator_user_id', enforced at finalizeAttribution for enqueues and preserved by
the retry-clone (copies both columns). But MergeCommentIntoPendingTask (main #5192)
re-stamps originator_user_id to the newly-coalesced comment's human WITHOUT touching
accountable_user_id — so folding member B's comment into member A's queued task left
originator=B / accountable=A, violating the invariant. Re-stamp accountable to mirror
the new originator (same thing finalizeAttribution does). Add a DB-backed regression
test.

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

* fix(attribution): Elon's 3 must-fixes + DB cross-column invariant CHECK (MUL-4302)

Bohan approved Elon's plan; this closes the three attribution boundaries he flagged
and locks the one-way invariant at the DB.

1. Delegation now inherits the parent's ACCOUNTABLE, not just its originator. An
   autopilot-rooted chain (parent originator NULL, accountable = trigger creator)
   @mentioning an agent / creating a sub-issue used to drop to unattributed →
   owner_fallback and fail-closed workspaces wrongly rejected the fan-out. Added
   ParentAccountable/OriginAccountable to CommentFacts/DirectFacts; ClassifyComment/
   ClassifyDirect copy accountable down (source=delegation, precise, originator NULL)
   so the chain root stays stable at any depth (§3.2).
2. The direct-chat send path (SendDirectChatMessage, MUL-4351) wrote only
   originator_user_id — no accountable/source/evidence, a NULL-source bypass. It now
   stamps the full direct_human attribution like EnqueueChatTask.
3. Comment-coalescing merge re-attribution is now ATOMIC: MergeCommentIntoPendingTask
   re-stamps the whole snapshot (person columns + source + delegation lineage + rule
   version + evidence) of the new comment, not just the two person fields, so a merged
   run never shows B accountable while pointing at A's stale source/evidence.
4. Migration 169: NOT VALID CHECK (originator_user_id IS NULL OR (accountable_user_id
   IS NOT NULL AND accountable_user_id = originator_user_id)). Enforces the invariant
   on every new write (the class of bug #5192 introduced); historical rows not blocked,
   VALIDATE after Phase 3 backfill. Updated test fixtures that seeded originator-only
   rows to also set accountable.

Merged latest main (renumbered attribution migrations 163–168 after main took 161/162;
merged the retry-clone chat_input_task_id + attribution columns). Verified: go build/
vet, attribution/service/handler/cmd-server tests on a migrated DB, frontend
typecheck/lint/tests.

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

* fix(attribution): trigger responsibility transfers to effective publisher; fail-closed comment merge

Elon final-review must-fix 1 (MUL-4302): trigger_owner now resolves to the
member CURRENTLY responsible for the firing trigger's effective config, not the
fixed creator. Per-trigger published_by on autopilot_trigger, seeded to the
creator and re-stamped to the editor on a substantive edit — a trigger-scoped
edit bumps only that row (UpdateAutopilotTrigger), an autopilot-level edit bumps
all its triggers (UpdateAutopilot). Editing one trigger never reassigns another.
Adds real dispatchRunOnly transfer test + resolver-level isolation test.

Must-fix 3: AttributionForMergedComment reuses applyAttributionFallback and
returns ErrAttributionFailClosed; the merge caller refuses on fail-closed,
keeping the queued task's original precise snapshot instead of degrading it to
owner_fallback. Adds regression test.

Renumbered attribution migrations to 166-172 after merging main.

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

* fix(attribution): make migration 172 invariant CHECK upgrade-safe (Option A)

Elon final-review must-fix 2 (MUL-4302), rollout chosen by Bohan. The NOT VALID
CHECK still checks a pre-existing row on any later UPDATE (even one not touching
attribution columns), so cross-deployment stale queued/running tasks (originator
set, accountable NULL from before migration 167) would fail on their next
claim/complete/cancel. Exempt exactly those legacy rows via 'originator_source
IS NULL' — that column was added in 166 with no backfill, so it is NULL only on
pre-migration rows and non-NULL on every attribution-aware write. New writes
stay fully enforced; the #5192 bypass class (source always set) is unaffected.
Phase 3 backfills legacy rows then drops+re-adds the strict form + VALIDATE.

Adds TestAttributionInvariantCheck_ExemptsLegacyRows (legacy row survives a
status UPDATE) and updates the reject-bypass test to the enforced regime.

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

* fix(attribution): pin substantive/cosmetic edit boundary for trigger transfer

Elon re-review must-fix 1: an autopilot-level edit only transfers trigger_owner
responsibility when it changes what the automation instructs or who/whether it
runs. autopilotRuleSubstantiveChange now includes description (the run Prompt)
and issue_title_template; title and project_id stay cosmetic/routing.
UpdateAutopilotTrigger no longer transfers on every PATCH — it compares the
persisted before/after and transfers only on a real cron/timezone/enabled/
event_filters change, not a label-only or no-op PATCH. Adds real handler tests
(prompt->all, title->none, cron->one+isolation, label->none, no-op->none).

Must-fix 3: real merge-path regression (TestMergeCommentIntoPendingTask_
FailClosedKeepsOriginalSnapshot) drives mergeCommentIntoPendingTask and asserts
a fail-closed workspace preserves the queued task's full snapshot; fail-open
control completes the owner_fallback merge.

Docs: migration 172 comment reworded to legacy-writer/unbackfilled-lineage
semantics (source NULL is not strictly pre-migration); PR description synced.

Migration renumber vs latest main (must-fix 2) deferred to pre-launch per Bohan.

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

* chore(attribution): renumber migrations to 167-173 after syncing main

Merged latest main (which took 166_project_dates) and shifted the attribution
migration series off the 166 collision: 166->167 agent_task_attribution,
167->168 accountable_user, 168->169 rule_version, 169->170 rule_version_index,
170->171 fail_closed, 171->172 trigger_publisher, 172->173 invariant_check.
Updated the internal cross-references in the migration comments accordingly.
Fixes TestMigrationNumericPrefixesStayUniqueAfterLegacySet on the merge tree.

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

* feat(admission): unify dispatch outcome + close rerun/chat/autopilot invoke holes (MUL-4525)

P0 first increment toward a platform-wide execution-admission contract so a
user who names an execution target always gets a definite result and never a
silent no-op, and so blocked targets are reported without leaking private-agent
details.

Backend:
- New shared contract (handler/admission.go): DispatchOutcome / DispatchStatus
  (queued/coalesced/deferred/blocked) + stable, enumeration-safe
  DispatchReasonCode set, plus writeDispatchBlocked() whose legacy `error`
  string never reveals target existence.
- Rerun (task.go / task_lifecycle.go): re-validate the operator can invoke the
  RESOLVED target agent (historical agent for a task_id rerun) before any
  cancel/enqueue; blocked returns a structured 403 and mutates nothing
  (ErrRerunInvokeNotAllowed).
- Chat send (chat.go): re-run canInvokeAgent on every send, not just the softer
  canAccessPrivateAgent view gate; a revoked permission blocks before the
  message/attachments/task persist.
- Autopilot manual "run now" (service/autopilot.go): admission now keys on the
  current CLICKER, not the autopilot creator — clicker admission and clicker
  attribution no longer fork. Automation (schedule/webhook) still falls back to
  the creator gate. Added reason_code to the run response for the UI.

Frontend:
- triggerAutopilot response is schema-parsed; handleRunNow branches on run
  status and shows a localized, reason_code-based warning for skipped/failed
  instead of a false-success toast.
- Chat send and rerun surface the structured 403 reason_code as localized
  toasts (dispatchReasonCode helper) instead of a generic failure.
- Additive fields only; older clients keep working. i18n added to all 4 locales.

Tests: rerun fail-before-mutation gate, autopilot clicker-vs-creator fork
(service + handler), reason-code classification, and a malformed-response
schema test. Backend handler+service suites and FE typecheck/lint green.

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

* test(cmd/server): thread nil invoke gate through RerunIssue call sites (MUL-4525)

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

* fix(admission): typed reason codes + run-now whitelist + real security tests (MUL-4525)

Addresses Elon's review of the P0 first increment (must-fix 1–3).

1. Run now no longer has a false-success branch. handleRunNow now classifies on
   a whitelist via a pure runNowToastKind(): only issue_created/running →
   success; skipped → warning; failed and any unknown/future status → error. Add
   run-now-toast unit test over all five status classes plus reason-code → key
   mapping.

2. reason_code is a typed value decided at the admission source, not
   reverse-engineered from English failure text. New leaf package
   internal/dispatch holds the canonical ReasonCode enum (shared by handler +
   service so they can never drift). shouldSkipDispatch / errDispatchSkipped /
   the fail path now carry a typed code through DispatchAutopilotManual straight
   into the response; the substring classifier is deleted. Fixes the two missed
   branches: attribution fail-closed → attribution_blocked (typed errors.Is),
   "agent has no runtime bound" → runtime_offline. Regression tests for both.

3. Security acceptance tests exercise the REAL handlers, not injected callbacks:
   - Chat: create session while invokable → revoke invoke (flip to private, keep
     owner-view) → send returns 403 + reason_code with zero chat_message / task
     writes.
   - Rerun: private historical agent through RerunIssue + canInvokeAgent — a
     non-invoking workspace owner is refused 403 + reason_code and mutates
     nothing (fail-before-mutation); the agent owner is allowed 202.

No migration; reason_code is a decision-time value only the manual "run now"
response carries. Additive on the wire. Backend build/vet/handler+service
suites and FE typecheck/lint/vitest green. (Migration-prefix collision with main
remains the deferred pre-merge renumber.)

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

* test(admission): make must-fix 3 acceptance tests prove the invariant (MUL-4525)

Addresses Elon's round-3 narrow review — the two security tests were not yet
falsifiable against the bugs they must catch.

- Rerun: after enqueuing the historical task, reassign the issue to a SECOND
  agent that the denied user CAN invoke. Now the current assignee and the
  task_id agent differ, so a rerun that wrongly validated the current assignee
  would let the denied user through — the 403 proves the gate is keyed on the
  historical private agent. The allow case now asserts the reran task's agent_id
  is the historical agent, not the current assignee.

- Chat: the blocked send now carries a valid, still-unbound attachment. After
  the 403 the test asserts the attachment's chat_session_id and chat_message_id
  are both still NULL, guarding against anyone moving attachment binding ahead
  of the invoke gate.

Test-only. Full internal/handler Go suite green.

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

* feat(comments): surface blocked @mention trigger_outcomes instead of silent no-op (MUL-4525 §2)

A comment that @mentions an agent/squad the author cannot invoke used to save
with zero feedback — the user assumed a bug. Now the explicit-mention path
reports a per-target outcome on both preview and create/edit.

Backend (server/internal/handler/comment.go):
- resolveMentionedAgentCommentTriggers collects blocked outcomes instead of a
  silent `continue`. The invoke gate is evaluated BEFORE any archived/runtime
  state is read, so a caller who cannot invoke a private target only ever sees
  the generic invocation_not_allowed and can never enumerate its existence.
- enqueueCommentAgentTriggers returns queued/coalesced/deferred/blocked per
  explicit mention; enqueue errors are typed (attribution_blocked via
  errors.Is), not swallowed. Implicit routing (assignee/thread/conversation)
  carries no outcome — the user never named those targets.
- trigger-preview returns `blocked[]`; create/edit return additive
  `trigger_outcomes[]`. One blocked mention never fails the comment.

Frontend:
- Composer shows a warning chip for blocked mentions before sending; after
  sending, a "posted, but N not triggered" toast (blocked-only; coalesced/
  deferred are success-shaped). Additive schema + defensive parse; i18n ×4.

Tests: handler partial-success + enumeration-safety acceptance tests; core
outcome-parse + preview-schema tests; hook/parity updated. Backend
handler+service suites and FE typecheck/lint/vitest green.

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

* i18n(admission): clearer, consistent blocked-trigger copy (MUL-4525)

Reword the awkward "you are not allowed to run this autopilot's assignee" and
polish all MUL-4525 blocked/partial copy across en/zh-Hans/ja/ko:

- Unify on "you don't have permission to use this <agent|target>" (zh: 没有…的
  使用权限) for autopilot Run now, comment mentions, rerun, and chat send —
  replacing the various "not allowed to run/trigger" phrasings.
- Align zh to the glossary term 智能体 (was mixed "Agent"), matching the
  surrounding UI voice (e.g. agent_link_no_access).
- Drop jargon: "blocked by an admission policy"/"被准入策略拦截" → "the run was
  blocked"/"本次运行已被拦截"; "attributed"/"归因" → plainer wording.
- Comment copy counts "mentions" (was "targets") for consistency with the chip.

Backend dispatchBlockedFallbackMessage (old-client English fallback) reworded to
match; its enumeration-safety test assertion updated. Copy-only — no key/logic
changes; parity + typecheck green.

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

* fix(comments): one outcome per explicit mention + FE success whitelist (MUL-4525)

Addresses Elon's round-2 review of the §2 comment trigger_outcomes.

1. Separate execution dedup from per-target outcomes. resolveMentionedAgent-
   CommentTriggers now returns triggers (deduped by executing agent) AND one
   commentMentionTarget per EXPLICIT mention. enqueueCommentAgentTriggers returns
   a per-executing-agent result map; commentTriggerOutcomes fans each agent's
   status to every target that resolved to it. So @Agent A + @Squad S(leader=A)
   coalesces to ONE task but yields TWO outcomes. The squad-leader self-suppress
   branch now returns a definite `deferred` outcome instead of no result. New
   CreateComment test asserts 1 task, 2 outcomes.

2. Frontend no longer treats an unknown status as success. unhandledComment-
   TriggerOutcomes whitelists queued/coalesced/deferred as handled; blocked and
   any unknown/future/empty status warn (mirrors the Run now whitelist). The
   preview schema's `blocked` now drops malformed entries INDIVIDUALLY instead of
   z.array(...).catch([]) discarding the whole set. Regression tests for the
   unknown status and the per-item drop.

Backend handler suite + go vet, FE typecheck/lint/vitest green.

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

* fix(comments): honest role + status in trigger_outcomes fan-out (MUL-4525)

Addresses Elon's round-3 review of the §2 fan-out.

1. Execution merge preserves the squad-leader role instead of first-mention-
   wins. The dedup now UPGRADES an already-added plain @agent trigger to a
   @squad-leader trigger for the same agent, so @Agent A + @Squad S(leader=A)
   always runs as a leader task (is_leader_task + squad_id=S) regardless of
   mention order — the daemon still injects S's briefing. When two DIFFERENT
   squads share one leader, the single run carries one squad's context and the
   other squad is reported `coalesced` (folded), never a second `queued`. The
   enqueue result now records the executed squad so the fan-out can tell them
   apart. Tests assert the task role in both orders and the two-squad split.

2. Squad-leader self-suppression no longer fakes success. The self-trigger
   guard keys on the latest task ROLE with no status filter, so a long-completed
   task also suppresses; reporting `deferred/already_active` when nothing is
   active was a false success. The branch now reports `deferred` only when a
   real non-terminal task is active (its reconcile covers the comment), else a
   non-success `blocked` + new `already_handled` reason. Fixed the reversed
   helper-semantics comment. New handler test covers the completed-task branch.

Backend handler+service suites and go vet green.

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

* fix(comments): fail-closed active-task check, never fake deferred (MUL-4525)

Elon round-4 must-fix: hasActiveTaskForIssueAndAgent swallowed DB errors by
returning true, so both call sites could turn a query failure into a
success-shaped `deferred/already_active` — a silent false success, exactly what
this issue forbids for admission-query failures.

- hasActiveTaskForIssueAndAgent now returns (bool, error).
- Two pure decision helpers govern the branches, fail closed on error:
  - decidePostMergeMiss: on query error, do NOT enqueue a fresh task (duplicate
    concurrent-run risk) AND report non-success blocked/internal_error; a
    confirmed active task defers; a confirmed-none enqueues fresh.
  - decideSuppressedLeaderOutcome: on query error, blocked/internal_error; a
    confirmed active run defers; else self_trigger_suppressed. Never a fabricated
    deferred.
- Renamed reason already_handled -> self_trigger_suppressed (Elon non-blocking
  note): the old name implied the new comment was already processed, but the
  real meaning is a suppressed self-trigger.

Deterministic unit tests cover the query-failure branch at both call sites
(no fresh enqueue, non-success outcome) — a real DB fault can't be forced
through valid handler inputs, and the decision is what governs the behavior.
Backend handler+service suites and go vet green.

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

* fix(comments): honest merge outcome — refused merge is blocked, not fake coalesced (MUL-4525)

A pending-task merge previously reported success (coalesced) even when it
was refused or failed: attribution fail-closed and unknown DB errors both
returned handled=true, so the caller recorded coalesced for a merge that
never happened. mergeCommentIntoPendingTask now returns a distinguishable
commentMergeResult; commentMergeTerminalOutcome maps a real merge to
coalesced, a fail-closed refusal to blocked/attribution_blocked, and any
other failure to blocked/internal_error. Only "no queued task to fold"
falls through to the active-task decision. Adds pure coverage of the
mapping plus a fail-closed regression asserting the non-success outcome
and unchanged task count.

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

* fix(comments): name blocked @mentions in preview + toast instead of a vague count (MUL-4525)

The blocked-trigger preview showed a red "1 mention won't trigger" with no
name, and the post-send toast said "but 1 mention wasn't triggered" — the
user can't tell which target or why. Now each blocked mention renders its own
chip named from the mention markup the user typed ("Go · No permission"),
with an error indicator and a short reason; the toast names the single target
too. The wire outcome still omits the target name (enumeration-safety) — the
label comes from the user's own draft, so nothing new is disclosed.

Shares a blocked-trigger-copy module (long + short reason labels) between the
chip and the toast, and a pure mentionLabelsByTarget/parseMentions helper in
core (fresh regex per call — a shared global leaked lastIndex). Adds core +
chip tests; drops the now-unused trigger_blocked_count keys across locales.

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

* feat(attribution): accountable-member avatar on agent task rows + transcript header (MUL-4302)

Surface who each agent run is on behalf of where runs are actually
browsed:
- Agent detail activity tab: an avatar-only AttributionBadge on every
  task row's meta line (Now + Recent work), tooltip carries the name +
  resolution source.
- Execution-record (transcript) dialog header: the full on-behalf-of
  badge next to the status pill.

Adds a compact variant="avatar" mode to AttributionBadge, reusing its
source-label mapping and degraded-attribution tone. Renders nothing when
a run has no resolved accountable member.

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

---------

Co-authored-by: J <j@multica.ai>
Co-authored-by: multica-agent <github@multica.ai>
2026-07-15 03:09:56 +08:00

2037 lines
73 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package handler
import (
"bytes"
"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"`
// ReasonCode is a stable, localizable, enumeration-safe classification of a
// non-success run (skipped/failed), derived from FailureReason. The "run now"
// UI localizes it instead of echoing the raw English reason (which may name a
// private assignee agent). Additive: nil for success-path runs and ignored by
// old clients (MUL-4525).
ReasonCode *string `json:"reason_code,omitempty"`
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),
// ReasonCode is left unset here: it is a decision-time value the manual
// "run now" handler injects from the typed dispatch outcome (MUL-4525).
// Persisted rows (list/history) surface the human failure_reason instead
// of a code reverse-engineered from that text.
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
}
// Creating an autopilot IS a substantive publish: append rule-version v1 with
// the creating member as publisher, so every autopilot has an accountable
// human at dispatch time (MUL-4302 §3.4).
if err := h.recordAutopilotRuleVersion(r.Context(), qtx, autopilot, "member", parseUUID(userID)); 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
}
// A substantive change (target / enabled-state / execution mode) republishes the
// rule: append a new version with THIS member as publisher, so a later run
// attributes to whoever last changed what the rule does — not the original
// creator. Cosmetic edits (title / description / template) write no version and
// leave accountability with the previous publisher (MUL-4302 §3.4).
if autopilotRuleSubstantiveChange(prev, autopilot) {
if err := h.recordAutopilotRuleVersion(r.Context(), qtx, autopilot, "member", parseUUID(userID)); err != nil {
writeError(w, http.StatusInternalServerError, "failed to update autopilot")
return
}
// An autopilot-level substantive edit governs every trigger, so responsibility
// for each firing trigger transfers to this editor (source=trigger_owner). A
// trigger-scoped edit re-stamps only its own row (see UpdateAutopilotTrigger).
if err := qtx.SetAutopilotTriggerPublishersByAutopilot(r.Context(), db.SetAutopilotTriggerPublishersByAutopilotParams{
AutopilotID: autopilot.ID,
PublishedByType: pgtype.Text{String: "member", Valid: true},
PublishedByID: parseUUID(userID),
}); 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)
}
// autopilotRuleSubstantiveChange reports whether a substantive (publish-worthy)
// field of the autopilot ROW changed between prev and next — a change that alters
// WHAT the automation instructs the agent to do, or WHO / WHETHER it runs, and so
// transfers accountability to the editor (MUL-4302 §3.4; boundary pinned with Elon):
//
// - assignee_type / assignee_id — who (agent / squad leader) executes;
// - status — enabled state (active / paused / archived);
// - execution_mode — run_only vs create_issue;
// - description — the product surfaces this as the run PROMPT, i.e. the task
// instruction itself, so editing it must transfer responsibility (the gap Elon
// flagged: a fresh publisher of the instructions is the accountable human);
// - issue_title_template — templates the created issue in create_issue mode; part
// of the instruction / output spec the run produces.
//
// Deliberately NOT substantive (cosmetic / routing — they change neither the
// instruction nor the executor): title (display label) and project_id (which project
// created issues are filed under). The comparison is faithful because UpdateAutopilot
// seeds every param from prev, so an omitted field round-trips unchanged.
//
// Trigger-table edits (cron / timezone / enabled / event_filters) are substantive PER
// TRIGGER and handled in UpdateAutopilotTrigger; archive and system-pause republish in
// their own paths.
func autopilotRuleSubstantiveChange(prev, next db.Autopilot) bool {
return prev.AssigneeType != next.AssigneeType ||
prev.AssigneeID != next.AssigneeID ||
prev.Status != next.Status ||
prev.ExecutionMode != next.ExecutionMode ||
prev.Description != next.Description ||
prev.IssueTitleTemplate != next.IssueTitleTemplate
}
// recordAutopilotRuleVersion appends one rule-version snapshot for a substantive
// publish (MUL-4302 §3.4). Thin handler wrapper over service.RecordAutopilotRuleVersion
// (shared with the failure monitor); callers pass their tx-scoped Queries so the
// version is atomic with the autopilot write.
func (h *Handler) recordAutopilotRuleVersion(ctx context.Context, q *db.Queries, ap db.Autopilot, publishedByType string, publishedByID pgtype.UUID) error {
return service.RecordAutopilotRuleVersion(ctx, q, ap, publishedByType, publishedByID)
}
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
}
// Product "delete" is archival: stop future triggers and hide the
// autopilot from default lists while preserving runs, tasks, webhook
// deliveries, subscribers, and collaborators as execution history.
// Archiving is a substantive status change (MUL-4302 §3.4), so republish the
// rule version with this member as publisher, atomically with the archive.
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.ArchiveAutopilot(r.Context(), idUUID); err != nil {
writeError(w, http.StatusInternalServerError, "failed to delete autopilot")
return
}
ap.Status = "archived" // reflect the post-archive state in the version snapshot
if err := h.recordAutopilotRuleVersion(r.Context(), qtx, ap, "member", parseUUID(userID)); 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
}
// A new trigger changes what / when the rule fires — a substantive publish, so
// the acting member republishes the rule version ATOMICALLY with the trigger
// create (MUL-4302 §3.4). Resolved here so both the webhook and schedule create
// paths can write the version inside the same tx as the INSERT — a failed
// version write must roll the trigger back, never leave future dispatches
// attributed to the previous publisher.
userID, ok := requireUserID(w, r)
if !ok {
return
}
publisherID := parseUUID(userID)
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, ptrToText(req.Label), provider, eventFiltersBytes, publisherID)
if err != nil {
writeError(w, http.StatusInternalServerError, "failed to create trigger")
return
}
resp := h.triggerToResponse(trigger)
h.publish(protocol.EventAutopilotUpdated, workspaceID, "member", userID, map[string]any{
"autopilot_id": uuidToString(ap.ID),
"trigger": resp,
})
writeJSON(w, http.StatusCreated, resp)
return
}
// Schedule create: write the trigger and republish the rule version atomically.
tx, err := h.TxStarter.Begin(r.Context())
if err != nil {
writeError(w, http.StatusInternalServerError, "failed to create trigger")
return
}
defer tx.Rollback(r.Context())
qtx := h.Queries.WithTx(tx)
trigger, err := qtx.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,
// Seed the responsible publisher = creator; a later substantive edit re-stamps
// it to the editor so runs attribute to whoever last shaped this trigger
// (source=trigger_owner, MUL-4302).
PublishedByType: pgtype.Text{String: "member", Valid: publisherID.Valid},
PublishedByID: publisherID,
})
if err != nil {
writeError(w, http.StatusInternalServerError, "failed to create trigger")
return
}
if err := h.recordAutopilotRuleVersion(r.Context(), qtx, ap, "member", publisherID); err != nil {
writeError(w, http.StatusInternalServerError, "failed to create trigger")
return
}
if err := tx.Commit(r.Context()); err != nil {
writeError(w, http.StatusInternalServerError, "failed to create trigger")
return
}
resp := h.triggerToResponse(trigger)
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.
//
// Each attempt runs in its OWN transaction so the trigger INSERT and the
// rule-version republish (a webhook trigger is a substantive change to what fires,
// MUL-4302 §3.4, published by publisherID) commit together — a version-write failure
// rolls the trigger back rather than leaving future dispatches attributed to the
// previous publisher. Retries on the unique-index collision case with a fresh token
// (the collided attempt's tx is already rolled back), so a vanishingly-rare RNG
// collision turns into a clean retry rather than a 500.
func (h *Handler) createWebhookTriggerWithMintedToken(
r *http.Request,
ap db.Autopilot,
label pgtype.Text,
provider string,
eventFilters []byte,
publisherID pgtype.UUID,
) (db.AutopilotTrigger, error) {
ctx := r.Context()
for attempt := 0; attempt < 3; attempt++ {
token, err := generateWebhookToken()
if err != nil {
return db.AutopilotTrigger{}, err
}
tx, err := h.TxStarter.Begin(ctx)
if err != nil {
return db.AutopilotTrigger{}, err
}
qtx := h.Queries.WithTx(tx)
trigger, err := qtx.CreateAutopilotTrigger(ctx, db.CreateAutopilotTriggerParams{
AutopilotID: ap.ID,
Kind: "webhook",
Enabled: true,
Label: label,
WebhookToken: pgtype.Text{String: token, Valid: true},
Provider: pgtype.Text{String: provider, Valid: provider != ""},
EventFilters: eventFilters,
// Seed the responsible publisher = creator; re-stamped to the editor on a
// later substantive edit (source=trigger_owner, MUL-4302).
PublishedByType: pgtype.Text{String: "member", Valid: publisherID.Valid},
PublishedByID: publisherID,
})
if err != nil {
tx.Rollback(ctx)
if isUniqueViolation(err) {
continue // token collision: retry with a fresh token
}
return db.AutopilotTrigger{}, err
}
if err := h.recordAutopilotRuleVersion(ctx, qtx, ap, "member", publisherID); err != nil {
tx.Rollback(ctx)
return db.AutopilotTrigger{}, err
}
if err := tx.Commit(ctx); err != nil {
return db.AutopilotTrigger{}, err
}
return trigger, nil
}
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}
}
userID, ok := requireUserID(w, r)
if !ok {
return
}
tx, err := h.TxStarter.Begin(r.Context())
if err != nil {
writeError(w, http.StatusInternalServerError, "failed to update trigger")
return
}
defer tx.Rollback(r.Context())
qtx := h.Queries.WithTx(tx)
trigger, err := qtx.UpdateAutopilotTrigger(r.Context(), params)
if err != nil {
writeError(w, http.StatusInternalServerError, "failed to update trigger")
return
}
// Only a substantive edit republishes the rule version and transfers this
// trigger's accountability to the editor. cron / timezone / enabled / event_filters
// change WHAT or WHEN the trigger fires; label is a cosmetic display field, and a
// no-op PATCH changes nothing — neither should move responsibility (MUL-4302; the
// over-transfer Elon flagged). Comparing the persisted before/after rows captures a
// real change and ignores label-only / no-op PATCHes (next_run_at is derived from
// cron/timezone, so it is not an independent signal).
triggerSubstantiveChange := prev.Enabled != trigger.Enabled ||
prev.CronExpression != trigger.CronExpression ||
prev.Timezone != trigger.Timezone ||
!bytes.Equal(prev.EventFilters, trigger.EventFilters)
if triggerSubstantiveChange {
if err := h.recordAutopilotRuleVersion(r.Context(), qtx, ap, "member", parseUUID(userID)); err != nil {
writeError(w, http.StatusInternalServerError, "failed to update trigger")
return
}
// Responsibility for THIS trigger's runs transfers to the editor. Scoped to the
// single row so editing one trigger never reassigns another's accountability —
// the per-firing-trigger granularity the autopilot-scoped rule_version can't give.
if err := qtx.SetAutopilotTriggerPublisher(r.Context(), db.SetAutopilotTriggerPublisherParams{
ID: trigger.ID,
PublishedByType: pgtype.Text{String: "member", Valid: true},
PublishedByID: parseUUID(userID),
}); err != nil {
writeError(w, http.StatusInternalServerError, "failed to update trigger")
return
}
}
if err := tx.Commit(r.Context()); err != nil {
writeError(w, http.StatusInternalServerError, "failed to update trigger")
return
}
resp := h.triggerToResponse(trigger)
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
}
// Removing a trigger changes what fires — a substantive publish (MUL-4302 §3.4).
// Republish the rule version with this member as publisher, atomically with the
// delete.
tx, err := h.TxStarter.Begin(r.Context())
if err != nil {
writeError(w, http.StatusInternalServerError, "failed to delete trigger")
return
}
defer tx.Rollback(r.Context())
qtx := h.Queries.WithTx(tx)
if err := qtx.DeleteAutopilotTrigger(r.Context(), triggerUUID); err != nil {
writeError(w, http.StatusInternalServerError, "failed to delete trigger")
return
}
if err := h.recordAutopilotRuleVersion(r.Context(), qtx, ap, "member", parseUUID(userID)); err != nil {
writeError(w, http.StatusInternalServerError, "failed to delete trigger")
return
}
if err := tx.Commit(r.Context()); 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
}
// A manual "run now" is a direct human action, so the run is attributed
// direct_human to the triggering member (MUL-4302 §4). Resolve the actor the
// same way assign/promote does; only a member actor is a human — an agent
// triggering via A2A yields an invalid actor and falls back to rule_owner.
userID, ok := requireUserID(w, r)
if !ok {
return
}
actorType, actorID := h.resolveActor(r, userID, workspaceID)
run, reasonCode, err := h.AutopilotService.DispatchAutopilotManual(r.Context(), autopilot, pgtype.UUID{}, nil, memberActorUserID(actorType, actorID))
if err != nil {
writeError(w, http.StatusInternalServerError, "failed to trigger autopilot: "+err.Error())
return
}
// Carry the typed admission reason (decided at its source, MUL-4525) straight
// into the response — no reverse-engineering from failure_reason text. The
// UI branches on run status + this code for the "run now" toast.
resp := runToResponse(*run)
if reasonCode != "" {
c := string(reasonCode)
resp.ReasonCode = &c
}
writeJSON(w, http.StatusOK, resp)
}