Files
sim/apps/sim/app/api/workflows/[id]/deploy/route.ts
Waleed 395a61d1b6 fix(deploy): consolidate deployment detection into single source of truth (#3606)
* fix(deploy): consolidate deployment detection into single source of truth

* fix(deploy): address PR review feedback

- Remove redundant isLoading check (subset of isPending in RQ v5)
- Make deployedState prop nullable to avoid non-null assertion
- Destructure mutateAsync to eliminate ESLint suppression
- Guard debounced invalidation against stale workflowId on switch

* fix(deploy): clean up self-explanatory comments and fix unstable mutation dep

- Remove self-explanatory comments in deploy.tsx, tool-input.tsx, use-child-workflow.ts
- Tighten non-obvious comments in use-change-detection.ts
- Destructure mutate/isPending in WorkflowToolDeployBadge to avoid unstable
  mutation object in useCallback deps (TanStack Query no-unstable-deps pattern)

* lint

* fix(deploy): skip expensive state merge when deployedState is null

Avoid running mergeSubblockStateWithValues on every render for
non-deployed workflows where changeDetected always returns false.

* fix(deploy): add missing workflow table import in deploy route

Pre-existing type error — workflow table was used but not imported.

* fix(deploy): forward AbortSignal in fetchDeployedWorkflowState

Match the pattern used by all other fetch helpers in the file so
in-flight requests are cancelled on component unmount or query re-trigger.

* perf(deploy): parallelize all DB queries in checkNeedsRedeployment

All three queries (active version, normalized data, workflow variables)
now run concurrently via Promise.all, saving one DB round trip on the
common path.

* fix(deploy): use sequential-then-parallel pattern in checkNeedsRedeployment

* fix(deploy): use client-side comparison for editor header, remove server polling

The lastSaved-based server polling was triggering API calls on every
local store mutation (before socket persistence), wasting requests and
checking stale DB state. Revert the editor header to pure client-side
hasWorkflowChanged comparison — zero network during editing, instant
badge updates. Child workflow badges still use server-side
useDeploymentInfo (they don't have Zustand state).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix(deploy): suppress transient Update flash during deployed state refetch

Guard change detection on isFetching (not just isLoading) so the
comparison is suppressed during background refetches after mutations,
preventing a brief Update→Live badge flicker.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-16 14:38:57 -07:00

388 lines
12 KiB
TypeScript

import { db, workflow, workflowDeploymentVersion } from '@sim/db'
import { createLogger } from '@sim/logger'
import { and, eq } from 'drizzle-orm'
import type { NextRequest } from 'next/server'
import { AuditAction, AuditResourceType, recordAudit } from '@/lib/audit/log'
import { generateRequestId } from '@/lib/core/utils/request'
import { removeMcpToolsForWorkflow, syncMcpToolsForWorkflow } from '@/lib/mcp/workflow-mcp-sync'
import {
cleanupWebhooksForWorkflow,
restorePreviousVersionWebhooks,
saveTriggerWebhooksForDeploy,
} from '@/lib/webhooks/deploy'
import {
activateWorkflowVersionById,
deployWorkflow,
loadWorkflowFromNormalizedTables,
undeployWorkflow,
} from '@/lib/workflows/persistence/utils'
import {
cleanupDeploymentVersion,
createSchedulesForDeploy,
validateWorkflowSchedules,
} from '@/lib/workflows/schedules'
import { validateWorkflowPermissions } from '@/lib/workflows/utils'
import {
checkNeedsRedeployment,
createErrorResponse,
createSuccessResponse,
} from '@/app/api/workflows/utils'
const logger = createLogger('WorkflowDeployAPI')
export const dynamic = 'force-dynamic'
export const runtime = 'nodejs'
export async function GET(request: NextRequest, { params }: { params: Promise<{ id: string }> }) {
const requestId = generateRequestId()
const { id } = await params
try {
const { error, workflow: workflowData } = await validateWorkflowPermissions(
id,
requestId,
'read'
)
if (error) {
return createErrorResponse(error.message, error.status)
}
if (!workflowData.isDeployed) {
logger.info(`[${requestId}] Workflow is not deployed: ${id}`)
return createSuccessResponse({
isDeployed: false,
deployedAt: null,
apiKey: null,
needsRedeployment: false,
isPublicApi: workflowData.isPublicApi ?? false,
})
}
const needsRedeployment = await checkNeedsRedeployment(id)
logger.info(`[${requestId}] Successfully retrieved deployment info: ${id}`)
const responseApiKeyInfo = workflowData.workspaceId ? 'Workspace API keys' : 'Personal API keys'
return createSuccessResponse({
apiKey: responseApiKeyInfo,
isDeployed: workflowData.isDeployed,
deployedAt: workflowData.deployedAt,
needsRedeployment,
isPublicApi: workflowData.isPublicApi ?? false,
})
} catch (error: any) {
logger.error(`[${requestId}] Error fetching deployment info: ${id}`, error)
return createErrorResponse(error.message || 'Failed to fetch deployment information', 500)
}
}
export async function POST(request: NextRequest, { params }: { params: Promise<{ id: string }> }) {
const requestId = generateRequestId()
const { id } = await params
try {
const {
error,
session,
workflow: workflowData,
} = await validateWorkflowPermissions(id, requestId, 'admin')
if (error) {
return createErrorResponse(error.message, error.status)
}
const actorUserId: string | null = session?.user?.id ?? null
if (!actorUserId) {
logger.warn(`[${requestId}] Unable to resolve actor user for workflow deployment: ${id}`)
return createErrorResponse('Unable to determine deploying user', 400)
}
const normalizedData = await loadWorkflowFromNormalizedTables(id)
if (!normalizedData) {
return createErrorResponse('Failed to load workflow state', 500)
}
const scheduleValidation = validateWorkflowSchedules(normalizedData.blocks)
if (!scheduleValidation.isValid) {
logger.warn(
`[${requestId}] Schedule validation failed for workflow ${id}: ${scheduleValidation.error}`
)
return createErrorResponse(`Invalid schedule configuration: ${scheduleValidation.error}`, 400)
}
const [currentActiveVersion] = await db
.select({ id: workflowDeploymentVersion.id })
.from(workflowDeploymentVersion)
.where(
and(
eq(workflowDeploymentVersion.workflowId, id),
eq(workflowDeploymentVersion.isActive, true)
)
)
.limit(1)
const previousVersionId = currentActiveVersion?.id
const rollbackDeployment = async () => {
if (previousVersionId) {
await restorePreviousVersionWebhooks({
request,
workflow: workflowData as Record<string, unknown>,
userId: actorUserId,
previousVersionId,
requestId,
})
const reactivateResult = await activateWorkflowVersionById({
workflowId: id,
deploymentVersionId: previousVersionId,
})
if (reactivateResult.success) {
return
}
}
await undeployWorkflow({ workflowId: id })
}
const deployResult = await deployWorkflow({
workflowId: id,
deployedBy: actorUserId,
workflowName: workflowData!.name,
})
if (!deployResult.success) {
return createErrorResponse(deployResult.error || 'Failed to deploy workflow', 500)
}
const deployedAt = deployResult.deployedAt!
const deploymentVersionId = deployResult.deploymentVersionId
if (!deploymentVersionId) {
await undeployWorkflow({ workflowId: id })
return createErrorResponse('Failed to resolve deployment version', 500)
}
const triggerSaveResult = await saveTriggerWebhooksForDeploy({
request,
workflowId: id,
workflow: workflowData,
userId: actorUserId,
blocks: normalizedData.blocks,
requestId,
deploymentVersionId,
previousVersionId,
})
if (!triggerSaveResult.success) {
await cleanupDeploymentVersion({
workflowId: id,
workflow: workflowData as Record<string, unknown>,
requestId,
deploymentVersionId,
})
await rollbackDeployment()
return createErrorResponse(
triggerSaveResult.error?.message || 'Failed to save trigger configuration',
triggerSaveResult.error?.status || 500
)
}
let scheduleInfo: { scheduleId?: string; cronExpression?: string; nextRunAt?: Date } = {}
const scheduleResult = await createSchedulesForDeploy(
id,
normalizedData.blocks,
db,
deploymentVersionId
)
if (!scheduleResult.success) {
logger.error(
`[${requestId}] Failed to create schedule for workflow ${id}: ${scheduleResult.error}`
)
await cleanupDeploymentVersion({
workflowId: id,
workflow: workflowData as Record<string, unknown>,
requestId,
deploymentVersionId,
})
await rollbackDeployment()
return createErrorResponse(scheduleResult.error || 'Failed to create schedule', 500)
}
if (scheduleResult.scheduleId) {
scheduleInfo = {
scheduleId: scheduleResult.scheduleId,
cronExpression: scheduleResult.cronExpression,
nextRunAt: scheduleResult.nextRunAt,
}
logger.info(
`[${requestId}] Schedule created for workflow ${id}: ${scheduleResult.scheduleId}`
)
}
if (previousVersionId && previousVersionId !== deploymentVersionId) {
try {
logger.info(`[${requestId}] Cleaning up previous version ${previousVersionId} DB records`)
await cleanupDeploymentVersion({
workflowId: id,
workflow: workflowData as Record<string, unknown>,
requestId,
deploymentVersionId: previousVersionId,
skipExternalCleanup: true,
})
} catch (cleanupError) {
logger.error(
`[${requestId}] Failed to clean up previous version ${previousVersionId}`,
cleanupError
)
// Non-fatal - continue with success response
}
}
logger.info(`[${requestId}] Workflow deployed successfully: ${id}`)
// Sync MCP tools with the latest parameter schema
await syncMcpToolsForWorkflow({ workflowId: id, requestId, context: 'deploy' })
recordAudit({
workspaceId: workflowData?.workspaceId || null,
actorId: actorUserId,
actorName: session?.user?.name,
actorEmail: session?.user?.email,
action: AuditAction.WORKFLOW_DEPLOYED,
resourceType: AuditResourceType.WORKFLOW,
resourceId: id,
resourceName: workflowData?.name,
description: `Deployed workflow "${workflowData?.name || id}"`,
metadata: { version: deploymentVersionId },
request,
})
const responseApiKeyInfo = workflowData!.workspaceId
? 'Workspace API keys'
: 'Personal API keys'
return createSuccessResponse({
apiKey: responseApiKeyInfo,
isDeployed: true,
deployedAt,
schedule: scheduleInfo.scheduleId
? {
id: scheduleInfo.scheduleId,
cronExpression: scheduleInfo.cronExpression,
nextRunAt: scheduleInfo.nextRunAt,
}
: undefined,
warnings: triggerSaveResult.warnings,
})
} catch (error: any) {
logger.error(`[${requestId}] Error deploying workflow: ${id}`, {
error: error.message,
stack: error.stack,
name: error.name,
cause: error.cause,
fullError: error,
})
return createErrorResponse(error.message || 'Failed to deploy workflow', 500)
}
}
export async function PATCH(request: NextRequest, { params }: { params: Promise<{ id: string }> }) {
const requestId = generateRequestId()
const { id } = await params
try {
const { error, session } = await validateWorkflowPermissions(id, requestId, 'admin')
if (error) {
return createErrorResponse(error.message, error.status)
}
const body = await request.json()
const { isPublicApi } = body
if (typeof isPublicApi !== 'boolean') {
return createErrorResponse('Invalid request body: isPublicApi must be a boolean', 400)
}
if (isPublicApi) {
const { validatePublicApiAllowed, PublicApiNotAllowedError } = await import(
'@/ee/access-control/utils/permission-check'
)
try {
await validatePublicApiAllowed(session?.user?.id)
} catch (err) {
if (err instanceof PublicApiNotAllowedError) {
return createErrorResponse('Public API access is disabled', 403)
}
throw err
}
}
await db.update(workflow).set({ isPublicApi }).where(eq(workflow.id, id))
logger.info(`[${requestId}] Updated isPublicApi for workflow ${id} to ${isPublicApi}`)
return createSuccessResponse({ isPublicApi })
} catch (error: unknown) {
const message = error instanceof Error ? error.message : 'Failed to update deployment settings'
logger.error(`[${requestId}] Error updating deployment settings: ${id}`, { error })
return createErrorResponse(message, 500)
}
}
export async function DELETE(
request: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
const requestId = generateRequestId()
const { id } = await params
try {
const {
error,
session,
workflow: workflowData,
} = await validateWorkflowPermissions(id, requestId, 'admin')
if (error) {
return createErrorResponse(error.message, error.status)
}
const result = await undeployWorkflow({ workflowId: id })
if (!result.success) {
return createErrorResponse(result.error || 'Failed to undeploy workflow', 500)
}
await cleanupWebhooksForWorkflow(id, workflowData as Record<string, unknown>, requestId)
await removeMcpToolsForWorkflow(id, requestId)
logger.info(`[${requestId}] Workflow undeployed successfully: ${id}`)
try {
const { PlatformEvents } = await import('@/lib/core/telemetry')
PlatformEvents.workflowUndeployed({ workflowId: id })
} catch (_e) {
// Silently fail
}
recordAudit({
workspaceId: workflowData?.workspaceId || null,
actorId: session!.user.id,
actorName: session?.user?.name,
actorEmail: session?.user?.email,
action: AuditAction.WORKFLOW_UNDEPLOYED,
resourceType: AuditResourceType.WORKFLOW,
resourceId: id,
resourceName: workflowData?.name,
description: `Undeployed workflow "${workflowData?.name || id}"`,
request,
})
return createSuccessResponse({
isDeployed: false,
deployedAt: null,
apiKey: null,
})
} catch (error: any) {
logger.error(`[${requestId}] Error undeploying workflow: ${id}`, error)
return createErrorResponse(error.message || 'Failed to undeploy workflow', 500)
}
}