mirror of
https://gh-proxy.org/https://github.com/klinsmaya/multica.git
synced 2026-11-09 01:46:46 +08:00
* feat: make workspace MCP servers an explicit per-agent assignment #6961 shipped workspace MCP as a shared document every agent inherited automatically. That is the wrong shape: a workspace MCP server should behave like a workspace skill — a library entry that reaches an agent only when someone gives it to that agent, with a per-agent on/off toggle. Creating one now gives it to nobody, so nothing propagates implicitly. Model (mirrors `skill` + `agent_skill`): - `workspace_mcp_server` — the library, keyed by id so renaming is safe - `agent_mcp_server` — the assignment, carrying its own `enabled` toggle - the agent's own `mcp_config` stays what it was: servers private to it At claim time the effective set is runtime servers < (assigned AND enabled workspace servers + the agent's own), with the agent's own entry winning a name collision. 318 drops `workspace.mcp_config`: leaving it would keep a second, silently inherited source alive. No data migration — converting each workspace document into library entries would ASSIGN them to every agent, which is the behaviour being removed, and the column shipped in the same unreleased cycle. The write-only secret contract, the legacy-container normalization, and the transport guards from the previous round are all preserved; the resolver is re-pointed at the assignment list rather than a workspace document. UI: workspace Settings → MCP is now a library (adding says so, and renaming is allowed because assignments key off the id); the agent's MCP tab replaces the auto "Inherited from workspace" list with the servers actually assigned to it, a picker to add more from the library, and a per-assignment toggle. Co-authored-by: multica-agent <[email protected]> * fix: fence the MCP writes, stop laundering unknown transports, gate on canEdit Three findings from review: 1. Neither new table carries a foreign key (repo rule), so nothing in the database catches a write landing after the row it depends on is gone. Create and assign now run in a transaction that joins the workspace teardown fence (#5219), and assign additionally holds the target server FOR SHARE while DeleteWorkspaceMcpServer holds it FOR UPDATE — so an assignment either lands before the delete and is swept, or waits and finds the server gone. Driven by two two-transaction race tests, verified to fail without the locks. 2. `mcpTransportOf` classified `{"type":"websocket","url":"wss://…"}` as http, because an unrecognised type fell through to the url check. That told the settings UI the guided form could edit the entry, and saving from it rewrites the protocol to `type: "http"` — the failure the client-side guard was added to prevent, reintroduced from the server. An explicit type is now returned verbatim; inference from command / url happens only when none was declared, which is lossless. 3. The agent MCP tab showed the picker, toggles, and remove button to members who cannot edit the agent, where every click 403s. It now takes `canEdit` like SkillsTab and hides the write affordances, keeping the inventory readable. 318 keeps dropping `workspace.mcp_config` with no data migration and no expand-contract window, now recorded as an explicit maintainer decision (Bohan, 2026-08-14): 314 merged in this same cycle and was never deployed, so there is no stored document to preserve and no old binary reading the column. The comment spells out what this file would have to become if that ever stops holding. Co-authored-by: multica-agent <[email protected]> --------- Co-authored-by: Bohan-J <[email protected]> Co-authored-by: multica-agent <[email protected]>
467 lines
15 KiB
Go
467 lines
15 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, attribution_fail_closed
|
|
`
|
|
|
|
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,
|
|
&i.AttributionFailClosed,
|
|
)
|
|
return i, err
|
|
}
|
|
|
|
const deleteWorkspace = `-- name: DeleteWorkspace :exec
|
|
WITH ws_installations AS (
|
|
SELECT id FROM channel_installation WHERE workspace_id = $1
|
|
),
|
|
ws_agents AS (
|
|
SELECT id FROM agent WHERE workspace_id = $1
|
|
),
|
|
ws_skills AS (
|
|
SELECT id FROM skill WHERE workspace_id = $1
|
|
),
|
|
cleared_agent_label_assignments AS (
|
|
DELETE FROM agent_to_label WHERE agent_id IN (SELECT id FROM ws_agents)
|
|
),
|
|
cleared_skill_label_assignments AS (
|
|
DELETE FROM skill_to_label WHERE skill_id IN (SELECT id FROM ws_skills)
|
|
),
|
|
cleared_chat_sessions AS (
|
|
DELETE FROM channel_chat_session_binding WHERE installation_id IN (SELECT id FROM ws_installations)
|
|
RETURNING chat_session_id
|
|
),
|
|
cleared_dingtalk_group_routes AS (
|
|
DELETE FROM dingtalk_group_route WHERE workspace_id = $1
|
|
),
|
|
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_draft_restores AS (
|
|
-- chat_draft_restore is keyed by chat_session_id with no FK (MUL-3515) and has
|
|
-- no reaper, while its chat_session rows cascade away with the workspace. Reach
|
|
-- them directly through chat_session (unlike the cards above, this is not
|
|
-- limited to channel-bound sessions) or every pending restore — each holding a
|
|
-- user's prompt text — would outlive the workspace permanently (#5219).
|
|
--
|
|
-- This sweep only sees restores committed before the statement's snapshot, so
|
|
-- the caller must already hold LockChatSessionsByWorkspace: that lock is what
|
|
-- keeps FinalizeDeferredCancelledChat from inserting one behind it.
|
|
DELETE FROM chat_draft_restore
|
|
WHERE chat_session_id IN (SELECT id FROM chat_session WHERE workspace_id = $1)
|
|
),
|
|
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
|
|
),
|
|
cleared_issue_properties AS (
|
|
DELETE FROM issue_property WHERE workspace_id = $1
|
|
),
|
|
cleared_quick_actions AS (
|
|
DELETE FROM quick_action WHERE workspace_id = $1
|
|
),
|
|
ws_mcp_servers AS (
|
|
SELECT id FROM workspace_mcp_server WHERE workspace_id = $1
|
|
),
|
|
cleared_agent_mcp_bindings AS (
|
|
-- agent_mcp_server carries no FK in either direction, so sweep it from
|
|
-- both sides: the workspace's own servers, and any binding held by an
|
|
-- agent that is about to be removed with the workspace.
|
|
DELETE FROM agent_mcp_server
|
|
WHERE server_id IN (SELECT id FROM ws_mcp_servers)
|
|
OR agent_id IN (SELECT id FROM ws_agents)
|
|
),
|
|
cleared_workspace_mcp_servers AS (
|
|
DELETE FROM workspace_mcp_server WHERE workspace_id = $1
|
|
),
|
|
deleted_pending_check_suites AS (
|
|
DELETE FROM github_pending_check_suite WHERE workspace_id = $1
|
|
),
|
|
ws_github_prs AS (
|
|
SELECT id FROM github_pull_request WHERE workspace_id = $1
|
|
),
|
|
cleared_github_pr_check_runs AS (
|
|
-- github_pull_request_check_run intentionally has no FK. Remove its rows
|
|
-- before the workspace delete cascades away the parent PR mirrors.
|
|
DELETE FROM github_pull_request_check_run
|
|
WHERE pr_id IN (SELECT id FROM ws_github_prs)
|
|
),
|
|
ws_vcs_prs AS (
|
|
SELECT id FROM vcs_pull_request WHERE workspace_id = $1
|
|
),
|
|
ws_vcs_connections AS (
|
|
SELECT id FROM vcs_connection WHERE workspace_id = $1
|
|
),
|
|
cleared_vcs_pr_links AS (
|
|
DELETE FROM issue_vcs_pull_request
|
|
WHERE pull_request_id IN (SELECT id FROM ws_vcs_prs)
|
|
),
|
|
cleared_vcs_commit_statuses AS (
|
|
DELETE FROM vcs_commit_status
|
|
WHERE connection_id IN (SELECT id FROM ws_vcs_connections)
|
|
),
|
|
cleared_vcs_prs AS (
|
|
DELETE FROM vcs_pull_request WHERE workspace_id = $1
|
|
),
|
|
cleared_vcs_connections AS (
|
|
DELETE FROM vcs_connection WHERE workspace_id = $1
|
|
),
|
|
cleared_client_usage_workspace AS (
|
|
UPDATE client_usage_daily SET workspace_id = NULL WHERE workspace_id = $1
|
|
)
|
|
DELETE FROM workspace WHERE workspace.id = $1
|
|
`
|
|
|
|
// The channel_* tables (MUL-3515 §4), resource-label junctions, custom issue
|
|
// property definitions, and quick actions carry NO FK to workspace, so — unlike the CASCADE-backed
|
|
// tables the DELETE below sweeps — they are not cleaned up implicitly. Remove
|
|
// their workspace-owned rows here so they commit or roll back atomically with
|
|
// the workspace row.
|
|
// VCS tables (migration 213) carry no FK to workspace, so they are not cascaded
|
|
// away by the DELETE below. Sweep the workspace's connections, mirrored PRs,
|
|
// their issue links, and CI statuses here. issue_vcs_pull_request has no
|
|
// workspace_id, so reach it through the workspace's PRs; vcs_commit_status has
|
|
// none either, so reach it through the workspace's connections.
|
|
func (q *Queries) DeleteWorkspace(ctx context.Context, id pgtype.UUID) error {
|
|
_, err := q.db.Exec(ctx, deleteWorkspace, id)
|
|
return err
|
|
}
|
|
|
|
const getDaemonWorkspace = `-- name: GetDaemonWorkspace :one
|
|
SELECT id, name
|
|
FROM workspace
|
|
WHERE id = $1
|
|
`
|
|
|
|
type GetDaemonWorkspaceRow struct {
|
|
ID pgtype.UUID `json:"id"`
|
|
Name string `json:"name"`
|
|
}
|
|
|
|
// Workspace-scoped daemon tokens do not carry a user ID. This narrow lookup
|
|
// lets them use the same endpoint without widening their token scope.
|
|
func (q *Queries) GetDaemonWorkspace(ctx context.Context, id pgtype.UUID) (GetDaemonWorkspaceRow, error) {
|
|
row := q.db.QueryRow(ctx, getDaemonWorkspace, id)
|
|
var i GetDaemonWorkspaceRow
|
|
err := row.Scan(&i.ID, &i.Name)
|
|
return i, err
|
|
}
|
|
|
|
const getWorkspace = `-- name: GetWorkspace :one
|
|
SELECT id, name, slug, description, settings, created_at, updated_at, context, repos, issue_prefix, issue_counter, avatar_url, attribution_fail_closed 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,
|
|
&i.AttributionFailClosed,
|
|
)
|
|
return i, err
|
|
}
|
|
|
|
const getWorkspaceAttributionFailClosed = `-- name: GetWorkspaceAttributionFailClosed :one
|
|
SELECT attribution_fail_closed FROM workspace
|
|
WHERE id = $1
|
|
`
|
|
|
|
// Lean read of the fail-closed attribution policy for the enqueue hot path
|
|
// (MUL-4302 §3.5), avoiding a full workspace-row fetch.
|
|
func (q *Queries) GetWorkspaceAttributionFailClosed(ctx context.Context, id pgtype.UUID) (bool, error) {
|
|
row := q.db.QueryRow(ctx, getWorkspaceAttributionFailClosed, id)
|
|
var attribution_fail_closed bool
|
|
err := row.Scan(&attribution_fail_closed)
|
|
return attribution_fail_closed, err
|
|
}
|
|
|
|
const getWorkspaceBySlug = `-- name: GetWorkspaceBySlug :one
|
|
SELECT id, name, slug, description, settings, created_at, updated_at, context, repos, issue_prefix, issue_counter, avatar_url, attribution_fail_closed 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,
|
|
&i.AttributionFailClosed,
|
|
)
|
|
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 listDaemonWorkspaces = `-- name: ListDaemonWorkspaces :many
|
|
SELECT w.id, w.name
|
|
FROM member m
|
|
JOIN workspace w ON w.id = m.workspace_id
|
|
WHERE m.user_id = $1
|
|
ORDER BY w.id ASC
|
|
`
|
|
|
|
type ListDaemonWorkspacesRow struct {
|
|
ID pgtype.UUID `json:"id"`
|
|
Name string `json:"name"`
|
|
}
|
|
|
|
// Daemons only need the membership set and display name to discover which
|
|
// workspaces should have local runtimes. Keep this projection intentionally
|
|
// narrow so the periodic consistency check never reads UI-only JSON/text
|
|
// columns such as settings, repos, or context.
|
|
func (q *Queries) ListDaemonWorkspaces(ctx context.Context, userID pgtype.UUID) ([]ListDaemonWorkspacesRow, error) {
|
|
rows, err := q.db.Query(ctx, listDaemonWorkspaces, userID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer rows.Close()
|
|
items := []ListDaemonWorkspacesRow{}
|
|
for rows.Next() {
|
|
var i ListDaemonWorkspacesRow
|
|
if err := rows.Scan(&i.ID, &i.Name); err != nil {
|
|
return nil, err
|
|
}
|
|
items = append(items, i)
|
|
}
|
|
if err := rows.Err(); err != nil {
|
|
return nil, err
|
|
}
|
|
return items, nil
|
|
}
|
|
|
|
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, w.attribution_fail_closed
|
|
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,
|
|
&i.AttributionFailClosed,
|
|
); err != nil {
|
|
return nil, err
|
|
}
|
|
items = append(items, i)
|
|
}
|
|
if err := rows.Err(); err != nil {
|
|
return nil, err
|
|
}
|
|
return items, nil
|
|
}
|
|
|
|
const lockWorkspaceForChatSessionCreate = `-- name: LockWorkspaceForChatSessionCreate :one
|
|
SELECT id FROM workspace WHERE id = $1 FOR KEY SHARE
|
|
`
|
|
|
|
// The creator half of the workspace delete/create protocol (#5219). Every
|
|
// production path that inserts a chat_session takes this FOR KEY SHARE lock on the
|
|
// parent workspace row, inside its transaction, before CreateChatSession. It
|
|
// conflicts with DeleteWorkspace's FOR UPDATE (so a create is blocked while a
|
|
// delete is in progress, and vice versa) but not with other creators (FOR KEY
|
|
// SHARE locks share), so concurrent session creation stays unserialized. This
|
|
// makes the mutual exclusion explicit rather than leaning on the workspace FK's
|
|
// implicit FOR KEY SHARE, which would vanish if that FK is dropped.
|
|
func (q *Queries) LockWorkspaceForChatSessionCreate(ctx context.Context, id pgtype.UUID) (pgtype.UUID, error) {
|
|
row := q.db.QueryRow(ctx, lockWorkspaceForChatSessionCreate, id)
|
|
var id_2 pgtype.UUID
|
|
err := row.Scan(&id_2)
|
|
return id_2, err
|
|
}
|
|
|
|
const lockWorkspaceForDelete = `-- name: LockWorkspaceForDelete :one
|
|
SELECT id FROM workspace WHERE id = $1 FOR UPDATE
|
|
`
|
|
|
|
// Taken first by DeleteWorkspace, before it enumerates the workspace's chat
|
|
// sessions. LockChatSessionsByWorkspace only covers sessions that exist when it
|
|
// runs; a CreateChatSession committing during the delete window would add one
|
|
// the lock set never saw, and a finalizer could then insert a restore for it
|
|
// after the sweep's snapshot — orphaning the prompt (#5219).
|
|
//
|
|
// The delete window is held closed against new sessions by an EXPLICIT protocol,
|
|
// not the chat_session.workspace_id FK: every session creator takes
|
|
// LockWorkspaceForChatSessionCreate (FOR KEY SHARE) on this row first, and this
|
|
// FOR UPDATE conflicts with it. Keeping the bar in the app layer means it does
|
|
// not silently break if that FK is ever dropped (the codebase is moving FK
|
|
// relationships into the application layer, MUL-3515). Lock order is
|
|
// workspace -> chat_session -> agent_task_queue; the finalizer never touches
|
|
// workspace, so this cannot deadlock against it.
|
|
func (q *Queries) LockWorkspaceForDelete(ctx context.Context, id pgtype.UUID) (pgtype.UUID, error) {
|
|
row := q.db.QueryRow(ctx, lockWorkspaceForDelete, id)
|
|
var id_2 pgtype.UUID
|
|
err := row.Scan(&id_2)
|
|
return id_2, err
|
|
}
|
|
|
|
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, attribution_fail_closed
|
|
`
|
|
|
|
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,
|
|
&i.AttributionFailClosed,
|
|
)
|
|
return i, err
|
|
}
|