Files
simstudioai--sim/apps/sim/lib/copilot/tools/handlers/deployment/manage.ts
T
wehub-resource-sync 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
chore: import upstream snapshot with attribution
2026-07-13 13:20:55 +08:00

611 lines
20 KiB
TypeScript

import { db } from '@sim/db'
import { chat, workflow, workflowMcpServer, workflowMcpTool } from '@sim/db/schema'
import { toError } from '@sim/utils/errors'
import { and, eq, inArray, isNull } from 'drizzle-orm'
import type { ExecutionContext, ToolCallResult } from '@/lib/copilot/request/types'
import {
performCreateWorkflowMcpServer,
performDeleteWorkflowMcpServer,
performUpdateWorkflowMcpServer,
} from '@/lib/mcp/orchestration'
import { generateWorkflowDiffSummary } from '@/lib/workflows/comparison'
import { performActivateVersion, performRevertToVersion } from '@/lib/workflows/orchestration'
import {
listWorkflowVersions,
updateDeploymentVersionMetadata,
} from '@/lib/workflows/persistence/utils'
import { checkNeedsRedeployment } from '@/app/api/workflows/utils'
import { ensureWorkflowAccess, ensureWorkspaceAccess } from '../access'
import type {
CheckDeploymentStatusParams,
CreateWorkspaceMcpServerParams,
DeleteWorkspaceMcpServerParams,
DiffWorkflowsParams,
GetDeploymentLogParams,
ListWorkspaceMcpServersParams,
LoadDeploymentParams,
PromoteToLiveParams,
UpdateDeploymentVersionParams,
UpdateWorkspaceMcpServerParams,
} from '../param-types'
import { resolveWorkflowStateRef } from './state-refs'
export async function executeCheckDeploymentStatus(
params: CheckDeploymentStatusParams,
context: ExecutionContext
): Promise<ToolCallResult> {
try {
const workflowId = params.workflowId || context.workflowId
if (!workflowId) {
return { success: false, error: 'workflowId is required' }
}
const { workflow: workflowRecord } = await ensureWorkflowAccess(workflowId, context.userId)
const workspaceId = workflowRecord.workspaceId
const [apiDeploy, chatDeploy] = await Promise.all([
db
.select({ isDeployed: workflow.isDeployed, deployedAt: workflow.deployedAt })
.from(workflow)
.where(eq(workflow.id, workflowId))
.limit(1),
db
.select({
id: chat.id,
identifier: chat.identifier,
title: chat.title,
description: chat.description,
authType: chat.authType,
allowedEmails: chat.allowedEmails,
outputConfigs: chat.outputConfigs,
password: chat.password,
customizations: chat.customizations,
})
.from(chat)
.where(and(eq(chat.workflowId, workflowId), isNull(chat.archivedAt)))
.limit(1),
])
const isApiDeployed = apiDeploy[0]?.isDeployed || false
const needsRedeployment = isApiDeployed ? await checkNeedsRedeployment(workflowId) : false
const apiDetails = {
isDeployed: isApiDeployed,
deployedAt: apiDeploy[0]?.deployedAt || null,
endpoint: isApiDeployed ? `/api/workflows/${workflowId}/execute` : null,
apiKey: workflowRecord.workspaceId ? 'Workspace API keys' : 'Personal API keys',
needsRedeployment,
}
const isChatDeployed = !!chatDeploy[0]
const chatCustomizations =
(chatDeploy[0]?.customizations as
| { welcomeMessage?: string; primaryColor?: string }
| undefined) || {}
const chatDetails = {
isDeployed: isChatDeployed,
chatId: chatDeploy[0]?.id || null,
identifier: chatDeploy[0]?.identifier || null,
chatUrl: isChatDeployed ? `/chat/${chatDeploy[0]?.identifier}` : null,
title: chatDeploy[0]?.title || null,
description: chatDeploy[0]?.description || null,
authType: chatDeploy[0]?.authType || null,
allowedEmails: chatDeploy[0]?.allowedEmails || null,
outputConfigs: chatDeploy[0]?.outputConfigs || null,
welcomeMessage: chatCustomizations.welcomeMessage || null,
primaryColor: chatCustomizations.primaryColor || null,
hasPassword: Boolean(chatDeploy[0]?.password),
}
const mcpDetails: {
isDeployed: boolean
servers: Array<{
serverId: string
serverName: string
toolName: string
toolDescription: string | null
parameterSchema: unknown
toolId: string
}>
} = { isDeployed: false, servers: [] }
if (workspaceId) {
const servers = await db
.select({
serverId: workflowMcpServer.id,
serverName: workflowMcpServer.name,
toolName: workflowMcpTool.toolName,
toolDescription: workflowMcpTool.toolDescription,
parameterSchema: workflowMcpTool.parameterSchema,
toolId: workflowMcpTool.id,
})
.from(workflowMcpTool)
.innerJoin(workflowMcpServer, eq(workflowMcpTool.serverId, workflowMcpServer.id))
.where(eq(workflowMcpTool.workflowId, workflowId))
if (servers.length > 0) {
mcpDetails.isDeployed = true
mcpDetails.servers = servers
}
}
const isDeployed = apiDetails.isDeployed || chatDetails.isDeployed || mcpDetails.isDeployed
return {
success: true,
output: { isDeployed, api: apiDetails, chat: chatDetails, mcp: mcpDetails },
}
} catch (error) {
return { success: false, error: toError(error).message }
}
}
export async function executeListWorkspaceMcpServers(
params: ListWorkspaceMcpServersParams,
context: ExecutionContext
): Promise<ToolCallResult> {
try {
let workspaceId = params.workspaceId || context.workspaceId
const workflowId = context.workflowId
if (!workspaceId && workflowId) {
const { workflow: workflowRecord } = await ensureWorkflowAccess(workflowId, context.userId)
workspaceId = workflowRecord.workspaceId ?? undefined
}
if (!workspaceId) {
return { success: false, error: 'workspaceId is required' }
}
await ensureWorkspaceAccess(workspaceId, context.userId, 'read')
const servers = await db
.select({
id: workflowMcpServer.id,
name: workflowMcpServer.name,
description: workflowMcpServer.description,
})
.from(workflowMcpServer)
.where(
and(eq(workflowMcpServer.workspaceId, workspaceId), isNull(workflowMcpServer.deletedAt))
)
const serverIds = servers.map((server) => server.id)
const tools =
serverIds.length > 0
? await db
.select({
serverId: workflowMcpTool.serverId,
toolName: workflowMcpTool.toolName,
})
.from(workflowMcpTool)
.where(
and(inArray(workflowMcpTool.serverId, serverIds), isNull(workflowMcpTool.archivedAt))
)
: []
const toolNamesByServer: Record<string, string[]> = {}
for (const tool of tools) {
if (!toolNamesByServer[tool.serverId]) {
toolNamesByServer[tool.serverId] = []
}
toolNamesByServer[tool.serverId].push(tool.toolName)
}
const serversWithToolNames = servers.map((server) => ({
...server,
toolCount: toolNamesByServer[server.id]?.length || 0,
toolNames: toolNamesByServer[server.id] || [],
}))
return { success: true, output: { servers: serversWithToolNames, count: servers.length } }
} catch (error) {
return { success: false, error: toError(error).message }
}
}
export async function executeCreateWorkspaceMcpServer(
params: CreateWorkspaceMcpServerParams,
context: ExecutionContext
): Promise<ToolCallResult> {
try {
let workspaceId = params.workspaceId || context.workspaceId
const workflowId = context.workflowId
if (!workspaceId && workflowId) {
const { workflow: workflowRecord } = await ensureWorkflowAccess(
workflowId,
context.userId,
'write'
)
workspaceId = workflowRecord.workspaceId ?? undefined
}
if (!workspaceId) {
return { success: false, error: 'workspaceId is required' }
}
await ensureWorkspaceAccess(workspaceId, context.userId, 'admin')
const name = params.name?.trim()
if (!name) {
return { success: false, error: 'name is required' }
}
const result = await performCreateWorkflowMcpServer({
workspaceId,
userId: context.userId,
name,
description: params.description,
isPublic: params.isPublic,
workflowIds: params.workflowIds,
})
if (!result.success) {
return { success: false, error: result.error || 'Failed to create MCP server' }
}
return { success: true, output: { server: result.server, addedTools: result.addedTools || [] } }
} catch (error) {
return { success: false, error: toError(error).message }
}
}
export async function executeUpdateWorkspaceMcpServer(
params: UpdateWorkspaceMcpServerParams,
context: ExecutionContext
): Promise<ToolCallResult> {
try {
const serverId = params.serverId
if (!serverId) {
return { success: false, error: 'serverId is required' }
}
const updates: { name?: string; description?: string | null; isPublic?: boolean } = {}
if (typeof params.name === 'string') {
const name = params.name.trim()
if (!name) return { success: false, error: 'name cannot be empty' }
updates.name = name
}
if (typeof params.description === 'string') {
updates.description = params.description.trim() || null
}
if (typeof params.isPublic === 'boolean') {
updates.isPublic = params.isPublic
}
if (Object.keys(updates).length === 0) {
return { success: false, error: 'At least one of name, description, or isPublic is required' }
}
const [existing] = await db
.select({
id: workflowMcpServer.id,
workspaceId: workflowMcpServer.workspaceId,
})
.from(workflowMcpServer)
.where(eq(workflowMcpServer.id, serverId))
.limit(1)
if (!existing) {
return { success: false, error: 'MCP server not found' }
}
await ensureWorkspaceAccess(existing.workspaceId, context.userId, 'write')
const result = await performUpdateWorkflowMcpServer({
serverId,
workspaceId: existing.workspaceId,
userId: context.userId,
...updates,
})
if (!result.success) {
return { success: false, error: result.error || 'Failed to update MCP server' }
}
return { success: true, output: { serverId, ...updates } }
} catch (error) {
return { success: false, error: toError(error).message }
}
}
export async function executeDeleteWorkspaceMcpServer(
params: DeleteWorkspaceMcpServerParams,
context: ExecutionContext
): Promise<ToolCallResult> {
try {
const serverId = params.serverId
if (!serverId) {
return { success: false, error: 'serverId is required' }
}
const [existing] = await db
.select({
id: workflowMcpServer.id,
name: workflowMcpServer.name,
workspaceId: workflowMcpServer.workspaceId,
})
.from(workflowMcpServer)
.where(and(eq(workflowMcpServer.id, serverId), isNull(workflowMcpServer.deletedAt)))
.limit(1)
if (!existing) {
return { success: false, error: 'MCP server not found' }
}
await ensureWorkspaceAccess(existing.workspaceId, context.userId, 'admin')
const result = await performDeleteWorkflowMcpServer({
serverId,
workspaceId: existing.workspaceId,
userId: context.userId,
})
if (!result.success) {
return { success: false, error: result.error || 'Failed to delete MCP server' }
}
return { success: true, output: { serverId, name: existing.name, deleted: true } }
} catch (error) {
return { success: false, error: toError(error).message }
}
}
export async function executeGetDeploymentLog(
params: GetDeploymentLogParams,
context: ExecutionContext
): Promise<ToolCallResult> {
try {
const workflowId = params.workflowId || context.workflowId
if (!workflowId) {
return { success: false, error: 'workflowId is required' }
}
await ensureWorkflowAccess(workflowId, context.userId)
const { versions: rows } = await listWorkflowVersions(workflowId)
const versions = rows.map((r) => ({
id: r.id,
version: r.version,
name: r.name ?? undefined,
description: r.description ?? undefined,
isActive: r.isActive,
createdAt: r.createdAt.toISOString(),
createdBy: r.createdBy ?? undefined,
}))
return { success: true, output: { workflowId, count: versions.length, versions } }
} catch (error) {
return { success: false, error: toError(error).message }
}
}
// Cap individual sub-block before/after values so a large diff can't blow the
// tool-result budget. Oversized values are replaced with an elision marker.
const MAX_DIFF_VALUE_BYTES = 2000
function guardDiffValue(value: unknown): unknown {
try {
const json = JSON.stringify(value)
if (json && json.length > MAX_DIFF_VALUE_BYTES) {
return { elided: true, bytes: json.length }
}
} catch {
return { elided: true, reason: 'unserializable' }
}
return value
}
export async function executeDiffWorkflows(
params: DiffWorkflowsParams,
context: ExecutionContext
): Promise<ToolCallResult> {
try {
const workflowId = params.workflowId || context.workflowId
if (!workflowId) {
return { success: false, error: 'workflowId is required' }
}
if (params.ref1 === undefined || params.ref2 === undefined) {
return { success: false, error: 'ref1 and ref2 are required' }
}
// resolveWorkflowStateRef enforces read access on the workflow.
const [side1, side2] = await Promise.all([
resolveWorkflowStateRef(workflowId, params.ref1, context.userId),
resolveWorkflowStateRef(workflowId, params.ref2, context.userId),
])
// ref1 = base/previous, ref2 = target/current: added = present in ref2 only.
const summary = generateWorkflowDiffSummary(side2.state, side1.state)
const diff = {
...summary,
modifiedBlocks: summary.modifiedBlocks.map((block) => ({
...block,
changes: block.changes.map((change) => ({
field: change.field,
oldValue: guardDiffValue(change.oldValue),
newValue: guardDiffValue(change.newValue),
})),
})),
}
return {
success: true,
output: {
workflowId,
ref1: { ref: side1.ref, version: side1.version, isActive: side1.isActive },
ref2: { ref: side2.ref, version: side2.version, isActive: side2.isActive },
diff,
},
}
} catch (error) {
return { success: false, error: toError(error).message }
}
}
function resolveLoadVersion(
raw: number | string
): { ok: true; version: number | 'active' } | { ok: false; error: string } {
if (typeof raw === 'number' && Number.isFinite(raw)) return { ok: true, version: raw }
if (typeof raw === 'string') {
const t = raw.trim().toLowerCase()
if (t === 'live' || t === 'active') return { ok: true, version: 'active' }
if (t === 'draft' || t === 'current') {
return {
ok: false,
error: 'Cannot load "draft" — load_deployment restores a deployed version into the draft',
}
}
if (/^\d+$/.test(t)) return { ok: true, version: Number.parseInt(t, 10) }
}
return {
ok: false,
error: `Invalid version "${String(raw)}": expected a version number or "live"`,
}
}
export async function executeLoadDeployment(
params: LoadDeploymentParams,
context: ExecutionContext
): Promise<ToolCallResult> {
try {
const workflowId = params.workflowId || context.workflowId
if (!workflowId) {
return { success: false, error: 'workflowId is required' }
}
if (params.version === undefined || params.version === null) {
return { success: false, error: 'version is required' }
}
const target = resolveLoadVersion(params.version)
if (!target.ok) {
return { success: false, error: target.error }
}
const { workflow: workflowRecord } = await ensureWorkflowAccess(
workflowId,
context.userId,
'admin'
)
const result = await performRevertToVersion({
workflowId,
version: target.version,
userId: context.userId,
workflow: workflowRecord as Record<string, unknown>,
})
if (!result.success) {
return { success: false, error: result.error || 'Failed to load deployment' }
}
const label = target.version === 'active' ? 'the live deployment' : `version ${target.version}`
return {
success: true,
output: {
workflowId,
message: `Loaded ${label} into the workflow draft`,
lastSaved: result.lastSaved,
},
}
} catch (error) {
return { success: false, error: toError(error).message }
}
}
function normalizePromoteVersion(raw: number | string): number | null {
if (typeof raw === 'number' && Number.isFinite(raw)) return raw
if (typeof raw === 'string' && /^\d+$/.test(raw.trim())) return Number.parseInt(raw.trim(), 10)
return null
}
export async function executePromoteToLive(
params: PromoteToLiveParams,
context: ExecutionContext
): Promise<ToolCallResult> {
try {
const workflowId = params.workflowId || context.workflowId
if (!workflowId) {
return { success: false, error: 'workflowId is required' }
}
if (params.version === undefined || params.version === null) {
return { success: false, error: 'version is required' }
}
const version = normalizePromoteVersion(params.version)
if (version === null) {
return {
success: false,
error:
'version must be a deployment version number (use load_deployment to change the draft; "live" is already live)',
}
}
const { workflow: workflowRecord } = await ensureWorkflowAccess(
workflowId,
context.userId,
'admin'
)
const result = await performActivateVersion({
workflowId,
version,
userId: context.userId,
workflow: workflowRecord as Record<string, unknown>,
})
if (!result.success) {
return { success: false, error: result.error || 'Failed to promote version' }
}
return {
success: true,
output: {
workflowId,
version,
message: `Promoted version ${version} to live`,
deployedAt: result.deployedAt ? new Date(result.deployedAt).toISOString() : undefined,
warnings: result.warnings,
},
}
} catch (error) {
return { success: false, error: toError(error).message }
}
}
export async function executeUpdateDeploymentVersion(
params: UpdateDeploymentVersionParams,
context: ExecutionContext
): Promise<ToolCallResult> {
try {
const workflowId = params.workflowId || context.workflowId
if (!workflowId) {
return { success: false, error: 'workflowId is required' }
}
if (params.version === undefined || params.version === null) {
return { success: false, error: 'version is required' }
}
const version = normalizePromoteVersion(params.version)
if (version === null) {
return {
success: false,
error: 'version must be a deployment version number (use get_deployment_log to find it)',
}
}
const name = typeof params.name === 'string' ? params.name.trim() : undefined
const description =
typeof params.description === 'string' ? params.description.trim() : undefined
if (name === undefined && description === undefined) {
return { success: false, error: 'Provide a name and/or description to update' }
}
await ensureWorkflowAccess(workflowId, context.userId, 'write')
const updated = await updateDeploymentVersionMetadata({
workflowId,
version,
...(name !== undefined ? { name: name || null } : {}),
...(description !== undefined ? { description: description || null } : {}),
})
if (!updated) {
return { success: false, error: `Deployment version ${version} not found` }
}
return {
success: true,
output: { workflowId, version, name: updated.name, description: updated.description },
}
} catch (error) {
return { success: false, error: toError(error).message }
}
}