Files
multica/server/pkg/db/queries/workspace.sql
Bohan Jiang ccacce60a1 fix(channels): auto-reclaim orphaned IM-bot installations + accurate rebind conflict copy (#4810) MUL-3937 (#5103)
* fix(channels): auto-reclaim orphaned bot installations + accurate rebind conflict copy

channel_installation has no FK to workspace/agent (MUL-3515 §4), so deleting a
workspace or hard-deleting an agent left the row behind, occupying the
(channel_type, app_id) routing slot forever — the bot could never be rebound and
the UI had no way to clear it (#4810). The 409 also always blamed "a different
Multica workspace" even when the real owner sat in the same workspace.

Auto-reclaim on delete:
- DeleteWorkspace and the runtime-teardown paths now sweep the workspace's /
  archived agents' channel installations and every dependent row in-tx.
- The shared install path (Feishu + Slack) reclaims a DEAD prior owner — a
  revoked placeholder or an orphan whose workspace/agent is gone — before the
  upsert, healing installations stranded before this fix. A live owner (active
  agent, including an archived one) is left in place, not stolen.

Accurate conflict copy:
- A rebind refused by a LIVE owner now distinguishes same-workspace / another
  agent, an archived agent, and a genuinely different workspace, for both Slack
  (typed sentinels) and Feishu (registration message).

MUL-3937

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

* fix(channels): reclaim cross-workspace revoked bots + sweep card/dedup/audit (#4810)

Address the #5103 review (yyclaw + Steve):

- Reclaim: a REVOKED installation in ANY workspace is now dead (except the
  caller's own row), not just same-workspace. Disconnect never hard-deletes the
  row and there is no release UI, so a cross-workspace revoked row would pin a
  bot's app_id slot forever, with the misleading "connected to another
  workspace" copy resurfacing. A new binder proves control by holding the app
  credentials, so reclaiming is safe. Live ACTIVE owners (incl. archived) are
  still refused.
- Sweep the two dependent tables the cleanups missed, in all three paths
  (reclaim / DeleteWorkspace / runtime teardown): channel_outbound_card_message
  (no reaper, so a permanent orphan otherwise) and channel_inbound_message_dedup
  (PurgeChannelInboundDedup has no caller).
- Audit rows: PURGE on the hard-delete paths instead of detaching them into
  permanently unattributable NULL rows; keep DETACH on reclaim, where the
  workspace survives and the row stays useful for triage.
- Tests: flip cross-ws revoked to reclaimed + add cross-ws active preserved;
  extend the reclaim and both delete-path cleanup tests for card/dedup and the
  audit purge/detach split; assert the channel sweep on the DeleteRuntimeProfile
  entry point.

MUL-3937

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-09 15:07:11 +08:00

92 lines
3.6 KiB
SQL

-- name: ListWorkspaces :many
SELECT w.id, w.name, w.slug, w.description, w.settings,
w.created_at, w.updated_at, w.context, w.repos,
w.issue_prefix, w.issue_counter, w.avatar_url
FROM member m
JOIN workspace w ON w.id = m.workspace_id
WHERE m.user_id = $1
ORDER BY w.created_at ASC;
-- name: GetWorkspace :one
SELECT * FROM workspace
WHERE id = $1;
-- name: GetWorkspaceBySlug :one
SELECT * FROM workspace
WHERE slug = $1;
-- name: CreateWorkspace :one
INSERT INTO workspace (name, slug, description, context, issue_prefix)
VALUES ($1, $2, $3, $4, $5)
RETURNING *;
-- name: UpdateWorkspace :one
UPDATE workspace SET
name = COALESCE(sqlc.narg('name'), name),
description = COALESCE(sqlc.narg('description'), description),
context = COALESCE(sqlc.narg('context'), context),
settings = COALESCE(sqlc.narg('settings'), settings),
repos = COALESCE(sqlc.narg('repos'), repos),
issue_prefix = COALESCE(sqlc.narg('issue_prefix'), issue_prefix),
avatar_url = COALESCE(sqlc.narg('avatar_url'), avatar_url),
updated_at = now()
WHERE id = $1
RETURNING *;
-- name: ListWorkspacesWithRepos :many
-- Workspaces with a non-empty repo registry, to route a webhook to the repo's
-- owning workspace. ORDER BY id keeps the resolver's tie-break stable on replay.
SELECT id, repos FROM workspace
WHERE repos IS NOT NULL AND repos <> '[]'::jsonb
ORDER BY id;
-- name: IncrementIssueCounter :one
UPDATE workspace SET issue_counter = issue_counter + 1
WHERE id = $1
RETURNING issue_counter;
-- name: DeleteWorkspace :exec
-- The channel_* tables carry NO FK to workspace (MUL-3515 §4), so — unlike the
-- CASCADE-backed tables the DELETE below sweeps — they are not cleaned up
-- implicitly. Remove this workspace's channel installations, and every dependent
-- row of each, here so a deleted workspace never leaves an orphaned installation
-- occupying its bot's (channel_type, config->>'app_id') routing slot, which would
-- make that bot un-rebindable anywhere until an operator hand-deletes the row
-- (#4810). All in one statement so it commits atomically with the workspace row.
WITH ws_installations AS (
SELECT id FROM channel_installation WHERE workspace_id = $1
),
cleared_chat_sessions AS (
DELETE FROM channel_chat_session_binding WHERE installation_id IN (SELECT id FROM ws_installations)
RETURNING chat_session_id
),
cleared_outbound_cards AS (
-- channel_outbound_card_message is keyed by chat_session_id (no FK); its own
-- chat_session rows cascade away with the workspace, so reach the cards through
-- the just-removed chat-session bindings, which still carry the id.
DELETE FROM channel_outbound_card_message
WHERE chat_session_id IN (SELECT chat_session_id FROM cleared_chat_sessions)
),
cleared_inbound_dedup AS (
DELETE FROM channel_inbound_message_dedup WHERE installation_id IN (SELECT id FROM ws_installations)
),
cleared_audit AS (
-- Purge, don't detach: the workspace is gone and channel_inbound_audit has no
-- workspace_id and no reaper, so a detached (NULL) row would be permanently
-- unattributable. (Reclaim, where the workspace survives, still detaches.)
DELETE FROM channel_inbound_audit WHERE installation_id IN (SELECT id FROM ws_installations)
),
cleared_user_bindings AS (
DELETE FROM channel_user_binding WHERE workspace_id = $1
),
cleared_binding_tokens AS (
DELETE FROM channel_binding_token WHERE workspace_id = $1
),
cleared_installations AS (
DELETE FROM channel_installation WHERE workspace_id = $1
),
deleted_pending_check_suites AS (
DELETE FROM github_pending_check_suite WHERE workspace_id = $1
)
DELETE FROM workspace WHERE workspace.id = $1;