mirror of
https://github.com/multica-ai/multica.git
synced 2026-07-12 20:29:10 +02:00
* 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>
297 lines
8.2 KiB
Go
297 lines
8.2 KiB
Go
// Code generated by sqlc. DO NOT EDIT.
|
|
// versions:
|
|
// sqlc v1.31.1
|
|
// source: workspace.sql
|
|
|
|
package db
|
|
|
|
import (
|
|
"context"
|
|
|
|
"github.com/jackc/pgx/v5/pgtype"
|
|
)
|
|
|
|
const createWorkspace = `-- name: CreateWorkspace :one
|
|
INSERT INTO workspace (name, slug, description, context, issue_prefix)
|
|
VALUES ($1, $2, $3, $4, $5)
|
|
RETURNING id, name, slug, description, settings, created_at, updated_at, context, repos, issue_prefix, issue_counter, avatar_url
|
|
`
|
|
|
|
type CreateWorkspaceParams struct {
|
|
Name string `json:"name"`
|
|
Slug string `json:"slug"`
|
|
Description pgtype.Text `json:"description"`
|
|
Context pgtype.Text `json:"context"`
|
|
IssuePrefix string `json:"issue_prefix"`
|
|
}
|
|
|
|
func (q *Queries) CreateWorkspace(ctx context.Context, arg CreateWorkspaceParams) (Workspace, error) {
|
|
row := q.db.QueryRow(ctx, createWorkspace,
|
|
arg.Name,
|
|
arg.Slug,
|
|
arg.Description,
|
|
arg.Context,
|
|
arg.IssuePrefix,
|
|
)
|
|
var i Workspace
|
|
err := row.Scan(
|
|
&i.ID,
|
|
&i.Name,
|
|
&i.Slug,
|
|
&i.Description,
|
|
&i.Settings,
|
|
&i.CreatedAt,
|
|
&i.UpdatedAt,
|
|
&i.Context,
|
|
&i.Repos,
|
|
&i.IssuePrefix,
|
|
&i.IssueCounter,
|
|
&i.AvatarUrl,
|
|
)
|
|
return i, err
|
|
}
|
|
|
|
const deleteWorkspace = `-- name: DeleteWorkspace :exec
|
|
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
|
|
`
|
|
|
|
// 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.
|
|
func (q *Queries) DeleteWorkspace(ctx context.Context, id pgtype.UUID) error {
|
|
_, err := q.db.Exec(ctx, deleteWorkspace, id)
|
|
return err
|
|
}
|
|
|
|
const getWorkspace = `-- name: GetWorkspace :one
|
|
SELECT id, name, slug, description, settings, created_at, updated_at, context, repos, issue_prefix, issue_counter, avatar_url FROM workspace
|
|
WHERE id = $1
|
|
`
|
|
|
|
func (q *Queries) GetWorkspace(ctx context.Context, id pgtype.UUID) (Workspace, error) {
|
|
row := q.db.QueryRow(ctx, getWorkspace, id)
|
|
var i Workspace
|
|
err := row.Scan(
|
|
&i.ID,
|
|
&i.Name,
|
|
&i.Slug,
|
|
&i.Description,
|
|
&i.Settings,
|
|
&i.CreatedAt,
|
|
&i.UpdatedAt,
|
|
&i.Context,
|
|
&i.Repos,
|
|
&i.IssuePrefix,
|
|
&i.IssueCounter,
|
|
&i.AvatarUrl,
|
|
)
|
|
return i, err
|
|
}
|
|
|
|
const getWorkspaceBySlug = `-- name: GetWorkspaceBySlug :one
|
|
SELECT id, name, slug, description, settings, created_at, updated_at, context, repos, issue_prefix, issue_counter, avatar_url FROM workspace
|
|
WHERE slug = $1
|
|
`
|
|
|
|
func (q *Queries) GetWorkspaceBySlug(ctx context.Context, slug string) (Workspace, error) {
|
|
row := q.db.QueryRow(ctx, getWorkspaceBySlug, slug)
|
|
var i Workspace
|
|
err := row.Scan(
|
|
&i.ID,
|
|
&i.Name,
|
|
&i.Slug,
|
|
&i.Description,
|
|
&i.Settings,
|
|
&i.CreatedAt,
|
|
&i.UpdatedAt,
|
|
&i.Context,
|
|
&i.Repos,
|
|
&i.IssuePrefix,
|
|
&i.IssueCounter,
|
|
&i.AvatarUrl,
|
|
)
|
|
return i, err
|
|
}
|
|
|
|
const incrementIssueCounter = `-- name: IncrementIssueCounter :one
|
|
UPDATE workspace SET issue_counter = issue_counter + 1
|
|
WHERE id = $1
|
|
RETURNING issue_counter
|
|
`
|
|
|
|
func (q *Queries) IncrementIssueCounter(ctx context.Context, id pgtype.UUID) (int32, error) {
|
|
row := q.db.QueryRow(ctx, incrementIssueCounter, id)
|
|
var issue_counter int32
|
|
err := row.Scan(&issue_counter)
|
|
return issue_counter, err
|
|
}
|
|
|
|
const listWorkspaces = `-- 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
|
|
`
|
|
|
|
func (q *Queries) ListWorkspaces(ctx context.Context, userID pgtype.UUID) ([]Workspace, error) {
|
|
rows, err := q.db.Query(ctx, listWorkspaces, userID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer rows.Close()
|
|
items := []Workspace{}
|
|
for rows.Next() {
|
|
var i Workspace
|
|
if err := rows.Scan(
|
|
&i.ID,
|
|
&i.Name,
|
|
&i.Slug,
|
|
&i.Description,
|
|
&i.Settings,
|
|
&i.CreatedAt,
|
|
&i.UpdatedAt,
|
|
&i.Context,
|
|
&i.Repos,
|
|
&i.IssuePrefix,
|
|
&i.IssueCounter,
|
|
&i.AvatarUrl,
|
|
); err != nil {
|
|
return nil, err
|
|
}
|
|
items = append(items, i)
|
|
}
|
|
if err := rows.Err(); err != nil {
|
|
return nil, err
|
|
}
|
|
return items, nil
|
|
}
|
|
|
|
const listWorkspacesWithRepos = `-- name: ListWorkspacesWithRepos :many
|
|
SELECT id, repos FROM workspace
|
|
WHERE repos IS NOT NULL AND repos <> '[]'::jsonb
|
|
ORDER BY id
|
|
`
|
|
|
|
type ListWorkspacesWithReposRow struct {
|
|
ID pgtype.UUID `json:"id"`
|
|
Repos []byte `json:"repos"`
|
|
}
|
|
|
|
// 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.
|
|
func (q *Queries) ListWorkspacesWithRepos(ctx context.Context) ([]ListWorkspacesWithReposRow, error) {
|
|
rows, err := q.db.Query(ctx, listWorkspacesWithRepos)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer rows.Close()
|
|
items := []ListWorkspacesWithReposRow{}
|
|
for rows.Next() {
|
|
var i ListWorkspacesWithReposRow
|
|
if err := rows.Scan(&i.ID, &i.Repos); err != nil {
|
|
return nil, err
|
|
}
|
|
items = append(items, i)
|
|
}
|
|
if err := rows.Err(); err != nil {
|
|
return nil, err
|
|
}
|
|
return items, nil
|
|
}
|
|
|
|
const updateWorkspace = `-- name: UpdateWorkspace :one
|
|
UPDATE workspace SET
|
|
name = COALESCE($2, name),
|
|
description = COALESCE($3, description),
|
|
context = COALESCE($4, context),
|
|
settings = COALESCE($5, settings),
|
|
repos = COALESCE($6, repos),
|
|
issue_prefix = COALESCE($7, issue_prefix),
|
|
avatar_url = COALESCE($8, avatar_url),
|
|
updated_at = now()
|
|
WHERE id = $1
|
|
RETURNING id, name, slug, description, settings, created_at, updated_at, context, repos, issue_prefix, issue_counter, avatar_url
|
|
`
|
|
|
|
type UpdateWorkspaceParams struct {
|
|
ID pgtype.UUID `json:"id"`
|
|
Name pgtype.Text `json:"name"`
|
|
Description pgtype.Text `json:"description"`
|
|
Context pgtype.Text `json:"context"`
|
|
Settings []byte `json:"settings"`
|
|
Repos []byte `json:"repos"`
|
|
IssuePrefix pgtype.Text `json:"issue_prefix"`
|
|
AvatarUrl pgtype.Text `json:"avatar_url"`
|
|
}
|
|
|
|
func (q *Queries) UpdateWorkspace(ctx context.Context, arg UpdateWorkspaceParams) (Workspace, error) {
|
|
row := q.db.QueryRow(ctx, updateWorkspace,
|
|
arg.ID,
|
|
arg.Name,
|
|
arg.Description,
|
|
arg.Context,
|
|
arg.Settings,
|
|
arg.Repos,
|
|
arg.IssuePrefix,
|
|
arg.AvatarUrl,
|
|
)
|
|
var i Workspace
|
|
err := row.Scan(
|
|
&i.ID,
|
|
&i.Name,
|
|
&i.Slug,
|
|
&i.Description,
|
|
&i.Settings,
|
|
&i.CreatedAt,
|
|
&i.UpdatedAt,
|
|
&i.Context,
|
|
&i.Repos,
|
|
&i.IssuePrefix,
|
|
&i.IssueCounter,
|
|
&i.AvatarUrl,
|
|
)
|
|
return i, err
|
|
}
|