From a0726025e95ba0ed204c91cf24e0f87afb08fdc8 Mon Sep 17 00:00:00 2001 From: Maxwell Calkin <101308415+MaxwellCalkin@users.noreply.github.com> Date: Sun, 8 Mar 2026 21:08:53 -0400 Subject: [PATCH 1/2] fix: enforce workflow validation before deploy and run The deploy endpoint only validated schedule data but never checked workflow state (block types, edges, tool references). The Run button had validation hardcoded to `false`. Redeployments (Update) skipped all pre-deploy checks entirely. Changes: - Backend: call validateWorkflowState() before deploying to reject workflows with unknown block types, dangling edges, or invalid tool references (returns 400 with details) - Frontend panel: replace hardcoded `hasValidationErrors = false` with a check that blocks are connected via edges - Frontend deploy hook: run pre-deploy checks for redeployments too, not just first deploys Fixes #3444 This PR was authored by Claude Opus 4.6 (AI), operated by @MaxwellCalkin Co-Authored-By: Claude Opus 4.6 --- apps/sim/app/api/workflows/[id]/deploy/route.ts | 16 ++++++++++++++++ .../components/deploy/hooks/use-deployment.ts | 13 +++++++------ .../w/[workflowId]/components/panel/panel.tsx | 6 +++++- 3 files changed, 28 insertions(+), 7 deletions(-) diff --git a/apps/sim/app/api/workflows/[id]/deploy/route.ts b/apps/sim/app/api/workflows/[id]/deploy/route.ts index 1dd8798a3f2..03a0b09af3a 100644 --- a/apps/sim/app/api/workflows/[id]/deploy/route.ts +++ b/apps/sim/app/api/workflows/[id]/deploy/route.ts @@ -20,6 +20,7 @@ import { createSchedulesForDeploy, validateWorkflowSchedules, } from '@/lib/workflows/schedules' +import { validateWorkflowState } from '@/lib/workflows/sanitization/validation' import { validateWorkflowPermissions } from '@/lib/workflows/utils' import { createErrorResponse, createSuccessResponse } from '@/app/api/workflows/utils' import type { WorkflowState } from '@/stores/workflows/workflow/types' @@ -134,6 +135,21 @@ export async function POST(request: NextRequest, { params }: { params: Promise<{ return createErrorResponse('Failed to load workflow state', 500) } + // Validate workflow state (block types, edges, tool references) + const workflowValidation = validateWorkflowState({ + blocks: normalizedData.blocks, + edges: normalizedData.edges, + loops: normalizedData.loops, + parallels: normalizedData.parallels, + } as WorkflowState) + if (!workflowValidation.valid) { + const errorSummary = workflowValidation.errors.join('; ') + logger.warn( + `[${requestId}] Workflow validation failed for ${id}: ${errorSummary}` + ) + return createErrorResponse(`Workflow validation failed: ${errorSummary}`, 400) + } + const scheduleValidation = validateWorkflowSchedules(normalizedData.blocks) if (!scheduleValidation.isValid) { logger.warn( diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/hooks/use-deployment.ts b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/hooks/use-deployment.ts index b6a5d585e32..216582412ac 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/hooks/use-deployment.ts +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/hooks/use-deployment.ts @@ -28,16 +28,13 @@ export function useDeployment({ /** * Handle deploy button click - * First deploy: calls API to deploy, then opens modal on success - * Already deployed: opens modal directly (validation happens on Update in modal) + * First deploy: runs pre-deploy checks, calls API to deploy, then opens modal on success + * Already deployed: runs pre-deploy checks, then opens modal (redeployment happens in modal) */ const handleDeployClick = useCallback(async () => { if (!workflowId) return { success: false, shouldOpenModal: false } - if (isDeployed) { - return { success: true, shouldOpenModal: true } - } - + // Always run pre-deploy checks, even for redeployments const { blocks, edges, loops, parallels } = useWorkflowStore.getState() const liveBlocks = mergeSubblockState(blocks, workflowId) const checkResult = runPreDeployChecks({ @@ -56,6 +53,10 @@ export function useDeployment({ return { success: false, shouldOpenModal: false } } + if (isDeployed) { + return { success: true, shouldOpenModal: true } + } + setIsDeploying(true) try { const response = await fetch(`/api/workflows/${workflowId}/deploy`, { 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..8809bc20ecb 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 @@ -356,7 +356,11 @@ 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 + + // Validate workflow has connected blocks (at least one edge means blocks are wired together) + const hasEdges = useWorkflowStore((state) => state.edges.length > 0) + const hasValidationErrors = hasBlocks && !hasEdges + const isWorkflowBlocked = isExecuting || hasValidationErrors const isButtonDisabled = !isExecuting && (isWorkflowBlocked || (!canRun && !isLoadingPermissions)) From 2766a3963b115329266d5915c6239cac217bc651 Mon Sep 17 00:00:00 2001 From: Maxwell Calkin Date: Sun, 8 Mar 2026 21:19:15 -0400 Subject: [PATCH 2/2] fix: address review feedback on validation logic - Allow single-block workflows to run (blockCount > 1 check instead of hasBlocks && !hasEdges, which incorrectly blocked standalone blocks) - Guard Mod+Enter keyboard shortcut with isButtonDisabled to match Run button behavior and prevent bypassing validation - Add explicit variables: {} default in deploy route to avoid relying on type assertion to paper over a missing field Co-Authored-By: Claude Opus 4.6 --- apps/sim/app/api/workflows/[id]/deploy/route.ts | 1 + .../w/[workflowId]/components/panel/panel.tsx | 9 ++++++--- 2 files changed, 7 insertions(+), 3 deletions(-) diff --git a/apps/sim/app/api/workflows/[id]/deploy/route.ts b/apps/sim/app/api/workflows/[id]/deploy/route.ts index 03a0b09af3a..aae32b15d6d 100644 --- a/apps/sim/app/api/workflows/[id]/deploy/route.ts +++ b/apps/sim/app/api/workflows/[id]/deploy/route.ts @@ -141,6 +141,7 @@ export async function POST(request: NextRequest, { params }: { params: Promise<{ edges: normalizedData.edges, loops: normalizedData.loops, parallels: normalizedData.parallels, + variables: {}, } as WorkflowState) if (!workflowValidation.valid) { const errorSummary = workflowValidation.errors.join('; ') 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 8809bc20ecb..8b71c9b7cee 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 @@ -357,9 +357,12 @@ export const Panel = memo(function Panel() { const canRun = userPermissions.canRead // Running only requires read permissions const isLoadingPermissions = userPermissions.isLoading - // Validate workflow has connected blocks (at least one edge means blocks are wired together) + // Validate workflow has connected blocks when multiple blocks exist. + // A single-block workflow (e.g. one starter or agent block) is valid without edges, + // but multiple blocks with no edges indicates a disconnected graph. + const blockCount = useWorkflowStore((state) => Object.keys(state.blocks).length) const hasEdges = useWorkflowStore((state) => state.edges.length > 0) - const hasValidationErrors = hasBlocks && !hasEdges + const hasValidationErrors = blockCount > 1 && !hasEdges const isWorkflowBlocked = isExecuting || hasValidationErrors const isButtonDisabled = !isExecuting && (isWorkflowBlocked || (!canRun && !isLoadingPermissions)) @@ -377,7 +380,7 @@ export const Panel = memo(function Panel() { handler: () => { if (isExecuting) { void cancelWorkflow() - } else { + } else if (!isButtonDisabled) { void runWorkflow() } },