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 +}