d25d482dc2
CI / Migrate Dev DB (push) Has been skipped
CI / Detect Version (push) Has been cancelled
CI / Migrate DB (push) Has been cancelled
CI / Build Dev ECR (./docker/app.Dockerfile, ECR_APP) (push) Has been cancelled
CI / Build Dev ECR (./docker/db.Dockerfile, ECR_MIGRATIONS) (push) Has been cancelled
CI / Build Dev ECR (./docker/pii.Dockerfile, ECR_PII) (push) Has been cancelled
CI / Build Dev ECR (./docker/realtime.Dockerfile, ECR_REALTIME) (push) Has been cancelled
CI / Deploy Trigger.dev (Dev) (push) Has been cancelled
CI / Build AMD64 (./docker/app.Dockerfile, ECR_APP, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build AMD64 (./docker/db.Dockerfile, ECR_MIGRATIONS, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build AMD64 (./docker/pii.Dockerfile, ECR_PII, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build AMD64 (./docker/realtime.Dockerfile, ECR_REALTIME, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/app.Dockerfile, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/db.Dockerfile, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/pii.Dockerfile, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/realtime.Dockerfile, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Check Docs Changes (push) Has been cancelled
CI / Process Docs (push) Has been cancelled
CI / Create GitHub Release (push) Has been cancelled
CI / Test and Build (push) Has been cancelled
Publish CLI Package / publish-npm (push) Has been cancelled
Publish Python SDK / publish-pypi (push) Has been cancelled
Publish TypeScript SDK / publish-npm (push) Has been cancelled
633 lines
24 KiB
TypeScript
633 lines
24 KiB
TypeScript
import {
|
|
credential,
|
|
customTools,
|
|
document,
|
|
knowledgeBase,
|
|
mcpServers,
|
|
skill,
|
|
userTableDefinitions,
|
|
workflow,
|
|
workflowDeploymentVersion,
|
|
workflowMcpServer,
|
|
workspaceEnvironment,
|
|
workspaceFileFolder,
|
|
workspaceFiles,
|
|
} from '@sim/db/schema'
|
|
import { and, count, eq, exists, inArray, isNull, sql } from 'drizzle-orm'
|
|
import type { ForkCopyableKind } from '@/lib/api/contracts/workspace-fork'
|
|
import type { DbOrTx } from '@/lib/db/types'
|
|
import type { ForkResourceType } from '@/ee/workspace-forking/lib/mapping/mapping-store'
|
|
import type {
|
|
ForkMcpServerMeta,
|
|
ForkRemapKind,
|
|
} from '@/ee/workspace-forking/lib/remap/remap-references'
|
|
|
|
export interface ForkResourceCandidate {
|
|
id: string
|
|
label: string
|
|
providerId?: string
|
|
}
|
|
|
|
export const CANDIDATE_LIMIT = 1000
|
|
|
|
/** The set of env-var keys defined in a workspace (for resolver identity + gating). */
|
|
export async function getWorkspaceEnvKeys(
|
|
executor: DbOrTx,
|
|
workspaceId: string
|
|
): Promise<Set<string>> {
|
|
const [row] = await executor
|
|
.select({ variables: workspaceEnvironment.variables })
|
|
.from(workspaceEnvironment)
|
|
.where(eq(workspaceEnvironment.workspaceId, workspaceId))
|
|
.limit(1)
|
|
const variables = row?.variables
|
|
if (!variables || typeof variables !== 'object') return new Set()
|
|
return new Set(Object.keys(variables as Record<string, unknown>))
|
|
}
|
|
|
|
// Shared `{ id, label }` candidate queries for the content resource kinds that BOTH the
|
|
// mapping-target picker and the fork-copy picker list - one source of the archived/deleted
|
|
// filters so the two pickers can never drift apart, and one optional `ids` filter so the picker
|
|
// path (unfiltered, capped) and the existence/label path (exact ids) share a single definition
|
|
// per kind. When `ids` is given the query is filtered to those exact ids and is NOT capped, so a
|
|
// valid target sitting past the candidate cap is never wrongly dropped. Credentials, env vars
|
|
// (mapping-only), and files-with-folder (copy-only) keep their own helpers below.
|
|
const tableCandidatesQuery = (executor: DbOrTx, workspaceId: string, ids?: string[]) => {
|
|
const query = executor
|
|
.select({ id: userTableDefinitions.id, label: userTableDefinitions.name })
|
|
.from(userTableDefinitions)
|
|
.where(
|
|
and(
|
|
eq(userTableDefinitions.workspaceId, workspaceId),
|
|
isNull(userTableDefinitions.archivedAt),
|
|
ids ? inArray(userTableDefinitions.id, ids) : undefined
|
|
)
|
|
)
|
|
return ids ? query : query.limit(CANDIDATE_LIMIT)
|
|
}
|
|
|
|
const knowledgeBaseCandidatesQuery = (executor: DbOrTx, workspaceId: string, ids?: string[]) => {
|
|
const query = executor
|
|
.select({ id: knowledgeBase.id, label: knowledgeBase.name })
|
|
.from(knowledgeBase)
|
|
.where(
|
|
and(
|
|
eq(knowledgeBase.workspaceId, workspaceId),
|
|
isNull(knowledgeBase.deletedAt),
|
|
ids ? inArray(knowledgeBase.id, ids) : undefined
|
|
)
|
|
)
|
|
return ids ? query : query.limit(CANDIDATE_LIMIT)
|
|
}
|
|
|
|
const customToolCandidatesQuery = (executor: DbOrTx, workspaceId: string, ids?: string[]) => {
|
|
const query = executor
|
|
.select({ id: customTools.id, label: customTools.title })
|
|
.from(customTools)
|
|
.where(
|
|
and(eq(customTools.workspaceId, workspaceId), ids ? inArray(customTools.id, ids) : undefined)
|
|
)
|
|
return ids ? query : query.limit(CANDIDATE_LIMIT)
|
|
}
|
|
|
|
const skillCandidatesQuery = (executor: DbOrTx, workspaceId: string, ids?: string[]) => {
|
|
const query = executor
|
|
.select({ id: skill.id, label: skill.name })
|
|
.from(skill)
|
|
.where(and(eq(skill.workspaceId, workspaceId), ids ? inArray(skill.id, ids) : undefined))
|
|
return ids ? query : query.limit(CANDIDATE_LIMIT)
|
|
}
|
|
|
|
const mcpServerCandidatesQuery = (executor: DbOrTx, workspaceId: string, ids?: string[]) => {
|
|
const query = executor
|
|
.select({ id: mcpServers.id, label: mcpServers.name })
|
|
.from(mcpServers)
|
|
.where(
|
|
and(
|
|
eq(mcpServers.workspaceId, workspaceId),
|
|
isNull(mcpServers.deletedAt),
|
|
ids ? inArray(mcpServers.id, ids) : undefined
|
|
)
|
|
)
|
|
return ids ? query : query.limit(CANDIDATE_LIMIT)
|
|
}
|
|
|
|
// Workspace-file mapping candidates are keyed by STORAGE KEY (not `workspace_files.id`): a
|
|
// `file-upload` reference stores the storage key, so a mapping target must be a key too. Only
|
|
// durable, non-deleted `workspace` files are mappable (chat/copilot uploads are session-scoped).
|
|
// An optional `keys` filter shares this definition between the mapping picker (unfiltered, capped)
|
|
// and the cap-free existence check.
|
|
const fileCandidatesQuery = (executor: DbOrTx, workspaceId: string, keys?: string[]) => {
|
|
const query = executor
|
|
.select({
|
|
id: workspaceFiles.key,
|
|
label: sql<string>`coalesce(${workspaceFiles.displayName}, ${workspaceFiles.originalName})`,
|
|
})
|
|
.from(workspaceFiles)
|
|
.where(
|
|
and(
|
|
eq(workspaceFiles.workspaceId, workspaceId),
|
|
eq(workspaceFiles.context, 'workspace'),
|
|
isNull(workspaceFiles.deletedAt),
|
|
keys ? inArray(workspaceFiles.key, keys) : undefined
|
|
)
|
|
)
|
|
return keys ? query : query.limit(CANDIDATE_LIMIT)
|
|
}
|
|
|
|
// Copyable workspace files WITH their folder grouping (LEFT JOIN gated on a live folder, so a file
|
|
// whose folder was deleted shows ungrouped). Shared by the fork-copy picker (unfiltered, capped)
|
|
// and the promote copyable-label lookup (filtered by exact storage keys, never capped), so the
|
|
// file+folder shape and its filters live in one place. Selects both the row id and the storage
|
|
// key; the copy picker reads `id`, the key-addressed label lookup reads `key`.
|
|
const fileCandidatesWithFolderQuery = (
|
|
executor: DbOrTx,
|
|
workspaceId: string,
|
|
options: { keys?: string[] } = {}
|
|
) => {
|
|
const { keys } = options
|
|
const query = executor
|
|
.select({
|
|
id: workspaceFiles.id,
|
|
key: workspaceFiles.key,
|
|
label: sql<string>`coalesce(${workspaceFiles.displayName}, ${workspaceFiles.originalName})`,
|
|
folderId: workspaceFiles.folderId,
|
|
folderName: workspaceFileFolder.name,
|
|
})
|
|
.from(workspaceFiles)
|
|
.leftJoin(
|
|
workspaceFileFolder,
|
|
and(
|
|
eq(workspaceFiles.folderId, workspaceFileFolder.id),
|
|
isNull(workspaceFileFolder.deletedAt)
|
|
)
|
|
)
|
|
.where(
|
|
and(
|
|
eq(workspaceFiles.workspaceId, workspaceId),
|
|
eq(workspaceFiles.context, 'workspace'),
|
|
isNull(workspaceFiles.deletedAt),
|
|
keys ? inArray(workspaceFiles.key, keys) : undefined
|
|
)
|
|
)
|
|
return keys ? query : query.limit(CANDIDATE_LIMIT)
|
|
}
|
|
|
|
/**
|
|
* List the resources in a workspace that can serve as mapping targets, grouped by
|
|
* remap kind. Used to populate the mapping UI's target pickers and to label the
|
|
* source resources being mapped. `knowledge-document` is intentionally left empty:
|
|
* documents are not a standalone mappable kind - they are dependent fields of their
|
|
* knowledge base, re-picked in the per-KB reconfigure flow (and auto-remapped when
|
|
* their KB is copied). `file` candidates are keyed by storage key.
|
|
*/
|
|
export async function listForkResourceCandidates(
|
|
executor: DbOrTx,
|
|
workspaceId: string
|
|
): Promise<Record<ForkRemapKind, ForkResourceCandidate[]>> {
|
|
const [creds, wsEnvRows, tables, kbs, servers, tools, skills, files] = await Promise.all([
|
|
executor
|
|
.select({
|
|
id: credential.id,
|
|
displayName: credential.displayName,
|
|
providerId: credential.providerId,
|
|
})
|
|
.from(credential)
|
|
// Only real connections are mappable credentials. `env_workspace`/`env_personal`
|
|
// rows live in the same table but are environment variables (surfaced via the
|
|
// 'env-var' kind), so they must never appear as credential targets.
|
|
.where(
|
|
and(
|
|
eq(credential.workspaceId, workspaceId),
|
|
inArray(credential.type, ['oauth', 'service_account'])
|
|
)
|
|
)
|
|
.limit(CANDIDATE_LIMIT),
|
|
executor
|
|
.select({ variables: workspaceEnvironment.variables })
|
|
.from(workspaceEnvironment)
|
|
.where(eq(workspaceEnvironment.workspaceId, workspaceId))
|
|
.limit(1),
|
|
tableCandidatesQuery(executor, workspaceId),
|
|
knowledgeBaseCandidatesQuery(executor, workspaceId),
|
|
mcpServerCandidatesQuery(executor, workspaceId),
|
|
customToolCandidatesQuery(executor, workspaceId),
|
|
skillCandidatesQuery(executor, workspaceId),
|
|
fileCandidatesQuery(executor, workspaceId),
|
|
])
|
|
|
|
const envVariables = wsEnvRows[0]?.variables
|
|
const envKeys =
|
|
envVariables && typeof envVariables === 'object'
|
|
? Object.keys(envVariables as Record<string, unknown>)
|
|
: []
|
|
|
|
return {
|
|
credential: creds.map((c) => ({
|
|
id: c.id,
|
|
label: c.displayName,
|
|
providerId: c.providerId ?? undefined,
|
|
})),
|
|
'env-var': envKeys.map((key) => ({ id: key, label: key })),
|
|
table: tables,
|
|
'knowledge-base': kbs,
|
|
'mcp-server': servers,
|
|
'custom-tool': tools,
|
|
skill: skills,
|
|
'knowledge-document': [],
|
|
file: files,
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Given mapped target ids grouped by kind, return the subset that still EXISTS in the
|
|
* target workspace (same archived/deleted filters as `listForkResourceCandidates`).
|
|
* Used at promote time so a mapping whose target was deleted after it was saved
|
|
* resolves as unmapped (surfaced/cleared) instead of writing a dead id into the
|
|
* promoted workflow. Queries the exact ids (not the capped candidate list) so a valid
|
|
* target is never wrongly dropped, and only the DB-backed kinds are checked - env-var
|
|
* existence is handled by the resolver's `targetEnvKeys`, and `file`/`workflow` are
|
|
* resolved by other paths.
|
|
*/
|
|
export async function filterExistingForkTargets(
|
|
executor: DbOrTx,
|
|
workspaceId: string,
|
|
idsByKind: Partial<Record<ForkRemapKind, Set<string>>>
|
|
): Promise<Partial<Record<ForkRemapKind, Set<string>>>> {
|
|
const ids = (kind: ForkRemapKind): string[] => {
|
|
const set = idsByKind[kind]
|
|
return set && set.size > 0 ? Array.from(set) : []
|
|
}
|
|
const credIds = ids('credential')
|
|
const tableIds = ids('table')
|
|
const kbIds = ids('knowledge-base')
|
|
const docIds = ids('knowledge-document')
|
|
const mcpIds = ids('mcp-server')
|
|
const toolIds = ids('custom-tool')
|
|
const skillIds = ids('skill')
|
|
// Files are identified by storage key (not `workspace_files.id`); a copied file's mapping
|
|
// target is its child storage key, so existence is checked by key in the target workspace.
|
|
const fileKeys = ids('file')
|
|
|
|
const [creds, tables, kbs, docs, servers, tools, skills, files] = await Promise.all([
|
|
credIds.length === 0
|
|
? Promise.resolve([] as Array<{ id: string }>)
|
|
: executor
|
|
.select({ id: credential.id })
|
|
.from(credential)
|
|
.where(
|
|
and(
|
|
eq(credential.workspaceId, workspaceId),
|
|
inArray(credential.type, ['oauth', 'service_account']),
|
|
inArray(credential.id, credIds)
|
|
)
|
|
),
|
|
tableIds.length === 0
|
|
? Promise.resolve([] as Array<{ id: string }>)
|
|
: tableCandidatesQuery(executor, workspaceId, tableIds),
|
|
kbIds.length === 0
|
|
? Promise.resolve([] as Array<{ id: string }>)
|
|
: knowledgeBaseCandidatesQuery(executor, workspaceId, kbIds),
|
|
// Documents are validated through a KB join (they are not a standalone candidate kind), so
|
|
// this existence check stays inline rather than sharing a per-kind candidate query.
|
|
docIds.length === 0
|
|
? Promise.resolve([] as Array<{ id: string }>)
|
|
: executor
|
|
.select({ id: document.id })
|
|
.from(document)
|
|
.innerJoin(knowledgeBase, eq(document.knowledgeBaseId, knowledgeBase.id))
|
|
.where(
|
|
and(
|
|
eq(knowledgeBase.workspaceId, workspaceId),
|
|
isNull(knowledgeBase.deletedAt),
|
|
isNull(document.deletedAt),
|
|
isNull(document.archivedAt),
|
|
inArray(document.id, docIds)
|
|
)
|
|
),
|
|
mcpIds.length === 0
|
|
? Promise.resolve([] as Array<{ id: string }>)
|
|
: mcpServerCandidatesQuery(executor, workspaceId, mcpIds),
|
|
toolIds.length === 0
|
|
? Promise.resolve([] as Array<{ id: string }>)
|
|
: customToolCandidatesQuery(executor, workspaceId, toolIds),
|
|
skillIds.length === 0
|
|
? Promise.resolve([] as Array<{ id: string }>)
|
|
: skillCandidatesQuery(executor, workspaceId, skillIds),
|
|
fileKeys.length === 0
|
|
? Promise.resolve([] as Array<{ id: string }>)
|
|
: fileCandidatesQuery(executor, workspaceId, fileKeys),
|
|
])
|
|
|
|
const result: Partial<Record<ForkRemapKind, Set<string>>> = {}
|
|
if (credIds.length > 0) result.credential = new Set(creds.map((r) => r.id))
|
|
if (tableIds.length > 0) result.table = new Set(tables.map((r) => r.id))
|
|
if (kbIds.length > 0) result['knowledge-base'] = new Set(kbs.map((r) => r.id))
|
|
if (docIds.length > 0) result['knowledge-document'] = new Set(docs.map((r) => r.id))
|
|
if (mcpIds.length > 0) result['mcp-server'] = new Set(servers.map((r) => r.id))
|
|
if (toolIds.length > 0) result['custom-tool'] = new Set(tools.map((r) => r.id))
|
|
if (skillIds.length > 0) result.skill = new Set(skills.map((r) => r.id))
|
|
// `fileCandidatesQuery` exposes the storage key under `id`, so file existence keys by `r.id`.
|
|
if (fileKeys.length > 0) result.file = new Set(files.map((r) => r.id))
|
|
return result
|
|
}
|
|
|
|
/**
|
|
* Identity metadata (`name`/`url`) for the given MCP server ids in a workspace, looked up by
|
|
* exact id (no candidate cap, same deleted filter as the candidates). Promote uses it for the
|
|
* MAPPED TARGET servers so remapped tool-input entries rewrite their embedded server metadata
|
|
* from the target row (see {@link ForkMcpServerMeta}) - one bounded `inArray` read per sync,
|
|
* never per-entry. An id absent from the map no longer exists; its entries are left as-is.
|
|
*/
|
|
export async function getMcpServerMetaByIds(
|
|
executor: DbOrTx,
|
|
workspaceId: string,
|
|
ids: string[]
|
|
): Promise<Map<string, ForkMcpServerMeta>> {
|
|
if (ids.length === 0) return new Map()
|
|
const rows = await executor
|
|
.select({ id: mcpServers.id, name: mcpServers.name, url: mcpServers.url })
|
|
.from(mcpServers)
|
|
.where(
|
|
and(
|
|
eq(mcpServers.workspaceId, workspaceId),
|
|
isNull(mcpServers.deletedAt),
|
|
inArray(mcpServers.id, ids)
|
|
)
|
|
)
|
|
return new Map(rows.map((row) => [row.id, { name: row.name, url: row.url ?? null }]))
|
|
}
|
|
|
|
/**
|
|
* Provider id for each given credential id in a workspace, looked up by exact id (no
|
|
* candidate cap). Presence in the returned map means the credential exists in the
|
|
* workspace, so this doubles as a cap-free existence + provider check for validation.
|
|
*/
|
|
export async function getCredentialProvidersByIds(
|
|
executor: DbOrTx,
|
|
workspaceId: string,
|
|
ids: string[]
|
|
): Promise<Map<string, string | null>> {
|
|
if (ids.length === 0) return new Map()
|
|
const rows = await executor
|
|
.select({ id: credential.id, providerId: credential.providerId })
|
|
.from(credential)
|
|
.where(
|
|
and(
|
|
eq(credential.workspaceId, workspaceId),
|
|
inArray(credential.type, ['oauth', 'service_account']),
|
|
inArray(credential.id, ids)
|
|
)
|
|
)
|
|
return new Map(rows.map((row) => [row.id, row.providerId ?? null]))
|
|
}
|
|
|
|
/** A copyable workspace file plus its folder grouping (null folder = workspace root). */
|
|
export interface ForkCopyableFileResource extends ForkResourceCandidate {
|
|
folderId: string | null
|
|
folderName: string | null
|
|
}
|
|
|
|
export interface ForkCopyableResources {
|
|
files: ForkCopyableFileResource[]
|
|
tables: ForkResourceCandidate[]
|
|
knowledgeBases: ForkResourceCandidate[]
|
|
customTools: ForkResourceCandidate[]
|
|
skills: ForkResourceCandidate[]
|
|
/** External MCP servers, copied as config rows (OAuth tokens never copied - re-auth in child). */
|
|
mcpServers: ForkResourceCandidate[]
|
|
/** Workflow-publishing MCP servers, copied as config-only shells with no workflows attached. */
|
|
workflowMcpServers: ForkResourceCandidate[]
|
|
/**
|
|
* Count of deployed workflows that the fork would copy. When 0, the fork modal shows an
|
|
* informational note (forking is never blocked) - create-fork seeds a blank starter
|
|
* workflow so the child is still a usable workspace.
|
|
*/
|
|
deployedWorkflowCount: number
|
|
}
|
|
|
|
/**
|
|
* List the resources in a workspace that can be selected for copy at fork time
|
|
* (the content kinds — never credentials or env vars). Powers the fork modal's
|
|
* resource picker.
|
|
*/
|
|
export async function listForkCopyableResources(
|
|
executor: DbOrTx,
|
|
workspaceId: string
|
|
): Promise<ForkCopyableResources> {
|
|
const [files, tables, kbs, tools, skills, externalServers, servers, deployed] = await Promise.all(
|
|
[
|
|
fileCandidatesWithFolderQuery(executor, workspaceId),
|
|
tableCandidatesQuery(executor, workspaceId),
|
|
knowledgeBaseCandidatesQuery(executor, workspaceId),
|
|
customToolCandidatesQuery(executor, workspaceId),
|
|
skillCandidatesQuery(executor, workspaceId),
|
|
// External MCP servers copy as config rows (same filter as the mapping candidates).
|
|
mcpServerCandidatesQuery(executor, workspaceId),
|
|
executor
|
|
.select({ id: workflowMcpServer.id, label: workflowMcpServer.name })
|
|
.from(workflowMcpServer)
|
|
.where(
|
|
and(eq(workflowMcpServer.workspaceId, workspaceId), isNull(workflowMcpServer.deletedAt))
|
|
)
|
|
.limit(CANDIDATE_LIMIT),
|
|
executor
|
|
.select({ value: count() })
|
|
.from(workflow)
|
|
// Match listDeployedWorkflows: a workflow only counts as copyable when it has an
|
|
// actually-active deployment version, not just the isDeployed flag, so the fork
|
|
// modal's preflight count never over-reports "ghost" deployed workflows.
|
|
.where(
|
|
and(
|
|
eq(workflow.workspaceId, workspaceId),
|
|
eq(workflow.isDeployed, true),
|
|
isNull(workflow.archivedAt),
|
|
exists(
|
|
executor
|
|
.select({ one: sql`1` })
|
|
.from(workflowDeploymentVersion)
|
|
.where(
|
|
and(
|
|
eq(workflowDeploymentVersion.workflowId, workflow.id),
|
|
eq(workflowDeploymentVersion.isActive, true)
|
|
)
|
|
)
|
|
)
|
|
)
|
|
),
|
|
]
|
|
)
|
|
return {
|
|
// The shared folder query also selects the storage key (for the label lookup); the copy
|
|
// picker addresses files by `workspace_files.id`, so drop the key here.
|
|
files: files.map((row) => ({
|
|
id: row.id,
|
|
label: row.label,
|
|
folderId: row.folderId,
|
|
folderName: row.folderName,
|
|
})),
|
|
tables,
|
|
knowledgeBases: kbs,
|
|
customTools: tools,
|
|
skills,
|
|
mcpServers: externalServers,
|
|
workflowMcpServers: servers,
|
|
deployedWorkflowCount: deployed[0]?.value ?? 0,
|
|
}
|
|
}
|
|
|
|
/**
|
|
* A copyable reference's display label plus its folder grouping. `parentId`/`parentLabel` are
|
|
* populated only for files (their folder id + name; null at the workspace root) and are null for
|
|
* every other copyable kind, which the picker renders flat.
|
|
*/
|
|
export interface ForkCopyableLabel {
|
|
label: string
|
|
parentId: string | null
|
|
parentLabel: string | null
|
|
}
|
|
|
|
/**
|
|
* One copyable resource in the sync SOURCE workspace, keyed the way the promote copy addresses
|
|
* it: files by STORAGE KEY (matching `file-upload` references + `planForkFileCopies`), every
|
|
* other kind by row id. `parentId`/`parentLabel` carry a file's folder grouping (null for
|
|
* non-file kinds and root files).
|
|
*/
|
|
export interface ForkCopyableSourceResource {
|
|
kind: ForkCopyableKind
|
|
sourceId: string
|
|
label: string
|
|
parentId: string | null
|
|
parentLabel: string | null
|
|
}
|
|
|
|
/**
|
|
* Every copyable-kind resource in the sync source workspace (same archived/deleted filters and
|
|
* per-kind {@link CANDIDATE_LIMIT} cap as the copy picker), as sync-copy candidate entries. The
|
|
* promote plan filters these down to the UNREFERENCED-and-unmapped set it offers for copy
|
|
* alongside the referenced candidates. Covers exactly the sync-copyable kinds
|
|
* (`forkCopyableKindSchema`): workflow-publishing MCP servers are fork-copy-only shells, and
|
|
* credentials / env vars are never copied.
|
|
*/
|
|
export async function listForkCopyableSourceResources(
|
|
executor: DbOrTx,
|
|
sourceWorkspaceId: string
|
|
): Promise<ForkCopyableSourceResource[]> {
|
|
const [files, tables, kbs, tools, skills, mcp] = await Promise.all([
|
|
fileCandidatesWithFolderQuery(executor, sourceWorkspaceId),
|
|
tableCandidatesQuery(executor, sourceWorkspaceId),
|
|
knowledgeBaseCandidatesQuery(executor, sourceWorkspaceId),
|
|
customToolCandidatesQuery(executor, sourceWorkspaceId),
|
|
skillCandidatesQuery(executor, sourceWorkspaceId),
|
|
mcpServerCandidatesQuery(executor, sourceWorkspaceId),
|
|
])
|
|
const flat = (
|
|
kind: ForkCopyableKind,
|
|
rows: Array<{ id: string; label: string }>
|
|
): ForkCopyableSourceResource[] =>
|
|
rows.map((row) => ({
|
|
kind,
|
|
sourceId: row.id,
|
|
label: row.label,
|
|
parentId: null,
|
|
parentLabel: null,
|
|
}))
|
|
return [
|
|
...files.map((row) => ({
|
|
kind: 'file' as const,
|
|
sourceId: row.key,
|
|
label: row.label,
|
|
parentId: row.folderId,
|
|
parentLabel: row.folderName,
|
|
})),
|
|
...flat('table', tables),
|
|
...flat('knowledge-base', kbs),
|
|
...flat('custom-tool', tools),
|
|
...flat('skill', skills),
|
|
...flat('mcp-server', mcp),
|
|
]
|
|
}
|
|
|
|
/**
|
|
* Labels (by exact id) for the copyable resource kinds referenced-but-unmapped at promote time,
|
|
* scoped to the source workspace and the same archived/deleted filters as the copy picker. A
|
|
* resource absent from the result no longer exists in the source, so it can't be copied and is
|
|
* dropped from the sync copy candidates. Keyed `${kind}:${id}` so callers can look a reference up
|
|
* directly; file entries additionally carry their folder grouping. Only kinds with ids are queried.
|
|
*/
|
|
export async function loadForkCopyableResourceLabels(
|
|
executor: DbOrTx,
|
|
sourceWorkspaceId: string,
|
|
idsByKind: Partial<Record<ForkCopyableKind, string[]>>
|
|
): Promise<Map<string, ForkCopyableLabel>> {
|
|
const labels = new Map<string, ForkCopyableLabel>()
|
|
const ids = (kind: ForkCopyableKind): string[] => {
|
|
const list = idsByKind[kind]
|
|
return list && list.length > 0 ? list : []
|
|
}
|
|
const kbIds = ids('knowledge-base')
|
|
const tableIds = ids('table')
|
|
const toolIds = ids('custom-tool')
|
|
const skillIds = ids('skill')
|
|
const mcpIds = ids('mcp-server')
|
|
// Files are keyed by storage key (not `workspace_files.id`), so they label by key.
|
|
const fileKeys = ids('file')
|
|
|
|
const [kbs, tables, tools, skills, mcp, files] = await Promise.all([
|
|
kbIds.length === 0
|
|
? Promise.resolve([] as Array<{ id: string; label: string }>)
|
|
: knowledgeBaseCandidatesQuery(executor, sourceWorkspaceId, kbIds),
|
|
tableIds.length === 0
|
|
? Promise.resolve([] as Array<{ id: string; label: string }>)
|
|
: tableCandidatesQuery(executor, sourceWorkspaceId, tableIds),
|
|
toolIds.length === 0
|
|
? Promise.resolve([] as Array<{ id: string; label: string }>)
|
|
: customToolCandidatesQuery(executor, sourceWorkspaceId, toolIds),
|
|
skillIds.length === 0
|
|
? Promise.resolve([] as Array<{ id: string; label: string }>)
|
|
: skillCandidatesQuery(executor, sourceWorkspaceId, skillIds),
|
|
mcpIds.length === 0
|
|
? Promise.resolve([] as Array<{ id: string; label: string }>)
|
|
: mcpServerCandidatesQuery(executor, sourceWorkspaceId, mcpIds),
|
|
fileKeys.length === 0
|
|
? Promise.resolve(
|
|
[] as Array<{
|
|
key: string
|
|
label: string
|
|
folderId: string | null
|
|
folderName: string | null
|
|
}>
|
|
)
|
|
: fileCandidatesWithFolderQuery(executor, sourceWorkspaceId, { keys: fileKeys }),
|
|
])
|
|
|
|
const flat = (label: string): ForkCopyableLabel => ({ label, parentId: null, parentLabel: null })
|
|
for (const row of kbs) labels.set(`knowledge-base:${row.id}`, flat(row.label))
|
|
for (const row of tables) labels.set(`table:${row.id}`, flat(row.label))
|
|
for (const row of tools) labels.set(`custom-tool:${row.id}`, flat(row.label))
|
|
for (const row of skills) labels.set(`skill:${row.id}`, flat(row.label))
|
|
for (const row of mcp) labels.set(`mcp-server:${row.id}`, flat(row.label))
|
|
for (const row of files) {
|
|
labels.set(`file:${row.key}`, {
|
|
label: row.label,
|
|
parentId: row.folderId,
|
|
parentLabel: row.folderName,
|
|
})
|
|
}
|
|
return labels
|
|
}
|
|
|
|
/** Resolve a credential id to its stored mapping resource type. */
|
|
export async function classifyCredentialResourceType(
|
|
executor: DbOrTx,
|
|
credentialId: string,
|
|
workspaceId: string
|
|
): Promise<Extract<ForkResourceType, 'oauth_credential' | 'service_account_credential'>> {
|
|
const [row] = await executor
|
|
.select({ type: credential.type })
|
|
.from(credential)
|
|
.where(and(eq(credential.id, credentialId), eq(credential.workspaceId, workspaceId)))
|
|
.limit(1)
|
|
return row?.type === 'service_account' ? 'service_account_credential' : 'oauth_credential'
|
|
}
|