Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 16 additions & 0 deletions apps/sim/app/api/workflows/[id]/deploy/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'

Expand Down Expand Up @@ -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(
Expand Down
Original file line number Diff line number Diff line change
@@ -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'
Expand Down Expand Up @@ -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'
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Import ordering violates project alias conventions

The import of validateWorkflowBlocks on line 58 appears after the store imports (@/stores/…), breaking the established grouping order where @/lib/… utilities come before @/stores/…. Per the project's import-path rules, library utilities should be in a distinct group above store imports.

Suggested change
import { validateWorkflowBlocks } from '@/lib/workflows/validation'
import { validateWorkflowBlocks } from '@/lib/workflows/validation'
import { useChatStore } from '@/stores/chat/store'

(Move the @/lib/workflows/validation import up into the @/lib/… import group, before the @/stores/… group.)

Rule Used: Import patterns for the Sim application (source)

Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!


const logger = createLogger('Panel')
/**
Expand Down Expand Up @@ -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))

Expand Down
99 changes: 99 additions & 0 deletions apps/sim/lib/workflows/validation.ts
Original file line number Diff line number Diff line change
@@ -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
Comment on lines +19 to +21
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Function variant of required silently mis-handled

The SubBlockConfig.required type includes a third variant — a callable: ((values?: Record<string, unknown>) => { field; value; not?; and? }). The current isSubBlockRequired only handles boolean and the object variant. When required is a function:

  1. It is not undefined, false, or true, so it falls through to the conditional-check branch.
  2. req.field on a function is undefined, so blockState.subBlocks[undefined as any] resolves to undefined.
  3. req.value is also undefined, so fieldValue === req.valuetrue.
  4. The function returns true, treating the field as always required.

This produces false-positive validation errors for any sub-block that conditionally declares required via a function — blocking deployment of valid workflows once such blocks are introduced.

The function should be called with the current block's sub-block values:

if (typeof req === 'function') {
  const values = Object.fromEntries(
    Object.entries(blockState.subBlocks).map(([k, v]) => [k, v?.value])
  )
  const resolved = req(values)
  // treat resolved like the object variant
  ...
}


// 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<string, BlockState>,
edges: Edge[]
): WorkflowValidationError[] {
const errors: WorkflowValidationError[] = []

// Build set of block IDs that have incoming edges
const blocksWithIncoming = new Set<string>()
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}`,
})
}
}
Comment on lines +67 to +86
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Multiple errors per block for the same missing field

The loop over blockConfig.subBlocks pushes one error per required-but-empty field. For a block missing three fields this generates three separate error objects. That is fine for the backend response message, but on the frontend validationErrors.length > 0 only tells the user something is wrong — there is no UI showing which blocks or fields need attention.

Consider deduplicating to at most one error per block (the first failing field), or grouping errors by blockId, so future UI work has a clean structure to display targeted feedback.

This is a style/UX suggestion; the current behavior is not broken.

Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!


// 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',
})
}
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Blocks inside containers always fail connection validation

High Severity

The "incoming connection" check doesn't account for blocks that are children of loop/parallel containers. Blocks inside a container have blockState.data?.parentId set, but the first block inside any container won't have an incoming edge — it's the loop/parallel entry point. The validation incorrectly flags these as "not connected to any input," producing false errors that prevent deploying or running any workflow using loops or parallels. Blocks with a parentId need to be excluded from the incoming-edge requirement.

Additional Locations (1)

Fix in Cursor Fix in Web

}

return errors
}