From 9f05a1fdc589d86be9bc0fa65bf2ff5530fda493 Mon Sep 17 00:00:00 2001 From: GitHub User Date: Tue, 10 Mar 2026 09:07:19 +0000 Subject: [PATCH] fix: enforce workflow validation before deploy and run Previously, hasValidationErrors was hardcoded to false in panel.tsx, and the deploy API only validated schedule data. This allowed users to deploy completely broken/unconfigured workflows. Frontend (panel.tsx): - Replace hardcoded `false` with actual validation using validateWorkflowBlocks() that checks required sub-block fields and block connectivity - Deploy/Run buttons are now disabled when validation errors exist Backend (deploy/route.ts): - Call validateWorkflowBlocks() before deploying - Return 400 with detailed error messages when validation fails New file (lib/workflows/validation.ts): - validateWorkflowBlocks(): checks that all required sub-block fields are filled and non-trigger blocks have incoming connections - Supports conditional required fields (field-dependent validation) - Skips disabled blocks and container nodes (loops, parallels) Fixes #3444 --- .../app/api/workflows/[id]/deploy/route.ts | 16 +++ .../w/[workflowId]/components/panel/panel.tsx | 10 +- apps/sim/lib/workflows/validation.ts | 99 +++++++++++++++++++ 3 files changed, 123 insertions(+), 2 deletions(-) create mode 100644 apps/sim/lib/workflows/validation.ts diff --git a/apps/sim/app/api/workflows/[id]/deploy/route.ts b/apps/sim/app/api/workflows/[id]/deploy/route.ts index 1dd8798a3f2..e6acd192a5b 100644 --- a/apps/sim/app/api/workflows/[id]/deploy/route.ts +++ b/apps/sim/app/api/workflows/[id]/deploy/route.ts @@ -21,6 +21,7 @@ import { validateWorkflowSchedules, } from '@/lib/workflows/schedules' import { validateWorkflowPermissions } from '@/lib/workflows/utils' +import { validateWorkflowBlocks } from '@/lib/workflows/validation' 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 blocks have required fields and are connected + const blockValidationErrors = validateWorkflowBlocks( + normalizedData.blocks, + normalizedData.edges + ) + if (blockValidationErrors.length > 0) { + logger.warn( + `[${requestId}] Workflow validation failed for ${id}: ${blockValidationErrors.length} error(s)` + ) + return createErrorResponse( + `Workflow has validation errors: ${blockValidationErrors.map((e) => `${e.blockName}: ${e.message}`).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..9ebe4147544 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' @@ -55,6 +55,7 @@ import { useVariablesStore } from '@/stores/variables/store' import { getWorkflowWithValues } from '@/stores/workflows' import { useWorkflowRegistry } from '@/stores/workflows/registry/store' import { useWorkflowStore } from '@/stores/workflows/workflow/store' +import { validateWorkflowBlocks } from '@/lib/workflows/validation' const logger = createLogger('Panel') /** @@ -356,7 +357,12 @@ 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 + + const blocks = useWorkflowStore((state) => state.blocks) + const edges = useWorkflowStore((state) => state.edges) + const validationErrors = useMemo(() => validateWorkflowBlocks(blocks, edges), [blocks, edges]) + const hasValidationErrors = validationErrors.length > 0 + const isWorkflowBlocked = isExecuting || hasValidationErrors const isButtonDisabled = !isExecuting && (isWorkflowBlocked || (!canRun && !isLoadingPermissions)) diff --git a/apps/sim/lib/workflows/validation.ts b/apps/sim/lib/workflows/validation.ts new file mode 100644 index 00000000000..4bbd7b12ade --- /dev/null +++ b/apps/sim/lib/workflows/validation.ts @@ -0,0 +1,99 @@ +import type { Edge } from 'reactflow' +import { getBlock } from '@/blocks/registry' +import type { SubBlockConfig } from '@/blocks/types' +import type { BlockState } from '@/stores/workflows/workflow/types' + +export interface WorkflowValidationError { + blockId: string + blockName: string + message: string +} + +/** + * Check whether a sub-block's `required` condition is satisfied given current block state. + */ +function isSubBlockRequired( + subBlockConfig: SubBlockConfig, + blockState: BlockState +): boolean { + const req = subBlockConfig.required + if (req === undefined || req === false) return false + if (req === true) return true + + // Conditional requirement: check field value + const fieldValue = blockState.subBlocks[req.field]?.value + const matches = Array.isArray(req.value) + ? req.value.includes(fieldValue as string | number | boolean) + : fieldValue === req.value + const fieldSatisfied = req.not ? !matches : matches + + if (req.and) { + const andValue = blockState.subBlocks[req.and.field]?.value + const andMatches = Array.isArray(req.and.value) + ? req.and.value.includes(andValue as string | number | boolean) + : andValue === req.and.value + const andSatisfied = req.and.not ? !andMatches : andMatches + return fieldSatisfied && andSatisfied + } + + return fieldSatisfied +} + +/** + * Validate that all required sub-block fields in a workflow are filled + * and that non-trigger blocks have at least one incoming connection. + */ +export function validateWorkflowBlocks( + blocks: Record, + edges: Edge[] +): WorkflowValidationError[] { + const errors: WorkflowValidationError[] = [] + + // Build set of block IDs that have incoming edges + const blocksWithIncoming = new Set() + for (const edge of edges) { + blocksWithIncoming.add(edge.target) + } + + for (const [blockId, blockState] of Object.entries(blocks)) { + if (!blockState.enabled) continue + + const blockConfig = getBlock(blockState.type) + if (!blockConfig) continue + + // Skip container-type blocks (loops, parallels) + if (blockState.data?.type === 'loop' || blockState.data?.type === 'parallel') continue + + // Check required sub-block fields + for (const subBlockConfig of blockConfig.subBlocks) { + if (!isSubBlockRequired(subBlockConfig, blockState)) continue + + const subBlockState = blockState.subBlocks[subBlockConfig.id] + const value = subBlockState?.value + const isEmpty = + value === null || + value === undefined || + value === '' || + (Array.isArray(value) && value.length === 0) + + if (isEmpty) { + errors.push({ + blockId, + blockName: blockState.name, + message: `Missing required field: ${subBlockConfig.title || subBlockConfig.id}`, + }) + } + } + + // Non-trigger blocks should have at least one incoming connection + if (blockConfig.category !== 'triggers' && !blocksWithIncoming.has(blockId)) { + errors.push({ + blockId, + blockName: blockState.name, + message: 'Block is not connected to any input', + }) + } + } + + return errors +}