Files
simstudioai--sim/apps/sim/lib/copilot/tools/handlers/deployment/deploy.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

837 lines
25 KiB
TypeScript

import { db } from '@sim/db'
import { chat, workflowMcpServer, workflowMcpTool } from '@sim/db/schema'
import { toError } from '@sim/utils/errors'
import { and, eq, isNull } from 'drizzle-orm'
import type { ExecutionContext, ToolCallResult } from '@/lib/copilot/request/types'
import { getBaseUrl } from '@/lib/core/utils/urls'
import {
performCreateWorkflowMcpTool,
performDeleteWorkflowMcpTool,
performUpdateWorkflowMcpTool,
} from '@/lib/mcp/orchestration'
import { getDeployedWorkflowInputFormat } from '@/lib/mcp/workflow-mcp-sync'
import {
applyDescriptionOverrides,
generateToolInputSchema,
getMeaningfulWorkflowDescription,
sanitizeToolName,
} from '@/lib/mcp/workflow-tool-schema'
import {
performChatDeploy,
performChatUndeploy,
performFullDeploy,
performFullUndeploy,
} from '@/lib/workflows/orchestration'
import { checkChatAccess, checkWorkflowAccessForChatCreation } from '@/app/api/chat/utils'
import { ensureWorkflowAccess } from '../access'
import type { DeployApiParams, DeployChatParams, DeployMcpParams } from '../param-types'
function buildWorkflowApiEndpoint(baseUrl: string, workflowId: string): string {
return `${baseUrl}/api/workflows/${workflowId}/execute`
}
function buildWorkflowApiConfig(baseUrl: string, apiEndpoint: string) {
return {
endpoint: apiEndpoint,
authentication: {
type: 'api_key',
acceptedHeaders: ['X-API-Key: YOUR_API_KEY', 'Authorization: Bearer YOUR_API_KEY'],
},
modes: {
sync: {
method: 'POST',
transport: 'json',
stream: false,
body: { input: { key: 'value' } },
},
stream: {
method: 'POST',
transport: 'sse',
stream: true,
body: { stream: true, input: { key: 'value' } },
},
async: {
method: 'POST',
transport: 'json',
stream: false,
headers: { 'X-Execution-Mode': 'async' },
body: { input: { key: 'value' } },
jobStatusEndpointTemplate: `${baseUrl}/api/jobs/{jobId}`,
},
},
}
}
function buildWorkflowApiExamples(baseUrl: string, apiEndpoint: string) {
return {
sync: `curl -X POST "${apiEndpoint}" \\
-H "Content-Type: application/json" \\
-H "X-API-Key: YOUR_API_KEY" \\
-d '{"input":{"key":"value"}}'`,
stream: `curl -N -X POST "${apiEndpoint}" \\
-H "Content-Type: application/json" \\
-H "X-API-Key: YOUR_API_KEY" \\
-d '{"stream":true,"input":{"key":"value"}}'`,
async: `curl -X POST "${apiEndpoint}" \\
-H "Content-Type: application/json" \\
-H "X-API-Key: YOUR_API_KEY" \\
-H "X-Execution-Mode: async" \\
-d '{"input":{"key":"value"}}'`,
poll: `curl "${baseUrl}/api/jobs/JOB_ID" \\
-H "X-API-Key: YOUR_API_KEY"`,
}
}
function buildMcpClientExamples(serverName: string, serverUrl: string) {
return {
cursor: {
mcpServers: {
[serverName]: {
url: serverUrl,
headers: { 'X-API-Key': 'YOUR_API_KEY' },
},
},
},
claudeCode: `claude mcp add ${serverName} --url "${serverUrl}" --header "X-API-Key: YOUR_API_KEY"`,
claudeDesktop: {
mcpServers: {
[serverName]: {
command: 'npx',
args: ['-y', 'mcp-remote', serverUrl, '--header', 'X-API-Key:YOUR_API_KEY'],
},
},
},
vscode: {
mcp: {
servers: {
[serverName]: {
type: 'http',
url: serverUrl,
headers: { 'X-API-Key': 'YOUR_API_KEY' },
},
},
},
},
}
}
export async function executeDeployApi(
params: DeployApiParams,
context: ExecutionContext
): Promise<ToolCallResult> {
try {
const workflowId = params.workflowId || context.workflowId
if (!workflowId) {
return { success: false, error: 'workflowId is required' }
}
const action = params.action === 'undeploy' ? 'undeploy' : 'deploy'
const { workflow: workflowRecord } = await ensureWorkflowAccess(
workflowId,
context.userId,
'admin'
)
if (action === 'undeploy') {
const result = await performFullUndeploy({ workflowId, userId: context.userId })
if (!result.success) {
return { success: false, error: result.error || 'Failed to undeploy workflow' }
}
const baseUrl = getBaseUrl()
const apiEndpoint = buildWorkflowApiEndpoint(baseUrl, workflowId)
return {
success: true,
output: {
workflowId,
isDeployed: false,
apiEndpoint,
baseUrl,
deploymentType: 'api',
deploymentStatus: {
api: {
isDeployed: false,
endpoint: apiEndpoint,
},
},
deploymentConfig: {
api: buildWorkflowApiConfig(baseUrl, apiEndpoint),
},
examples: {
api: {
curl: buildWorkflowApiExamples(baseUrl, apiEndpoint),
},
},
},
}
}
const versionDescription = params.versionDescription?.trim()
if (!versionDescription) {
return {
success: false,
error:
'versionDescription is required when deploying. Provide a concise summary of what changed in this deployment version (call diff_workflows with ref1 "live" and ref2 "draft" if unsure what changed).',
}
}
const versionName = params.versionName?.trim()
if (!versionName) {
return {
success: false,
error:
'versionName is required when deploying. Provide a short human-readable label for this deployment version.',
}
}
const result = await performFullDeploy({
workflowId,
userId: context.userId,
workflowName: workflowRecord.name || undefined,
versionDescription,
versionName,
})
if (!result.success) {
return { success: false, error: result.error || 'Failed to deploy workflow' }
}
const baseUrl = getBaseUrl()
const apiEndpoint = buildWorkflowApiEndpoint(baseUrl, workflowId)
const apiConfig = buildWorkflowApiConfig(baseUrl, apiEndpoint)
const apiExamples = buildWorkflowApiExamples(baseUrl, apiEndpoint)
return {
success: true,
output: {
workflowId,
isDeployed: true,
deployedAt: result.deployedAt,
version: result.version,
apiEndpoint,
baseUrl,
deploymentType: 'api',
deploymentStatus: {
api: {
isDeployed: true,
endpoint: apiEndpoint,
deployedAt: result.deployedAt,
version: result.version,
},
},
deploymentConfig: {
api: apiConfig,
},
examples: {
api: {
curl: apiExamples,
},
},
},
}
} catch (error) {
return { success: false, error: toError(error).message }
}
}
export async function executeDeployChat(
params: DeployChatParams,
context: ExecutionContext
): Promise<ToolCallResult> {
try {
const workflowId = params.workflowId || context.workflowId
if (!workflowId) {
return { success: false, error: 'workflowId is required' }
}
const action = params.action === 'undeploy' ? 'undeploy' : 'deploy'
if (action === 'undeploy') {
const baseUrl = getBaseUrl()
const apiEndpoint = buildWorkflowApiEndpoint(baseUrl, workflowId)
const apiConfig = buildWorkflowApiConfig(baseUrl, apiEndpoint)
const apiExamples = buildWorkflowApiExamples(baseUrl, apiEndpoint)
const existing = await db
.select()
.from(chat)
.where(and(eq(chat.workflowId, workflowId), isNull(chat.archivedAt)))
.limit(1)
if (!existing.length) {
return { success: false, error: 'No active chat deployment found for this workflow' }
}
const { hasAccess, workspaceId: chatWorkspaceId } = await checkChatAccess(
existing[0].id,
context.userId
)
if (!hasAccess) {
return { success: false, error: 'Unauthorized chat access' }
}
const undeployResult = await performChatUndeploy({
chatId: existing[0].id,
userId: context.userId,
workspaceId: chatWorkspaceId,
})
if (!undeployResult.success) {
return { success: false, error: undeployResult.error || 'Failed to undeploy chat' }
}
return {
success: true,
output: {
workflowId,
success: true,
action: 'undeploy',
isDeployed: true,
isChatDeployed: false,
deploymentType: 'chat',
apiEndpoint,
baseUrl,
deploymentStatus: {
api: {
isDeployed: true,
endpoint: apiEndpoint,
},
chat: {
isDeployed: false,
identifier: existing[0].identifier,
title: existing[0].title,
},
},
deploymentConfig: {
api: apiConfig,
chat: {
identifier: existing[0].identifier,
title: existing[0].title,
description: existing[0].description || '',
authType: existing[0].authType,
allowedEmails: (existing[0].allowedEmails as string[]) || [],
outputConfigs:
(existing[0].outputConfigs as Array<{ blockId: string; path: string }>) || [],
welcomeMessage:
(existing[0].customizations as { welcomeMessage?: string } | null)
?.welcomeMessage || 'Hi there! How can I help you today?',
},
},
examples: {
api: {
curl: apiExamples,
},
},
},
}
}
const { hasAccess, workflow: workflowRecord } = await checkWorkflowAccessForChatCreation(
workflowId,
context.userId
)
if (!hasAccess || !workflowRecord) {
return { success: false, error: 'Workflow not found or access denied' }
}
const [existingDeployment] = await db
.select()
.from(chat)
.where(and(eq(chat.workflowId, workflowId), isNull(chat.archivedAt)))
.limit(1)
const identifier = String(params.identifier || existingDeployment?.identifier || '').trim()
const title = String(params.title || existingDeployment?.title || '').trim()
if (!identifier || !title) {
return { success: false, error: 'Chat identifier and title are required' }
}
const versionDescription = params.versionDescription?.trim()
if (!versionDescription) {
return {
success: false,
error:
'versionDescription is required when deploying. Provide a concise summary of what changed in this deployment version (distinct from the chat-facing description; call diff_workflows with ref1 "live" and ref2 "draft" if unsure).',
}
}
const versionName = params.versionName?.trim()
if (!versionName) {
return {
success: false,
error:
'versionName is required when deploying. Provide a short human-readable label for this deployment version (distinct from the chat title).',
}
}
const identifierPattern = /^[a-z0-9-]+$/
if (!identifierPattern.test(identifier)) {
return {
success: false,
error: 'Identifier can only contain lowercase letters, numbers, and hyphens',
}
}
const existingIdentifier = await db
.select()
.from(chat)
.where(and(eq(chat.identifier, identifier), isNull(chat.archivedAt)))
.limit(1)
if (existingIdentifier.length > 0 && existingIdentifier[0].id !== existingDeployment?.id) {
return { success: false, error: 'Identifier already in use' }
}
const existingCustomizations =
(existingDeployment?.customizations as
| { primaryColor?: string; welcomeMessage?: string; imageUrl?: string }
| undefined) || {}
const resolvedDescription = String(params.description || existingDeployment?.description || '')
const resolvedAuthType = (params.authType || existingDeployment?.authType || 'public') as
| 'public'
| 'password'
| 'email'
| 'sso'
const resolvedAllowedEmails =
params.allowedEmails || (existingDeployment?.allowedEmails as string[]) || []
const resolvedOutputConfigs = (params.outputConfigs ||
existingDeployment?.outputConfigs ||
[]) as Array<{
blockId: string
path: string
}>
const welcomeMessage =
typeof params.welcomeMessage === 'string'
? params.welcomeMessage
: params.customizations?.welcomeMessage || existingCustomizations.welcomeMessage
const imageUrl =
params.customizations?.imageUrl ||
params.customizations?.iconUrl ||
existingCustomizations.imageUrl
const result = await performChatDeploy({
workflowId,
userId: context.userId,
identifier,
title,
description: resolvedDescription,
versionDescription,
versionName,
customizations: {
primaryColor:
params.customizations?.primaryColor ||
existingCustomizations.primaryColor ||
'var(--brand-hover)',
welcomeMessage: welcomeMessage || 'Hi there! How can I help you today?',
...(imageUrl ? { imageUrl } : {}),
},
authType: resolvedAuthType,
password: params.password,
allowedEmails: resolvedAllowedEmails,
outputConfigs: resolvedOutputConfigs,
workspaceId: workflowRecord.workspaceId,
})
if (!result.success) {
return { success: false, error: result.error || 'Failed to deploy chat' }
}
const baseUrl = getBaseUrl()
const apiEndpoint = buildWorkflowApiEndpoint(baseUrl, workflowId)
const apiConfig = buildWorkflowApiConfig(baseUrl, apiEndpoint)
const apiExamples = buildWorkflowApiExamples(baseUrl, apiEndpoint)
return {
success: true,
output: {
workflowId,
success: true,
action: 'deploy',
isDeployed: true,
isChatDeployed: true,
identifier,
chatUrl: result.chatUrl,
apiEndpoint,
baseUrl,
deployedAt: result.deployedAt || null,
version: result.version,
deploymentType: 'chat',
deploymentStatus: {
api: {
isDeployed: true,
endpoint: apiEndpoint,
deployedAt: result.deployedAt || null,
version: result.version,
},
chat: {
isDeployed: true,
identifier,
chatUrl: result.chatUrl,
title,
description: resolvedDescription,
authType: resolvedAuthType,
},
},
deploymentConfig: {
api: apiConfig,
chat: {
identifier,
chatUrl: result.chatUrl,
title,
description: resolvedDescription,
authType: resolvedAuthType,
allowedEmails: resolvedAllowedEmails,
outputConfigs: resolvedOutputConfigs,
welcomeMessage: welcomeMessage || 'Hi there! How can I help you today?',
primaryColor:
params.customizations?.primaryColor ||
existingCustomizations.primaryColor ||
'var(--brand-hover)',
...(imageUrl ? { imageUrl } : {}),
},
},
examples: {
chat: {
open: result.chatUrl,
},
api: {
curl: apiExamples,
},
},
},
}
} catch (error) {
return { success: false, error: toError(error).message }
}
}
export async function executeDeployMcp(
params: DeployMcpParams,
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,
'admin'
)
const workspaceId = workflowRecord.workspaceId
if (!workspaceId) {
return { success: false, error: 'workspaceId is required' }
}
const serverId = params.serverId
if (!serverId) {
return {
success: false,
error: 'serverId is required. Use list_workspace_mcp_servers to get available servers.',
}
}
const [serverRecord] = await db
.select({
id: workflowMcpServer.id,
name: workflowMcpServer.name,
})
.from(workflowMcpServer)
.where(
and(
eq(workflowMcpServer.id, serverId),
eq(workflowMcpServer.workspaceId, workspaceId),
isNull(workflowMcpServer.deletedAt)
)
)
.limit(1)
if (!serverRecord) {
return { success: false, error: 'MCP server not found in this workspace' }
}
// Handle undeploy action — remove workflow from MCP server
if (params.action === 'undeploy') {
const [existingTool] = await db
.select({ id: workflowMcpTool.id })
.from(workflowMcpTool)
.where(
and(
eq(workflowMcpTool.serverId, serverId),
eq(workflowMcpTool.workflowId, workflowId),
isNull(workflowMcpTool.archivedAt)
)
)
.limit(1)
if (!existingTool) {
return { success: false, error: 'Workflow is not deployed to this MCP server' }
}
const deleteResult = await performDeleteWorkflowMcpTool({
serverId,
toolId: existingTool.id,
workspaceId,
userId: context.userId,
})
if (!deleteResult.success) {
return { success: false, error: deleteResult.error || 'Failed to undeploy MCP tool' }
}
return {
success: true,
output: {
workflowId,
serverId,
serverName: serverRecord.name,
action: 'undeploy',
removed: true,
deploymentType: 'mcp',
deploymentStatus: {
mcp: {
isDeployed: false,
serverId,
serverName: serverRecord.name,
},
},
},
}
}
if (!workflowRecord.isDeployed) {
return {
success: false,
error: 'Workflow must be deployed before adding as an MCP tool. Use deploy_api first.',
}
}
const existingTool = await db
.select()
.from(workflowMcpTool)
.where(
and(
eq(workflowMcpTool.serverId, serverId),
eq(workflowMcpTool.workflowId, workflowId),
isNull(workflowMcpTool.archivedAt)
)
)
.limit(1)
const toolName = sanitizeToolName(
params.toolName || workflowRecord.name || `workflow_${workflowId}`
)
const toolDescription =
params.toolDescription?.trim() ||
getMeaningfulWorkflowDescription(workflowRecord.description, workflowRecord.name) ||
`Execute ${workflowRecord.name} workflow`
/**
* Parameter names/types come from the workflow's deployed input trigger; this tool only sets
* per-parameter descriptions, sent as sparse overrides. The materialized schema is echoed in the
* response for the model's reference.
*/
const inputFormat = await getDeployedWorkflowInputFormat(workflowId)
const parameterDescriptionOverrides = Object.fromEntries(
(params.parameterDescriptions ?? [])
.filter((entry) => entry && typeof entry.name === 'string' && entry.name.trim() !== '')
.map((entry) => [entry.name.trim(), (entry.description ?? '').trim()])
.filter(([, description]) => description !== '')
)
const parameterSchema = applyDescriptionOverrides(
generateToolInputSchema(inputFormat),
parameterDescriptionOverrides
)
const baseUrl = getBaseUrl()
const mcpServerUrl = `${baseUrl}/api/mcp/serve/${serverId}`
const apiEndpoint = buildWorkflowApiEndpoint(baseUrl, workflowId)
const clientExamples = buildMcpClientExamples(serverRecord.name, mcpServerUrl)
if (existingTool.length > 0) {
const toolId = existingTool[0].id
const updateResult = await performUpdateWorkflowMcpTool({
serverId,
toolId,
workspaceId,
userId: context.userId,
toolName,
toolDescription,
parameterDescriptionOverrides,
})
if (!updateResult.success || !updateResult.tool) {
return { success: false, error: updateResult.error || 'Failed to update MCP tool' }
}
return {
success: true,
output: {
toolId,
toolName,
toolDescription,
updated: true,
mcpServerUrl,
baseUrl,
serverId,
serverName: serverRecord.name,
deploymentType: 'mcp',
apiEndpoint,
deploymentStatus: {
api: {
isDeployed: true,
endpoint: apiEndpoint,
},
mcp: {
isDeployed: true,
serverId,
serverName: serverRecord.name,
toolId,
toolName,
updated: true,
},
},
deploymentConfig: {
mcp: {
serverId,
serverName: serverRecord.name,
serverUrl: mcpServerUrl,
toolId,
toolName,
toolDescription,
parameterSchema,
authentication: {
type: 'api_key',
header: 'X-API-Key: YOUR_API_KEY',
},
},
},
examples: {
mcp: clientExamples,
},
},
}
}
const createResult = await performCreateWorkflowMcpTool({
serverId,
workspaceId,
userId: context.userId,
workflowId,
toolName,
toolDescription,
parameterDescriptionOverrides,
})
if (!createResult.success || !createResult.tool) {
return { success: false, error: createResult.error || 'Failed to deploy MCP tool' }
}
const toolId = createResult.tool.id
return {
success: true,
output: {
toolId,
toolName,
toolDescription,
updated: false,
mcpServerUrl,
baseUrl,
serverId,
serverName: serverRecord.name,
deploymentType: 'mcp',
apiEndpoint,
deploymentStatus: {
api: {
isDeployed: true,
endpoint: apiEndpoint,
},
mcp: {
isDeployed: true,
serverId,
serverName: serverRecord.name,
toolId,
toolName,
updated: false,
},
},
deploymentConfig: {
mcp: {
serverId,
serverName: serverRecord.name,
serverUrl: mcpServerUrl,
toolId,
toolName,
toolDescription,
parameterSchema,
authentication: {
type: 'api_key',
header: 'X-API-Key: YOUR_API_KEY',
},
},
},
examples: {
mcp: clientExamples,
},
},
}
} catch (error) {
return { success: false, error: toError(error).message }
}
}
export async function executeRedeploy(
params: { workflowId?: string; versionDescription?: string; versionName?: string },
context: ExecutionContext
): Promise<ToolCallResult> {
try {
const workflowId = params.workflowId || context.workflowId
if (!workflowId) {
return { success: false, error: 'workflowId is required' }
}
const versionDescription = params.versionDescription?.trim()
if (!versionDescription) {
return {
success: false,
error:
'versionDescription is required. Provide a concise summary of what changed in this deployment version (call diff_workflows with ref1 "live" and ref2 "draft" if unsure what changed).',
}
}
const versionName = params.versionName?.trim()
if (!versionName) {
return {
success: false,
error:
'versionName is required. Provide a short human-readable label for this deployment version.',
}
}
await ensureWorkflowAccess(workflowId, context.userId, 'admin')
const result = await performFullDeploy({
workflowId,
userId: context.userId,
versionDescription,
versionName,
})
if (!result.success) {
return { success: false, error: result.error || 'Failed to redeploy workflow' }
}
const baseUrl = getBaseUrl()
const apiEndpoint = buildWorkflowApiEndpoint(baseUrl, workflowId)
const apiConfig = buildWorkflowApiConfig(baseUrl, apiEndpoint)
const apiExamples = buildWorkflowApiExamples(baseUrl, apiEndpoint)
return {
success: true,
output: {
workflowId,
isDeployed: true,
deployedAt: result.deployedAt || null,
version: result.version,
apiEndpoint,
baseUrl,
deploymentType: 'api',
deploymentStatus: {
api: {
isDeployed: true,
endpoint: apiEndpoint,
deployedAt: result.deployedAt || null,
version: result.version,
},
},
deploymentConfig: {
api: apiConfig,
},
examples: {
api: {
curl: apiExamples,
},
},
},
}
} catch (error) {
return { success: false, error: toError(error).message }
}
}