Files
multica/server/pkg/db/generated/workspace_delete.sql.go
T
659bb41abd MUL-6350: rebuild the plugin system (3/4) — hook engine (#7182)
* feat(plugins): hook engine — signed outbound calls, trigger-decided identity (MUL-6350)

The direction reverses here. PR 1 and 2 only ever ran inbound: a sandboxed
surface asked the host, and the host acted on the signed-in user's session, so
nothing left our infrastructure on a plugin's say-so. A hook leaves, and every
new check exists because of that.

Destination: the target host must be inside the granted `net:` set, resolve to
a public address, and be re-checked at dial. Body: HMAC-SHA256 over
timestamp.body, so a receiver can tell our call from anyone else's and a
captured one expires.

Identity follows the trigger, not the plugin. A ui/manual hook runs because a
person pressed something, so its writes stay theirs; an event hook has nobody
behind it and writes as the installation — hence the fourth author_type.

Two credentials, moving opposite ways, which is what decides how each is
stored. The install token goes plugin -> host and is only ever verified, so it
is hashed. The signing secret goes host -> plugin and must be reproduced to
sign with, so it is derived from the deployment key and stored nowhere.

Event hooks never block the host: Bus.Publish runs listeners inline on the
publishing request's goroutine, so the dispatcher hands off to a worker pool
immediately. Three attempts, then a circuit breaker, then silence.

Co-authored-by: multica-agent <[email protected]>

* feat(plugins): manual hook actions, ui trigger, and a worked hook example (MUL-6350)

The host owns the manual menu entry rather than letting a surface draw one,
because the trigger decides identity: a manual call acts as the person who
picked it, so the entry has to live where the host can prove somebody did.

The ui trigger goes through the bridge for the same reason a surface cannot
fetch its own backend directly — routing through the host is what makes the
call signed, rate limited, bounded to the granted net: domains and recorded.

buildActorNameResolver gains a plugin branch. Without it an event-written
comment rendered as "System", which is the one attribution that is actively
misleading: it reads as the platform having said something.

examples/plugins/triage-notify carries the half that does NOT run in Multica —
a reference handler showing the four things a receiver must do, including the
replay guard the host cannot do for it.

TestShippedExamplesParseAndInstallOnThisHost closes the gap that let an example
drift: nothing checked that shipped documentation still parses, or that it
declares only what this build can run.

TestCheckCapabilitiesReportsEveryUnavailableContribution rewritten the way the
other two were in PR 2 — it named today's unshipped set, so flipping ui/manual/
event on turned it red with a message about the flip rather than about the gate.
It now derives its expectation from HostCapabilities and survives a flip while
still failing if the gate stops checking a category.

Co-authored-by: multica-agent <[email protected]>

* feat(plugins): modal surface, invocation cleanup, honest capability guards (MUL-6350)

TestShippedHostCapabilitiesRunTheSurfacesTheHostMounts caught me flipping the
modal surface on without a renderer — an install that succeeds and then never
appears, which is the exact failure that guard exists to prevent. Built the
mount point rather than weakening the guard, and rewrote it to name the host
code behind each line so the next flip has to change both together.

plugin_invocation was missing from workspace teardown and from uninstall.
TestWorkspaceDeletionManifestCoversPublicSchema caught the first; the second
would have left rows naming an installation that no longer exists. Deleted by
workspace_id rather than through installation ids, so a record whose plugin was
already uninstalled does not outlive the workspace it describes.

A modal opens because a person picked it from the issue menu, never on the
plugin's own initiative — same sandbox as a panel, differing only in where it
appears.

Co-authored-by: multica-agent <[email protected]>

* test(plugins): prove the event path never blocks, and contain worker panics (MUL-6350)

Writing the never-blocks test found a real bug rather than confirming one.
Bus.Publish recovers panics in its listeners so one bad handler cannot kill the
request that published; moving delivery onto a worker goroutine stepped outside
that protection, where a panic is not a failed hook but a dead process.
runGuarded restores the guarantee the hand-off gave up.

The bridge now takes an EventSink rather than the concrete dispatcher, so the
vocabulary mapping — which internal event becomes which published one — is
asserted directly instead of through a live endpoint. That mapping is a
published contract; the internal names are not.

The backpressure test stops the workers rather than racing them, so it
reproduces a saturated pool without depending on timing, and asserts the exact
overflow instead of "something was dropped".

Co-authored-by: multica-agent <[email protected]>

* fix(plugins): two hook-contract bugs a live run found (MUL-6350)

Both were invisible to the tests because the tests made one call each, and a
real handler does not.

The callback token was single-use. The reference handler reads the issue,
decides, then posts a comment — and the second call died on a spent token.
Read-then-write is the floor for a handler that does anything with what it
read, so a single-use grant made the callback unusable.

The trade that decides it is not one call versus several. It is this token
versus the installation's standing token: a handler that cannot finish with the
callback gets written against the install token instead, which never expires
and is not scoped to an invocation. A control that pushes authors toward the
stronger credential is worse than the looser one they will actually use. The
grant is now invocation-scoped — minutes, this installation's scopes, the actor
fixed at dispatch — and revoked as soon as the call it was issued for returns.

The hook body also omitted the issue. The host resolves and permission-checks
it, then said nothing, so a handler had to read a client-supplied field —
unvalidated for ui/manual, and absent entirely for event, where there is no
client. issue_id now travels in the body.

Also adds MULTICA_PLUGIN_DEV_CA: a hook URL must be HTTPS, so an author testing
against localhost needs its certificate trusted by something. A named CA file
rather than InsecureSkipVerify, which survives into production behind a config
flag nobody remembers and cannot be spotted by reading the code.

Co-authored-by: multica-agent <[email protected]>

* fix(plugins): enforce the callback token's issue scope, sweep invocations (MUL-6350)

Addresses Fabel 5's review. The first item is the one that mattered.

The callback grant's issue narrowing existed only in two doc comments and the
PR description — pluginTokenCaller never read grant.IssueID, so a ui/manual
token was worth every issue in the workspace its actor could see, for the whole
five minutes it lived, on read and write alike. Now carried into the caller and
checked in pluginIssueForUser, after resolution so an identifier and a uuid for
the same issue agree. 404 rather than 403: "you are scoped elsewhere" confirms
the id exists.

DeleteExpiredPluginInvocations was generated and never called, while the table
comment and the PR both said TTL-swept. A sweeper now runs on the dispatcher's
lifecycle — same concern, bounded resources for something a third party drives.

Event subscriptions bypassed the read scopes. issue.* pushes the description
and comment.created pushes the body — the same content the Action API refuses
without issues:read / comments:read. Subscribing was a way to receive what
reading was not granted. Enforced at manifest validation so the consent screen
shows the read scope a subscription implies.

Migration 349's ADD CONSTRAINT copied 107's shape onto a table that is no
longer 107's size: it revalidated every comment row under ACCESS EXCLUSIVE.
NOT VALID plus a 352 that VALIDATEs under SHARE UPDATE EXCLUSIVE costs nothing
and stops nothing.

Plus the half-built breaker UI (a plugin could be enabled and silently
delivering nothing), redirect refusal on the dev hook client (a 302 would have
replayed the signed body and the callback token onward), and omitting
callback_url rather than sending a relative one.

Co-authored-by: multica-agent <[email protected]>

* fix(plugins): the invocation sweeper must not touch the database at construction (MUL-6350)

CI panicked in cmd/server: the sweeper I added last round fired immediately on
its own goroutine, and that test builds a router over a Queries whose pool was
never opened. pgxpool dereferenced the nil pool and the process died.

Two things were wrong, and only one of them is the obvious one.

The sweep no longer runs at construction. It waited for nothing and gained one
hour of retention on a cold start against a 7-day TTL; the cost was a goroutine
reaching for the database before the process is known to have one.

The nil check was also the wrong instrument. sqlc's Queries wraps an executor,
so a non-nil Queries over an unopened pool passes every comparison available at
that layer and only fails inside pgxpool — no amount of nil-checking here can
see it. The guard is a recover, for the same reason runGuarded exists: on a bare
goroutine a panic is not a failed sweep, it is a dead server. I had already
fixed that class once for delivery and then reintroduced it in the sweeper.

The regression test builds the exact shape CI hit — a dispatcher over db.New(nil)
— and fails if either the construction-time sweep or the recover comes back.
cmd/server cannot reproduce it locally, where DATABASE_URL supplies a real pool.

Co-authored-by: multica-agent <[email protected]>

* fix(plugins): gate the event dispatch path behind plugins_v1 (MUL-6350)

Answering "is this still behind the flag" turned up the one place it was not.

Every other plugin surface is refused by a handler that reads the flag off a
request. An event hook has no request: the dispatcher is subscribed to the bus
at startup and delivers on a worker. Nothing in it consulted the flag, so
turning plugins off hid the UI and refused the Action API while the OUTBOUND
calls kept going — fail-open on the one path that reaches a third party.

It only bites after a plugin has been installed, because installation is
flag-gated, so a deployment that never enabled plugins was never exposed. A
workspace that enabled it, installed something, then turned it off was.

The check reads the flag per delivery rather than once at subscription, so the
flip takes effect immediately instead of at the next restart. It also removes
the flag-off cost: every dispatched event used to run a
ListWorkspacePluginInstallations query to discover there was nothing to call.

Nil flags read as disabled — a deployment that never wired the flag service must
not get outbound hooks by omission.

The test lives in internal/handler because it needs a real installed plugin to
mean anything: with the flag on the endpoint is called, with it off nothing
leaves while the same installation stays enabled and still declares the hook.
Removing the check makes it fail on the second half.

Co-authored-by: multica-agent <[email protected]>

* perf(plugins): stop parsing event payloads on the publishing goroutine (MUL-6350)

Found while answering whether merging can affect existing users, and the answer
was "in one measurable way it can".

The bus listener passed issueIDFromPayload(e.Payload) as an argument, so a JSON
marshal plus unmarshal of the full event payload — for issue.created that is the
whole issue body, description included — ran inline on the goroutine of whatever
request published. Seven event types, every workspace, and the feature flag was
checked far downstream on the worker, which then threw the result away. A
deployment with plugins switched off paid for all of it.

The listener now hands the payload over unexamined and the id is extracted on
the worker, past the flag check. It also fits the rule this file already
claimed: the publishing request does the least it possibly can.

Not moved: the status_changed lookup, which is a map read, not a parse.

TestPublishingDoesNotParseThePayload counts MarshalJSON calls during Publish and
fails if any happen — restoring the old argument makes it fail with the count.

Co-authored-by: multica-agent <[email protected]>

---------

Co-authored-by: Lambda <[email protected]>
Co-authored-by: multica-agent <[email protected]>
2026-08-19 19:29:15 +08:00

1135 lines
38 KiB
Go

// Code generated by sqlc. DO NOT EDIT.
// versions:
// sqlc v1.31.1
// source: workspace_delete.sql
package db
import (
"context"
"github.com/jackc/pgx/v5/pgtype"
)
const deleteTaskBatch = `-- name: DeleteTaskBatch :exec
WITH
batch AS MATERIALIZED (
SELECT id FROM unnest($1::uuid[]) AS t(id)
),
deleted_task_usage AS (
DELETE FROM task_usage WHERE task_id IN (SELECT id FROM batch)
),
deleted_task_messages AS (
DELETE FROM task_message WHERE task_id IN (SELECT id FROM batch)
),
deleted_task_tokens AS (
DELETE FROM task_token WHERE task_id IN (SELECT id FROM batch)
),
deleted_channel_outbound_cards AS (
DELETE FROM channel_outbound_card_message WHERE task_id IN (SELECT id FROM batch)
),
deleted_lark_outbound_cards AS (
DELETE FROM lark_outbound_card_message WHERE task_id IN (SELECT id FROM batch)
),
deleted_draft_restores AS (
DELETE FROM chat_draft_restore WHERE task_id IN (SELECT id FROM batch)
)
DELETE FROM agent_task_queue WHERE id IN (SELECT id FROM batch)
`
// Deletes one bounded batch of tasks together with everything that hangs off
// them, every arm keyed by task_id against an existing index, then the task rows
// by primary key. The legacy FK cascades stay a safety net only: this statement
// is what actually removes the rows, so teardown keeps working when they go.
func (q *Queries) DeleteTaskBatch(ctx context.Context, taskIds []pgtype.UUID) error {
_, err := q.db.Exec(ctx, deleteTaskBatch, taskIds)
return err
}
const deleteTaskTokensByAgent = `-- name: DeleteTaskTokensByAgent :exec
DELETE FROM task_token WHERE agent_id = $1
`
// The third explicit task_token path. workspace_id (in DeleteWorkspaceLeafData)
// and task_id (in DeleteTaskBatch) do not cover a token whose own workspace_id
// points at a neighbour while its agent lives here, and teardown must not fall
// back on the agent_id cascade for it. Uses idx_task_token_agent_id
// (migration 275); one agent key per call for the same planner reason as
// ListTaskIDsByAgentBatch.
func (q *Queries) DeleteTaskTokensByAgent(ctx context.Context, agentID pgtype.UUID) error {
_, err := q.db.Exec(ctx, deleteTaskTokensByAgent, agentID)
return err
}
const deleteWorkspaceAdministration = `-- name: DeleteWorkspaceAdministration :exec
WITH
deleted_members AS (
DELETE FROM member WHERE member.workspace_id = $1
),
deleted_notification_preferences AS (
DELETE FROM notification_preference
WHERE notification_preference.workspace_id = $1
),
deleted_pins AS (
DELETE FROM pinned_item WHERE pinned_item.workspace_id = $1
),
deleted_daemon_tokens AS (
DELETE FROM daemon_token WHERE daemon_token.workspace_id = $1
),
detached_feedback AS (
UPDATE feedback
SET workspace_id = NULL
WHERE feedback.workspace_id = $1
),
detached_client_usage AS (
UPDATE client_usage_daily
SET workspace_id = NULL
WHERE client_usage_daily.workspace_id = $1
),
deleted_share_links AS (
DELETE FROM workspace_share_link
WHERE workspace_share_link.workspace_id = $1
)
DELETE FROM workspace_invitation
WHERE workspace_invitation.workspace_id = $1
`
func (q *Queries) DeleteWorkspaceAdministration(ctx context.Context, workspaceID pgtype.UUID) error {
_, err := q.db.Exec(ctx, deleteWorkspaceAdministration, workspaceID)
return err
}
const deleteWorkspaceAgents = `-- name: DeleteWorkspaceAgents :exec
DELETE FROM agent WHERE agent.workspace_id = $1
`
func (q *Queries) DeleteWorkspaceAgents(ctx context.Context, workspaceID pgtype.UUID) error {
_, err := q.db.Exec(ctx, deleteWorkspaceAgents, workspaceID)
return err
}
const deleteWorkspaceAutopilotChildren = `-- name: DeleteWorkspaceAutopilotChildren :exec
WITH
deleted_triggers AS (
DELETE FROM autopilot_trigger
WHERE autopilot_id IN (
SELECT id FROM autopilot WHERE autopilot.workspace_id = $1
)
)
DELETE FROM autopilot_rule_version
WHERE autopilot_rule_version.workspace_id = $1
`
func (q *Queries) DeleteWorkspaceAutopilotChildren(ctx context.Context, workspaceID pgtype.UUID) error {
_, err := q.db.Exec(ctx, deleteWorkspaceAutopilotChildren, workspaceID)
return err
}
const deleteWorkspaceAutopilotQuotaPeriods = `-- name: DeleteWorkspaceAutopilotQuotaPeriods :exec
DELETE FROM autopilot_quota_period
WHERE autopilot_quota_period.workspace_id = $1
`
func (q *Queries) DeleteWorkspaceAutopilotQuotaPeriods(ctx context.Context, workspaceID pgtype.UUID) error {
_, err := q.db.Exec(ctx, deleteWorkspaceAutopilotQuotaPeriods, workspaceID)
return err
}
const deleteWorkspaceAutopilotQuotaReservations = `-- name: DeleteWorkspaceAutopilotQuotaReservations :exec
DELETE FROM autopilot_quota_reservation
WHERE autopilot_quota_reservation.workspace_id = $1
`
func (q *Queries) DeleteWorkspaceAutopilotQuotaReservations(ctx context.Context, workspaceID pgtype.UUID) error {
_, err := q.db.Exec(ctx, deleteWorkspaceAutopilotQuotaReservations, workspaceID)
return err
}
const deleteWorkspaceAutopilotRuns = `-- name: DeleteWorkspaceAutopilotRuns :exec
DELETE FROM autopilot_run
WHERE autopilot_id IN (
SELECT id FROM autopilot WHERE autopilot.workspace_id = $1
)
`
func (q *Queries) DeleteWorkspaceAutopilotRuns(ctx context.Context, workspaceID pgtype.UUID) error {
_, err := q.db.Exec(ctx, deleteWorkspaceAutopilotRuns, workspaceID)
return err
}
const deleteWorkspaceAutopilots = `-- name: DeleteWorkspaceAutopilots :exec
DELETE FROM autopilot WHERE autopilot.workspace_id = $1
`
func (q *Queries) DeleteWorkspaceAutopilots(ctx context.Context, workspaceID pgtype.UUID) error {
_, err := q.db.Exec(ctx, deleteWorkspaceAutopilots, workspaceID)
return err
}
const deleteWorkspaceChatMessages = `-- name: DeleteWorkspaceChatMessages :exec
DELETE FROM chat_message
WHERE chat_session_id IN (
SELECT id FROM chat_session WHERE chat_session.workspace_id = $1
)
`
func (q *Queries) DeleteWorkspaceChatMessages(ctx context.Context, workspaceID pgtype.UUID) error {
_, err := q.db.Exec(ctx, deleteWorkspaceChatMessages, workspaceID)
return err
}
const deleteWorkspaceComments = `-- name: DeleteWorkspaceComments :exec
DELETE FROM comment WHERE comment.workspace_id = $1
`
func (q *Queries) DeleteWorkspaceComments(ctx context.Context, workspaceID pgtype.UUID) error {
_, err := q.db.Exec(ctx, deleteWorkspaceComments, workspaceID)
return err
}
const deleteWorkspaceCommunicationRoots = `-- name: DeleteWorkspaceCommunicationRoots :exec
WITH
deleted_sessions AS (
DELETE FROM chat_session WHERE chat_session.workspace_id = $1
),
deleted_channel_installations AS (
DELETE FROM channel_installation
WHERE channel_installation.workspace_id = $1
)
DELETE FROM lark_installation WHERE lark_installation.workspace_id = $1
`
func (q *Queries) DeleteWorkspaceCommunicationRoots(ctx context.Context, workspaceID pgtype.UUID) error {
_, err := q.db.Exec(ctx, deleteWorkspaceCommunicationRoots, workspaceID)
return err
}
const deleteWorkspaceConnections = `-- name: DeleteWorkspaceConnections :exec
WITH deleted_github_installations AS (
DELETE FROM github_installation
WHERE github_installation.workspace_id = $1
)
DELETE FROM vcs_connection WHERE vcs_connection.workspace_id = $1
`
func (q *Queries) DeleteWorkspaceConnections(ctx context.Context, workspaceID pgtype.UUID) error {
_, err := q.db.Exec(ctx, deleteWorkspaceConnections, workspaceID)
return err
}
const deleteWorkspaceIssueRoots = `-- name: DeleteWorkspaceIssueRoots :exec
WITH
deleted_issues AS (
DELETE FROM issue WHERE issue.workspace_id = $1
),
deleted_labels AS (
DELETE FROM issue_label WHERE issue_label.workspace_id = $1
),
deleted_properties AS (
DELETE FROM issue_property WHERE issue_property.workspace_id = $1
),
deleted_issue_views AS (
DELETE FROM issue_view WHERE issue_view.workspace_id = $1
),
deleted_issue_view_preferences AS (
DELETE FROM issue_view_preference
WHERE issue_view_preference.workspace_id = $1
)
DELETE FROM quick_action WHERE quick_action.workspace_id = $1
`
func (q *Queries) DeleteWorkspaceIssueRoots(ctx context.Context, workspaceID pgtype.UUID) error {
_, err := q.db.Exec(ctx, deleteWorkspaceIssueRoots, workspaceID)
return err
}
const deleteWorkspaceLeafData = `-- name: DeleteWorkspaceLeafData :exec
WITH
ws_agents AS MATERIALIZED (
SELECT id FROM agent WHERE workspace_id = $1
),
ws_issues AS MATERIALIZED (
SELECT id FROM issue WHERE workspace_id = $1
),
ws_labels AS MATERIALIZED (
SELECT id FROM issue_label WHERE workspace_id = $1
),
ws_skills AS MATERIALIZED (
SELECT id FROM skill WHERE workspace_id = $1
),
ws_squads AS MATERIALIZED (
SELECT id FROM squad WHERE workspace_id = $1
),
ws_sessions AS MATERIALIZED (
SELECT id FROM chat_session WHERE workspace_id = $1
),
ws_autopilots AS MATERIALIZED (
SELECT id FROM autopilot WHERE workspace_id = $1
),
ws_github_prs AS MATERIALIZED (
SELECT id FROM github_pull_request WHERE workspace_id = $1
),
ws_vcs_prs AS MATERIALIZED (
SELECT id FROM vcs_pull_request WHERE workspace_id = $1
),
ws_vcs_connections AS MATERIALIZED (
SELECT id FROM vcs_connection WHERE workspace_id = $1
),
ws_channel_installations AS MATERIALIZED (
SELECT id FROM channel_installation WHERE workspace_id = $1
),
ws_lark_installations AS MATERIALIZED (
SELECT id FROM lark_installation WHERE workspace_id = $1
),
deleted_task_tokens AS (
DELETE FROM task_token
WHERE workspace_id = $1
),
deleted_hourly_dirty AS (
DELETE FROM task_usage_hourly_dirty WHERE workspace_id = $1
),
deleted_hourly AS (
DELETE FROM task_usage_hourly WHERE workspace_id = $1
),
deleted_attachments AS (
DELETE FROM attachment WHERE workspace_id = $1
),
deleted_channel_outbound_cards AS (
DELETE FROM channel_outbound_card_message
WHERE chat_session_id IN (SELECT id FROM ws_sessions)
),
deleted_lark_outbound_cards AS (
DELETE FROM lark_outbound_card_message
WHERE chat_session_id IN (SELECT id FROM ws_sessions)
),
deleted_draft_restores AS (
DELETE FROM chat_draft_restore
WHERE chat_session_id IN (SELECT id FROM ws_sessions)
),
deleted_agent_builder_drafts AS (
DELETE FROM agent_builder_draft WHERE workspace_id = $1
),
deleted_comment_reactions AS (
DELETE FROM comment_reaction WHERE workspace_id = $1
),
deleted_issue_reactions AS (
DELETE FROM issue_reaction WHERE workspace_id = $1
),
deleted_activity AS (
DELETE FROM activity_log WHERE workspace_id = $1
),
deleted_inbox AS (
DELETE FROM inbox_item WHERE workspace_id = $1
),
deleted_issue_dependencies AS (
DELETE FROM issue_dependency
WHERE issue_id IN (SELECT id FROM ws_issues)
OR depends_on_issue_id IN (SELECT id FROM ws_issues)
),
deleted_issue_subscribers AS (
DELETE FROM issue_subscriber
WHERE issue_id IN (SELECT id FROM ws_issues)
),
deleted_issue_labels AS (
DELETE FROM issue_to_label
WHERE issue_id IN (SELECT id FROM ws_issues)
OR label_id IN (SELECT id FROM ws_labels)
),
deleted_agent_labels AS (
DELETE FROM agent_to_label
WHERE agent_id IN (SELECT id FROM ws_agents)
OR label_id IN (SELECT id FROM ws_labels)
),
deleted_skill_labels AS (
DELETE FROM skill_to_label
WHERE skill_id IN (SELECT id FROM ws_skills)
OR label_id IN (SELECT id FROM ws_labels)
),
deleted_issue_github_links AS (
DELETE FROM issue_pull_request
WHERE issue_id IN (SELECT id FROM ws_issues)
OR pull_request_id IN (SELECT id FROM ws_github_prs)
),
deleted_issue_vcs_links AS (
DELETE FROM issue_vcs_pull_request
WHERE issue_id IN (SELECT id FROM ws_issues)
OR pull_request_id IN (SELECT id FROM ws_vcs_prs)
),
deleted_agent_invocation_targets AS (
DELETE FROM agent_invocation_target
WHERE agent_id IN (SELECT id FROM ws_agents)
),
deleted_agent_skills AS (
DELETE FROM agent_skill
WHERE agent_id IN (SELECT id FROM ws_agents)
OR skill_id IN (SELECT id FROM ws_skills)
),
deleted_skill_files AS (
DELETE FROM skill_file
WHERE skill_id IN (SELECT id FROM ws_skills)
),
deleted_daemon_connections AS (
DELETE FROM daemon_connection
WHERE agent_id IN (SELECT id FROM ws_agents)
),
deleted_squad_members AS (
DELETE FROM squad_member
WHERE squad_id IN (SELECT id FROM ws_squads)
),
deleted_project_resources AS (
DELETE FROM project_resource WHERE workspace_id = $1
),
deleted_autopilot_collaborators AS (
DELETE FROM autopilot_collaborator
WHERE autopilot_id IN (SELECT id FROM ws_autopilots)
),
deleted_autopilot_subscribers AS (
DELETE FROM autopilot_subscriber
WHERE autopilot_id IN (SELECT id FROM ws_autopilots)
),
deleted_webhook_deliveries AS (
DELETE FROM webhook_delivery WHERE workspace_id = $1
),
deleted_github_check_runs AS (
DELETE FROM github_pull_request_check_run
WHERE pr_id IN (SELECT id FROM ws_github_prs)
),
deleted_github_check_suites AS (
DELETE FROM github_pull_request_check_suite
WHERE pr_id IN (SELECT id FROM ws_github_prs)
),
deleted_pending_github_suites AS (
DELETE FROM github_pending_check_suite WHERE workspace_id = $1
),
deleted_vcs_commit_statuses AS (
DELETE FROM vcs_commit_status
WHERE connection_id IN (SELECT id FROM ws_vcs_connections)
),
deleted_channel_chat_bindings AS (
DELETE FROM channel_chat_session_binding
WHERE installation_id IN (SELECT id FROM ws_channel_installations)
OR chat_session_id IN (SELECT id FROM ws_sessions)
),
deleted_dingtalk_group_routes AS (
DELETE FROM dingtalk_group_route WHERE workspace_id = $1
),
deleted_channel_inbound_dedup AS (
DELETE FROM channel_inbound_message_dedup
WHERE installation_id IN (SELECT id FROM ws_channel_installations)
),
deleted_channel_inbound_audit AS (
DELETE FROM channel_inbound_audit
WHERE installation_id IN (SELECT id FROM ws_channel_installations)
),
deleted_channel_user_bindings AS (
DELETE FROM channel_user_binding WHERE workspace_id = $1
),
deleted_channel_binding_tokens AS (
DELETE FROM channel_binding_token WHERE workspace_id = $1
),
deleted_lark_chat_bindings AS (
DELETE FROM lark_chat_session_binding
WHERE installation_id IN (SELECT id FROM ws_lark_installations)
OR chat_session_id IN (SELECT id FROM ws_sessions)
),
deleted_lark_inbound_dedup AS (
DELETE FROM lark_inbound_message_dedup
WHERE installation_id IN (SELECT id FROM ws_lark_installations)
),
deleted_lark_inbound_audit AS (
DELETE FROM lark_inbound_audit
WHERE installation_id IN (SELECT id FROM ws_lark_installations)
),
deleted_lark_user_bindings AS (
DELETE FROM lark_user_binding WHERE workspace_id = $1
),
deleted_lark_binding_tokens AS (
DELETE FROM lark_binding_token WHERE workspace_id = $1
)
UPDATE channel_media_pending_object
SET state = CASE
WHEN state = 'tombstoned' THEN 'tombstoned'
ELSE 'deleting'
END,
lease_token = NULL,
lease_expires_at = NULL,
next_attempt_at = now(),
last_error = NULL
WHERE channel_media_pending_object.workspace_id = $1
`
// Everything task-keyed moved to DeleteTaskBatch, which runs in bounded batches
// before this step; what is left is keyed by the workspace or by one of the
// workspace-scoped id sets below.
// One of three explicit task_token paths. This is the workspace-keyed one;
// DeleteTaskBatch covers task_id and DeleteTaskTokensByAgent covers agent_id, so
// a token whose workspace_id points at a neighbour while its task or agent lives
// here is still removed by this teardown rather than by the FK cascade. The
// former single statement combined all three with OR, which cost a full scan of
// task_token (MUL-5999); split, each path is an index scan.
// Same no-FK chore as chat_draft_restore above. Matched on workspace_id rather
// than the session set because that column exists precisely so this statement
// does not have to join through chat_session, which it deletes in this same CTE.
// Keep the two-system cleanup ledger until object storage has been settled.
// Moving every row out of pending also prevents a concurrent media bind from
// attaching an object after the workspace teardown commits. The reconciler
// performs the idempotent object delete and clears the row afterwards.
func (q *Queries) DeleteWorkspaceLeafData(ctx context.Context, workspaceID pgtype.UUID) error {
_, err := q.db.Exec(ctx, deleteWorkspaceLeafData, workspaceID)
return err
}
const deleteWorkspacePluginData = `-- name: DeleteWorkspacePluginData :exec
WITH installations AS MATERIALIZED (
SELECT plugin_installation.id
FROM plugin_installation
WHERE plugin_installation.workspace_id = $1
),
deleted_storage AS (
DELETE FROM plugin_storage
WHERE installation_id IN (SELECT id FROM installations)
),
deleted_secrets AS (
DELETE FROM plugin_secret
WHERE installation_id IN (SELECT id FROM installations)
),
deleted_invocations AS (
DELETE FROM plugin_invocation
WHERE workspace_id = $1
)
DELETE FROM plugin_installation WHERE id IN (SELECT id FROM installations)
`
// Plugin relationships have no foreign keys or cascades. Storage and secrets
// hang off the installation, so both leaf tables are cleared through the
// workspace's installation ids before the installations themselves.
// Hook call records are workspace-scoped in their own right, so this deletes by
// workspace rather than through the installation ids: a row whose installation
// was already uninstalled would otherwise survive the workspace it described.
func (q *Queries) DeleteWorkspacePluginData(ctx context.Context, workspaceID pgtype.UUID) error {
_, err := q.db.Exec(ctx, deleteWorkspacePluginData, workspaceID)
return err
}
const deleteWorkspacePullRequests = `-- name: DeleteWorkspacePullRequests :exec
WITH deleted_github_prs AS (
DELETE FROM github_pull_request
WHERE github_pull_request.workspace_id = $1
)
DELETE FROM vcs_pull_request WHERE vcs_pull_request.workspace_id = $1
`
func (q *Queries) DeleteWorkspacePullRequests(ctx context.Context, workspaceID pgtype.UUID) error {
_, err := q.db.Exec(ctx, deleteWorkspacePullRequests, workspaceID)
return err
}
const deleteWorkspaceRuntimesAndProjects = `-- name: DeleteWorkspaceRuntimesAndProjects :exec
WITH
deleted_runtimes AS (
DELETE FROM agent_runtime WHERE agent_runtime.workspace_id = $1
),
deleted_profiles AS (
DELETE FROM runtime_profile WHERE runtime_profile.workspace_id = $1
)
DELETE FROM project WHERE project.workspace_id = $1
`
func (q *Queries) DeleteWorkspaceRuntimesAndProjects(ctx context.Context, workspaceID pgtype.UUID) error {
_, err := q.db.Exec(ctx, deleteWorkspaceRuntimesAndProjects, workspaceID)
return err
}
const deleteWorkspaceSquadsAndSkills = `-- name: DeleteWorkspaceSquadsAndSkills :exec
WITH deleted_squads AS (
DELETE FROM squad WHERE squad.workspace_id = $1
)
DELETE FROM skill WHERE skill.workspace_id = $1
`
func (q *Queries) DeleteWorkspaceSquadsAndSkills(ctx context.Context, workspaceID pgtype.UUID) error {
_, err := q.db.Exec(ctx, deleteWorkspaceSquadsAndSkills, workspaceID)
return err
}
const detachTaskBatchReferences = `-- name: DetachTaskBatchReferences :exec
WITH detached_runs AS (
UPDATE autopilot_run
SET task_id = NULL
WHERE task_id = ANY($1::uuid[])
)
UPDATE agent_task_queue
SET parent_task_id = NULL
WHERE parent_task_id = ANY($1::uuid[])
`
// Break inbound references to a task batch before deleting it, in the
// application layer rather than through the FKs' ON DELETE SET NULL. Both
// referencing columns are indexed (idx_agent_task_queue_parent from migration
// 055, idx_autopilot_run_task_id from migration 277) so this stays a per-batch
// index scan instead of the per-row full scan the FK action would do.
//
// Kept separate from DeleteTaskBatch: a task can be both a member of the batch
// and the parent of another member, and updating plus deleting the same row
// inside one statement is not well defined.
func (q *Queries) DetachTaskBatchReferences(ctx context.Context, taskIds []pgtype.UUID) error {
_, err := q.db.Exec(ctx, detachTaskBatchReferences, taskIds)
return err
}
const listTaskIDsByAgentFirstPage = `-- name: ListTaskIDsByAgentFirstPage :many
SELECT id FROM agent_task_queue
WHERE agent_id = $1
ORDER BY id
LIMIT $2
FOR UPDATE
`
type ListTaskIDsByAgentFirstPageParams struct {
AgentID pgtype.UUID `json:"agent_id"`
Limit int32 `json:"limit"`
}
// First page of the same walk; see ListWorkspaceAgentIDFirstPage for why this is
// a separate query rather than a zero-uuid cursor, and ListTaskIDsByAgentPage for
// the single-key / keyset / FOR UPDATE rationale.
func (q *Queries) ListTaskIDsByAgentFirstPage(ctx context.Context, arg ListTaskIDsByAgentFirstPageParams) ([]pgtype.UUID, error) {
rows, err := q.db.Query(ctx, listTaskIDsByAgentFirstPage, arg.AgentID, arg.Limit)
if err != nil {
return nil, err
}
defer rows.Close()
items := []pgtype.UUID{}
for rows.Next() {
var id pgtype.UUID
if err := rows.Scan(&id); err != nil {
return nil, err
}
items = append(items, id)
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const listTaskIDsByAgentPage = `-- name: ListTaskIDsByAgentPage :many
SELECT id FROM agent_task_queue
WHERE agent_id = $1 AND id > $2
ORDER BY id
LIMIT $3
FOR UPDATE
`
type ListTaskIDsByAgentPageParams struct {
AgentID pgtype.UUID `json:"agent_id"`
ID pgtype.UUID `json:"id"`
Limit int32 `json:"limit"`
}
// One owner key per call, one keyset-paged chunk per call.
//
// Single-key equality is deliberate. All three ownership paths must stay (nothing
// enforces that a task's agent/runtime/issue share a workspace), but expressing
// them as `OR agent_id IN (subquery) OR …` — or as a UNION of joins, or as
// `= ANY($1::uuid[])` over a whole workspace's agents — makes the planner
// estimate a proportional share of the global table and fall back to a Seq Scan
// on agent_task_queue. A single-key equality is the only form whose row estimate
// stays small enough to be index-driven regardless of how the workspace's tasks
// are distributed (MUL-5999).
//
// `id > $2 ORDER BY id LIMIT $3` against idx_agent_task_queue_agent_id_keyset
// (migration 278) bounds the SCAN, not just the result: each page is an index
// range scan that starts where the previous one stopped. Bounding only the result
// — `ORDER BY id LIMIT n` against the (agent_id, status) index — puts a Sort over
// the agent's entire task set in front of every page, so a busy agent costs
// roughly O(N² / page) for the sweep.
//
// FOR UPDATE is load-bearing for correctness, not throughput. It locks the rows
// this page is about to delete, and under READ COMMITTED PostgreSQL re-checks the
// WHERE clause after acquiring each lock: a task a concurrent committed UPDATE
// has already moved to another owner no longer matches and is skipped, and one we
// did lock cannot be moved away before we delete it.
func (q *Queries) ListTaskIDsByAgentPage(ctx context.Context, arg ListTaskIDsByAgentPageParams) ([]pgtype.UUID, error) {
rows, err := q.db.Query(ctx, listTaskIDsByAgentPage, arg.AgentID, arg.ID, arg.Limit)
if err != nil {
return nil, err
}
defer rows.Close()
items := []pgtype.UUID{}
for rows.Next() {
var id pgtype.UUID
if err := rows.Scan(&id); err != nil {
return nil, err
}
items = append(items, id)
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const listTaskIDsByIssueFirstPage = `-- name: ListTaskIDsByIssueFirstPage :many
SELECT id FROM agent_task_queue
WHERE issue_id = $1
ORDER BY id
LIMIT $2
FOR UPDATE
`
type ListTaskIDsByIssueFirstPageParams struct {
IssueID pgtype.UUID `json:"issue_id"`
Limit int32 `json:"limit"`
}
// First page of the same walk; see ListWorkspaceAgentIDFirstPage for why this is
// a separate query rather than a zero-uuid cursor, and ListTaskIDsByAgentPage for
// the single-key / keyset / FOR UPDATE rationale.
func (q *Queries) ListTaskIDsByIssueFirstPage(ctx context.Context, arg ListTaskIDsByIssueFirstPageParams) ([]pgtype.UUID, error) {
rows, err := q.db.Query(ctx, listTaskIDsByIssueFirstPage, arg.IssueID, arg.Limit)
if err != nil {
return nil, err
}
defer rows.Close()
items := []pgtype.UUID{}
for rows.Next() {
var id pgtype.UUID
if err := rows.Scan(&id); err != nil {
return nil, err
}
items = append(items, id)
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const listTaskIDsByIssuePage = `-- name: ListTaskIDsByIssuePage :many
SELECT id FROM agent_task_queue
WHERE issue_id = $1 AND id > $2
ORDER BY id
LIMIT $3
FOR UPDATE
`
type ListTaskIDsByIssuePageParams struct {
IssueID pgtype.UUID `json:"issue_id"`
ID pgtype.UUID `json:"id"`
Limit int32 `json:"limit"`
}
// Uses idx_agent_task_queue_issue_id_keyset (migration 279). See
// ListTaskIDsByAgentPage for the single-key / keyset / FOR UPDATE rationale.
func (q *Queries) ListTaskIDsByIssuePage(ctx context.Context, arg ListTaskIDsByIssuePageParams) ([]pgtype.UUID, error) {
rows, err := q.db.Query(ctx, listTaskIDsByIssuePage, arg.IssueID, arg.ID, arg.Limit)
if err != nil {
return nil, err
}
defer rows.Close()
items := []pgtype.UUID{}
for rows.Next() {
var id pgtype.UUID
if err := rows.Scan(&id); err != nil {
return nil, err
}
items = append(items, id)
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const listTaskIDsByRuntimeFirstPage = `-- name: ListTaskIDsByRuntimeFirstPage :many
SELECT id FROM agent_task_queue
WHERE runtime_id = $1
ORDER BY id
LIMIT $2
FOR UPDATE
`
type ListTaskIDsByRuntimeFirstPageParams struct {
RuntimeID pgtype.UUID `json:"runtime_id"`
Limit int32 `json:"limit"`
}
// First page of the same walk; see ListWorkspaceAgentIDFirstPage for why this is
// a separate query rather than a zero-uuid cursor, and ListTaskIDsByAgentPage for
// the single-key / keyset / FOR UPDATE rationale.
func (q *Queries) ListTaskIDsByRuntimeFirstPage(ctx context.Context, arg ListTaskIDsByRuntimeFirstPageParams) ([]pgtype.UUID, error) {
rows, err := q.db.Query(ctx, listTaskIDsByRuntimeFirstPage, arg.RuntimeID, arg.Limit)
if err != nil {
return nil, err
}
defer rows.Close()
items := []pgtype.UUID{}
for rows.Next() {
var id pgtype.UUID
if err := rows.Scan(&id); err != nil {
return nil, err
}
items = append(items, id)
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const listTaskIDsByRuntimePage = `-- name: ListTaskIDsByRuntimePage :many
SELECT id FROM agent_task_queue
WHERE runtime_id = $1 AND id > $2
ORDER BY id
LIMIT $3
FOR UPDATE
`
type ListTaskIDsByRuntimePageParams struct {
RuntimeID pgtype.UUID `json:"runtime_id"`
ID pgtype.UUID `json:"id"`
Limit int32 `json:"limit"`
}
// Uses idx_agent_task_queue_runtime_id (migration 273, (runtime_id, id)); before
// it every runtime_id index was partial, so this path had none at all. See
// ListTaskIDsByAgentPage for the single-key / keyset / FOR UPDATE rationale.
func (q *Queries) ListTaskIDsByRuntimePage(ctx context.Context, arg ListTaskIDsByRuntimePageParams) ([]pgtype.UUID, error) {
rows, err := q.db.Query(ctx, listTaskIDsByRuntimePage, arg.RuntimeID, arg.ID, arg.Limit)
if err != nil {
return nil, err
}
defer rows.Close()
items := []pgtype.UUID{}
for rows.Next() {
var id pgtype.UUID
if err := rows.Scan(&id); err != nil {
return nil, err
}
items = append(items, id)
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const listWorkspaceAgentIDFirstPage = `-- name: ListWorkspaceAgentIDFirstPage :many
SELECT id FROM agent
WHERE agent.workspace_id = $1
ORDER BY id
LIMIT $2
`
type ListWorkspaceAgentIDFirstPageParams struct {
WorkspaceID pgtype.UUID `json:"workspace_id"`
Limit int32 `json:"limit"`
}
// First page of the same walk. Split from the keyset query rather than seeded
// with the all-zero uuid, because that value is itself a valid uuid: a row whose
// id happened to be all zeros would be skipped forever by `id > $2`.
func (q *Queries) ListWorkspaceAgentIDFirstPage(ctx context.Context, arg ListWorkspaceAgentIDFirstPageParams) ([]pgtype.UUID, error) {
rows, err := q.db.Query(ctx, listWorkspaceAgentIDFirstPage, arg.WorkspaceID, arg.Limit)
if err != nil {
return nil, err
}
defer rows.Close()
items := []pgtype.UUID{}
for rows.Next() {
var id pgtype.UUID
if err := rows.Scan(&id); err != nil {
return nil, err
}
items = append(items, id)
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const listWorkspaceAgentIDPage = `-- name: ListWorkspaceAgentIDPage :many
SELECT id FROM agent
WHERE agent.workspace_id = $1 AND id > $2
ORDER BY id
LIMIT $3
`
type ListWorkspaceAgentIDPageParams struct {
WorkspaceID pgtype.UUID `json:"workspace_id"`
ID pgtype.UUID `json:"id"`
Limit int32 `json:"limit"`
}
// Owner enumeration in bounded, keyset-paged chunks. The rows are already locked
// by LockWorkspaceTaskOwnerAgents above; this only walks them.
//
// Keyset on id, not on a natural key: the cursor column must be immutable, and
// renaming an agent mid-teardown would otherwise let it sort behind the cursor
// and escape the sweep. Uses idx_agent_workspace_id_keyset (migration 281), which
// exists so this page does not sort the whole owner set.
func (q *Queries) ListWorkspaceAgentIDPage(ctx context.Context, arg ListWorkspaceAgentIDPageParams) ([]pgtype.UUID, error) {
rows, err := q.db.Query(ctx, listWorkspaceAgentIDPage, arg.WorkspaceID, arg.ID, arg.Limit)
if err != nil {
return nil, err
}
defer rows.Close()
items := []pgtype.UUID{}
for rows.Next() {
var id pgtype.UUID
if err := rows.Scan(&id); err != nil {
return nil, err
}
items = append(items, id)
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const listWorkspaceIssueIDFirstPage = `-- name: ListWorkspaceIssueIDFirstPage :many
SELECT id FROM issue
WHERE issue.workspace_id = $1
ORDER BY id
LIMIT $2
`
type ListWorkspaceIssueIDFirstPageParams struct {
WorkspaceID pgtype.UUID `json:"workspace_id"`
Limit int32 `json:"limit"`
}
// First page of the same walk. Split from the keyset query rather than seeded
// with the all-zero uuid, because that value is itself a valid uuid: a row whose
// id happened to be all zeros would be skipped forever by `id > $2`.
func (q *Queries) ListWorkspaceIssueIDFirstPage(ctx context.Context, arg ListWorkspaceIssueIDFirstPageParams) ([]pgtype.UUID, error) {
rows, err := q.db.Query(ctx, listWorkspaceIssueIDFirstPage, arg.WorkspaceID, arg.Limit)
if err != nil {
return nil, err
}
defer rows.Close()
items := []pgtype.UUID{}
for rows.Next() {
var id pgtype.UUID
if err := rows.Scan(&id); err != nil {
return nil, err
}
items = append(items, id)
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const listWorkspaceIssueIDPage = `-- name: ListWorkspaceIssueIDPage :many
SELECT id FROM issue
WHERE issue.workspace_id = $1 AND id > $2
ORDER BY id
LIMIT $3
`
type ListWorkspaceIssueIDPageParams struct {
WorkspaceID pgtype.UUID `json:"workspace_id"`
ID pgtype.UUID `json:"id"`
Limit int32 `json:"limit"`
}
// Uses idx_issue_workspace_id_keyset (migration 282).
func (q *Queries) ListWorkspaceIssueIDPage(ctx context.Context, arg ListWorkspaceIssueIDPageParams) ([]pgtype.UUID, error) {
rows, err := q.db.Query(ctx, listWorkspaceIssueIDPage, arg.WorkspaceID, arg.ID, arg.Limit)
if err != nil {
return nil, err
}
defer rows.Close()
items := []pgtype.UUID{}
for rows.Next() {
var id pgtype.UUID
if err := rows.Scan(&id); err != nil {
return nil, err
}
items = append(items, id)
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const listWorkspaceRuntimeIDFirstPage = `-- name: ListWorkspaceRuntimeIDFirstPage :many
SELECT id FROM agent_runtime
WHERE agent_runtime.workspace_id = $1
ORDER BY id
LIMIT $2
`
type ListWorkspaceRuntimeIDFirstPageParams struct {
WorkspaceID pgtype.UUID `json:"workspace_id"`
Limit int32 `json:"limit"`
}
// First page of the same walk. Split from the keyset query rather than seeded
// with the all-zero uuid, because that value is itself a valid uuid: a row whose
// id happened to be all zeros would be skipped forever by `id > $2`.
func (q *Queries) ListWorkspaceRuntimeIDFirstPage(ctx context.Context, arg ListWorkspaceRuntimeIDFirstPageParams) ([]pgtype.UUID, error) {
rows, err := q.db.Query(ctx, listWorkspaceRuntimeIDFirstPage, arg.WorkspaceID, arg.Limit)
if err != nil {
return nil, err
}
defer rows.Close()
items := []pgtype.UUID{}
for rows.Next() {
var id pgtype.UUID
if err := rows.Scan(&id); err != nil {
return nil, err
}
items = append(items, id)
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const listWorkspaceRuntimeIDPage = `-- name: ListWorkspaceRuntimeIDPage :many
SELECT id FROM agent_runtime
WHERE agent_runtime.workspace_id = $1 AND id > $2
ORDER BY id
LIMIT $3
`
type ListWorkspaceRuntimeIDPageParams struct {
WorkspaceID pgtype.UUID `json:"workspace_id"`
ID pgtype.UUID `json:"id"`
Limit int32 `json:"limit"`
}
// Uses idx_agent_runtime_workspace_id_keyset (migration 283).
func (q *Queries) ListWorkspaceRuntimeIDPage(ctx context.Context, arg ListWorkspaceRuntimeIDPageParams) ([]pgtype.UUID, error) {
rows, err := q.db.Query(ctx, listWorkspaceRuntimeIDPage, arg.WorkspaceID, arg.ID, arg.Limit)
if err != nil {
return nil, err
}
defer rows.Close()
items := []pgtype.UUID{}
for rows.Next() {
var id pgtype.UUID
if err := rows.Scan(&id); err != nil {
return nil, err
}
items = append(items, id)
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const lockTaskUsageRollupForWorkspaceDelete = `-- name: LockTaskUsageRollupForWorkspaceDelete :exec
SELECT pg_advisory_xact_lock(4246)
`
// Advisory lock 4246 is the hourly rollup family's key: the rollup function
// takes it per transaction (migration 272) and the backfill commands take it
// per session. An in-flight rollup finishes first, then no new rollup can
// write the workspace's aggregates until this delete commits. The caller
// bounds this wait with SET LOCAL lock_timeout — batch jobs hold 4246 for
// minutes, and an unbounded wait here hangs the delete request (MUL-5983).
func (q *Queries) LockTaskUsageRollupForWorkspaceDelete(ctx context.Context) error {
_, err := q.db.Exec(ctx, lockTaskUsageRollupForWorkspaceDelete)
return err
}
const lockWorkspaceTaskOwnerAgents = `-- name: LockWorkspaceTaskOwnerAgents :exec
SELECT 1 FROM agent WHERE agent.workspace_id = $1 FOR UPDATE
`
// The three LockWorkspaceTaskOwner* statements are a write fence, not a lookup —
// they deliberately return nothing, so a workspace with a huge owner set costs
// the API process no memory at all.
//
// agent_task_queue has no workspace_id, so a task becomes this workspace's
// problem through agent_id, issue_id or runtime_id — and inserting such a task
// requires FOR KEY SHARE on the referenced owner row, which conflicts with
// FOR UPDATE. Holding these locks for the rest of the teardown transaction
// therefore blocks any enqueue (or reassignment) that would add a task to this
// workspace after we have swept it, which row locks on the tasks alone cannot
// do. Same technique the handler already uses on the workspace row to keep
// CreateChatSession out of the delete window.
//
// This fence is defence in depth, not the only guarantee: deleteWorkspaceTasks
// re-verifies every owner after sweeping it and fails closed if tasks keep
// appearing, so teardown does not silently leak rows even if the fence is ever
// weakened (for example by removing the legacy FKs this one leans on).
func (q *Queries) LockWorkspaceTaskOwnerAgents(ctx context.Context, workspaceID pgtype.UUID) error {
_, err := q.db.Exec(ctx, lockWorkspaceTaskOwnerAgents, workspaceID)
return err
}
const lockWorkspaceTaskOwnerIssues = `-- name: LockWorkspaceTaskOwnerIssues :exec
SELECT 1 FROM issue WHERE issue.workspace_id = $1 FOR UPDATE
`
func (q *Queries) LockWorkspaceTaskOwnerIssues(ctx context.Context, workspaceID pgtype.UUID) error {
_, err := q.db.Exec(ctx, lockWorkspaceTaskOwnerIssues, workspaceID)
return err
}
const lockWorkspaceTaskOwnerRuntimes = `-- name: LockWorkspaceTaskOwnerRuntimes :exec
SELECT 1 FROM agent_runtime WHERE agent_runtime.workspace_id = $1 FOR UPDATE
`
func (q *Queries) LockWorkspaceTaskOwnerRuntimes(ctx context.Context, workspaceID pgtype.UUID) error {
_, err := q.db.Exec(ctx, lockWorkspaceTaskOwnerRuntimes, workspaceID)
return err
}
const prepareWorkspaceDeletionLinks = `-- name: PrepareWorkspaceDeletionLinks :exec
WITH
detached_comments AS (
UPDATE comment
SET parent_id = NULL
WHERE comment.workspace_id = $1
AND parent_id IS NOT NULL
),
detached_issues AS (
UPDATE issue
SET parent_issue_id = NULL
WHERE issue.workspace_id = $1
AND parent_issue_id IS NOT NULL
)
UPDATE webhook_delivery
SET replayed_from_delivery_id = NULL
WHERE webhook_delivery.workspace_id = $1
AND replayed_from_delivery_id IS NOT NULL
`
// Break self-references and the task/run cycle explicitly before deleting
// either side. This keeps their ordering in the application-owned graph
// instead of relying on ON DELETE SET NULL actions.
//
// The task half of this step now lives in DetachTaskBatchReferences, which runs
// once per bounded batch instead of once for a whole workspace's task set.
func (q *Queries) PrepareWorkspaceDeletionLinks(ctx context.Context, workspaceID pgtype.UUID) error {
_, err := q.db.Exec(ctx, prepareWorkspaceDeletionLinks, workspaceID)
return err
}
const setWorkspaceTeardownMode = `-- name: SetWorkspaceTeardownMode :exec
SELECT set_config('multica.workspace_teardown', 'on', true)
`
// Workspace deletion is application-owned. These statements form a fixed,
// bottom-up deletion plan; the legacy foreign keys remain only as a
// compatibility safety net during the expand phase.
// The setting is transaction-local. Migration 242 makes only the three DELETE
// dirty triggers skip their per-row work while this flag is on.
func (q *Queries) SetWorkspaceTeardownMode(ctx context.Context) error {
_, err := q.db.Exec(ctx, setWorkspaceTeardownMode)
return err
}