diff --git a/apps/sim/app/api/workflows/[id]/deploy/route.ts b/apps/sim/app/api/workflows/[id]/deploy/route.ts index 1dd8798a3f2..1a7e505c2af 100644 --- a/apps/sim/app/api/workflows/[id]/deploy/route.ts +++ b/apps/sim/app/api/workflows/[id]/deploy/route.ts @@ -134,6 +134,24 @@ export async function POST(request: NextRequest, { params }: { params: Promise<{ return createErrorResponse('Failed to load workflow state', 500) } + // Validate workflow state before allowing deployment + const { validateWorkflowState } = await import('@/lib/workflows/sanitization/validation') + const stateValidation = validateWorkflowState({ + blocks: normalizedData.blocks, + edges: normalizedData.edges, + loops: normalizedData.loops || {}, + parallels: normalizedData.parallels || {}, + }) + if (!stateValidation.valid) { + logger.warn( + `[${requestId}] Workflow validation failed for ${id}: ${stateValidation.errors.join('; ')}` + ) + return createErrorResponse( + `Workflow has validation errors: ${stateValidation.errors.join('; ')}`, + 400 + ) + } + const scheduleValidation = validateWorkflowSchedules(normalizedData.blocks) if (!scheduleValidation.isValid) { logger.warn( diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/panel.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/panel.tsx index 47e6e420709..8a6af1462f5 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/panel.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/panel.tsx @@ -1,6 +1,6 @@ 'use client' -import { memo, useCallback, useEffect, useRef, useState } from 'react' +import { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react' import { createLogger } from '@sim/logger' import { ArrowUp, Lock, Square, Unlock } from 'lucide-react' import { useParams, useRouter } from 'next/navigation' @@ -26,6 +26,7 @@ import { } from '@/components/emcn' import { VariableIcon } from '@/components/icons' import { generateWorkflowJson } from '@/lib/workflows/operations/import-export' +import { validateWorkflowState } from '@/lib/workflows/sanitization/validation' import { useRegisterGlobalCommands } from '@/app/workspace/[workspaceId]/providers/global-commands-provider' import { useUserPermissionsContext } from '@/app/workspace/[workspaceId]/providers/workspace-permissions-provider' import { createCommands } from '@/app/workspace/[workspaceId]/utils/commands-utils' @@ -356,7 +357,25 @@ export const Panel = memo(function Panel() { // Compute run button state const canRun = userPermissions.canRead // Running only requires read permissions const isLoadingPermissions = userPermissions.isLoading - const hasValidationErrors = false // TODO: Add validation logic if needed + // Memoize workflow structure to avoid re-validating on every store change + // (e.g. block dragging at 60fps, text input, etc.) + const blocks = useWorkflowStore((state) => state.blocks) + const edges = useWorkflowStore((state) => state.edges) + const loops = useWorkflowStore((state) => state.loops) + const parallels = useWorkflowStore((state) => state.parallels) + + const hasValidationErrors = useMemo(() => { + if (Object.keys(blocks).length === 0) return false + // Pass shallow copies to validateWorkflowState to prevent any + // internal mutation from affecting Zustand store state + const result = validateWorkflowState({ + blocks: { ...blocks }, + edges: [...edges], + loops: { ...(loops || {}) }, + parallels: { ...(parallels || {}) }, + }) + return !result.valid + }, [blocks, edges, loops, parallels]) const isWorkflowBlocked = isExecuting || hasValidationErrors const isButtonDisabled = !isExecuting && (isWorkflowBlocked || (!canRun && !isLoadingPermissions))