{isCountMode
? `${iterationType === 'loop' ? 'Loop' : 'Parallel'} Iterations`
- : `${iterationType === 'loop' ? 'Collection' : 'Parallel'} Items`}
+ : isConditionMode
+ ? 'While Condition'
+ : `${iterationType === 'loop' ? 'Collection' : 'Parallel'} Items`}
{isCountMode ? (
@@ -289,6 +305,44 @@ export function IterationBadges({ nodeId, data, iterationType }: IterationBadges
autoFocus
/>
+ ) : isConditionMode ? (
+ // Code editor for while condition
+
+
+ {conditionString === '' && (
+
+ {' < 10'}
+
+ )}
+
highlight(code, languages.javascript, 'javascript')}
+ padding={0}
+ style={{
+ fontFamily: 'monospace',
+ lineHeight: '21px',
+ }}
+ className='w-full focus:outline-none'
+ textareaClassName='focus:outline-none focus:ring-0 bg-transparent resize-none w-full overflow-hidden whitespace-pre-wrap'
+ />
+
+
+ JavaScript expression that evaluates to true/false. Type "{'<'}" to reference
+ blocks.
+
+ {showTagDropdown && (
+
setShowTagDropdown(false)}
+ />
+ )}
+
) : (
// Code editor for collection-based mode
diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/components/sub-block/components/index.ts b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/components/sub-block/components/index.ts
index 82a4ed8aae..3d924266f1 100644
--- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/components/sub-block/components/index.ts
+++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/components/sub-block/components/index.ts
@@ -28,4 +28,5 @@ export { Table } from './table'
export { TimeInput } from './time-input'
export { ToolInput } from './tool-input/tool-input'
export { TriggerConfig } from './trigger-config/trigger-config'
+export { VariablesInput } from './variables-input/variables-input'
export { WebhookConfig } from './webhook/webhook'
diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/components/sub-block/components/variables-input/variables-input.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/components/sub-block/components/variables-input/variables-input.tsx
new file mode 100644
index 0000000000..fd15bd1a58
--- /dev/null
+++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/components/sub-block/components/variables-input/variables-input.tsx
@@ -0,0 +1,396 @@
+import { useRef, useState } from 'react'
+import { Plus, Trash } from 'lucide-react'
+import { useParams } from 'next/navigation'
+import { Badge } from '@/components/ui/badge'
+import { Button } from '@/components/ui/button'
+import { formatDisplayText } from '@/components/ui/formatted-text'
+import { Input } from '@/components/ui/input'
+import { Label } from '@/components/ui/label'
+import {
+ Select,
+ SelectContent,
+ SelectItem,
+ SelectTrigger,
+ SelectValue,
+} from '@/components/ui/select'
+import { checkTagTrigger, TagDropdown } from '@/components/ui/tag-dropdown'
+import { Textarea } from '@/components/ui/textarea'
+import { cn } from '@/lib/utils'
+import { useSubBlockValue } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/components/sub-block/hooks/use-sub-block-value'
+import { useAccessibleReferencePrefixes } from '@/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-accessible-reference-prefixes'
+import { useVariablesStore } from '@/stores/panel/variables/store'
+import type { Variable } from '@/stores/panel/variables/types'
+
+interface VariableAssignment {
+ id: string
+ variableId?: string
+ variableName: string
+ type: 'string' | 'plain' | 'number' | 'boolean' | 'object' | 'array' | 'json'
+ value: string
+ isExisting: boolean
+}
+
+interface VariablesInputProps {
+ blockId: string
+ subBlockId: string
+ isPreview?: boolean
+ previewValue?: VariableAssignment[] | null
+ disabled?: boolean
+ isConnecting?: boolean
+}
+
+const DEFAULT_ASSIGNMENT: Omit
= {
+ variableName: '',
+ type: 'string',
+ value: '',
+ isExisting: false,
+}
+
+export function VariablesInput({
+ blockId,
+ subBlockId,
+ isPreview = false,
+ previewValue,
+ disabled = false,
+ isConnecting = false,
+}: VariablesInputProps) {
+ const params = useParams()
+ const workflowId = params.workflowId as string
+ const [storeValue, setStoreValue] = useSubBlockValue(blockId, subBlockId)
+ const { variables: workflowVariables } = useVariablesStore()
+ const accessiblePrefixes = useAccessibleReferencePrefixes(blockId)
+
+ const [showTags, setShowTags] = useState(false)
+ const [cursorPosition, setCursorPosition] = useState(0)
+ const [activeFieldId, setActiveFieldId] = useState(null)
+ const [activeSourceBlockId, setActiveSourceBlockId] = useState(null)
+ const valueInputRefs = useRef>({})
+ const overlayRefs = useRef>({})
+ const [dragHighlight, setDragHighlight] = useState>({})
+
+ const currentWorkflowVariables = Object.values(workflowVariables).filter(
+ (v: Variable) => v.workflowId === workflowId
+ )
+
+ const value = isPreview ? previewValue : storeValue
+ const assignments: VariableAssignment[] = value || []
+
+ const getAvailableVariablesFor = (currentAssignmentId: string) => {
+ const otherSelectedIds = new Set(
+ assignments
+ .filter((a) => a.id !== currentAssignmentId)
+ .map((a) => a.variableId)
+ .filter((id): id is string => !!id)
+ )
+
+ return currentWorkflowVariables.filter((variable) => !otherSelectedIds.has(variable.id))
+ }
+
+ const hasNoWorkflowVariables = currentWorkflowVariables.length === 0
+ const allVariablesAssigned =
+ !hasNoWorkflowVariables && getAvailableVariablesFor('new').length === 0
+
+ const addAssignment = () => {
+ if (isPreview || disabled) return
+
+ const newAssignment: VariableAssignment = {
+ ...DEFAULT_ASSIGNMENT,
+ id: crypto.randomUUID(),
+ }
+ setStoreValue([...(assignments || []), newAssignment])
+ }
+
+ const removeAssignment = (id: string) => {
+ if (isPreview || disabled) return
+ setStoreValue((assignments || []).filter((a) => a.id !== id))
+ }
+
+ const updateAssignment = (id: string, updates: Partial) => {
+ if (isPreview || disabled) return
+ setStoreValue((assignments || []).map((a) => (a.id === id ? { ...a, ...updates } : a)))
+ }
+
+ const handleVariableSelect = (assignmentId: string, variableId: string) => {
+ const selectedVariable = currentWorkflowVariables.find((v) => v.id === variableId)
+ if (selectedVariable) {
+ updateAssignment(assignmentId, {
+ variableId: selectedVariable.id,
+ variableName: selectedVariable.name,
+ type: selectedVariable.type as any,
+ isExisting: true,
+ })
+ }
+ }
+
+ const handleTagSelect = (tag: string) => {
+ if (!activeFieldId) return
+
+ const assignment = assignments.find((a) => a.id === activeFieldId)
+ if (!assignment) return
+
+ const currentValue = assignment.value || ''
+
+ const textBeforeCursor = currentValue.slice(0, cursorPosition)
+ const lastOpenBracket = textBeforeCursor.lastIndexOf('<')
+
+ const newValue =
+ currentValue.slice(0, lastOpenBracket) + tag + currentValue.slice(cursorPosition)
+
+ updateAssignment(activeFieldId, { value: newValue })
+ setShowTags(false)
+
+ setTimeout(() => {
+ const inputEl = valueInputRefs.current[activeFieldId]
+ if (inputEl) {
+ inputEl.focus()
+ const newCursorPos = lastOpenBracket + tag.length
+ inputEl.setSelectionRange(newCursorPos, newCursorPos)
+ }
+ }, 10)
+ }
+
+ const handleValueInputChange = (
+ assignmentId: string,
+ newValue: string,
+ selectionStart?: number
+ ) => {
+ updateAssignment(assignmentId, { value: newValue })
+
+ if (selectionStart !== undefined) {
+ setCursorPosition(selectionStart)
+ setActiveFieldId(assignmentId)
+
+ const shouldShowTags = checkTagTrigger(newValue, selectionStart)
+ setShowTags(shouldShowTags.show)
+
+ if (shouldShowTags.show) {
+ const textBeforeCursor = newValue.slice(0, selectionStart)
+ const lastOpenBracket = textBeforeCursor.lastIndexOf('<')
+ const tagContent = textBeforeCursor.slice(lastOpenBracket + 1)
+ const dotIndex = tagContent.indexOf('.')
+ const sourceBlock = dotIndex > 0 ? tagContent.slice(0, dotIndex) : null
+ setActiveSourceBlockId(sourceBlock)
+ }
+ }
+ }
+
+ const handleDrop = (e: React.DragEvent, assignmentId: string) => {
+ e.preventDefault()
+ setDragHighlight((prev) => ({ ...prev, [assignmentId]: false }))
+
+ const tag = e.dataTransfer.getData('text/plain')
+ if (tag?.startsWith('<')) {
+ const assignment = assignments.find((a) => a.id === assignmentId)
+ if (!assignment) return
+
+ const currentValue = assignment.value || ''
+ updateAssignment(assignmentId, { value: currentValue + tag })
+ }
+ }
+
+ const handleDragOver = (e: React.DragEvent, assignmentId: string) => {
+ e.preventDefault()
+ setDragHighlight((prev) => ({ ...prev, [assignmentId]: true }))
+ }
+
+ const handleDragLeave = (e: React.DragEvent, assignmentId: string) => {
+ e.preventDefault()
+ setDragHighlight((prev) => ({ ...prev, [assignmentId]: false }))
+ }
+
+ if (isPreview && (!assignments || assignments.length === 0)) {
+ return (
+
+ No variable assignments defined
+
+ )
+ }
+
+ return (
+
+ {assignments && assignments.length > 0 ? (
+
+ {assignments.map((assignment) => {
+ return (
+
+ {!isPreview && !disabled && (
+
removeAssignment(assignment.id)}
+ >
+
+
+ )}
+
+
+
+
Variable
+
{
+ if (value === '__new__') {
+ return
+ }
+ handleVariableSelect(assignment.id, value)
+ }}
+ disabled={isPreview || disabled}
+ >
+
+
+
+
+ {(() => {
+ const availableVars = getAvailableVariablesFor(assignment.id)
+ return availableVars.length > 0 ? (
+ availableVars.map((variable) => (
+
+
+ {variable.name}
+
+ {variable.type}
+
+
+
+ ))
+ ) : (
+
+ {currentWorkflowVariables.length > 0
+ ? 'All variables have been assigned.'
+ : 'No variables defined in this workflow.'}
+ {currentWorkflowVariables.length === 0 && (
+ <>
+
+ Add them in the Variables panel.
+ >
+ )}
+
+ )
+ })()}
+
+
+
+
+
+ Type
+
+
+
+
+
Value
+ {assignment.type === 'object' || assignment.type === 'array' ? (
+
+
+
+ )
+ })}
+
+ ) : null}
+
+ {!isPreview && !disabled && (
+
+ {!hasNoWorkflowVariables && }
+ {hasNoWorkflowVariables
+ ? 'No variables found'
+ : allVariablesAssigned
+ ? 'All Variables Assigned'
+ : 'Add Variable Assignment'}
+
+ )}
+
+ )
+}
diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/components/sub-block/sub-block.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/components/sub-block/sub-block.tsx
index f800624d9c..e40055f2f5 100644
--- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/components/sub-block/sub-block.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/components/sub-block/sub-block.tsx
@@ -35,6 +35,7 @@ import {
TimeInput,
ToolInput,
TriggerConfig,
+ VariablesInput,
WebhookConfig,
} from '@/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/components/sub-block/components'
import type { SubBlockConfig } from '@/blocks/types'
@@ -472,6 +473,18 @@ export const SubBlock = memo(
/>
)
}
+ case 'variables-input': {
+ return (
+
+ )
+ }
case 'response-format':
return (
syntax. All Variables blocks share the same namespace, so later blocks can update previously set variables.',
+ bgColor: '#8B5CF6',
+ bestPractices: `
+ - Variables are workflow-scoped and persist throughout execution (but not between executions)
+ - Reference variables using syntax in any block
+ - Variable names should be descriptive and follow camelCase or snake_case convention
+ - Any Variables block can update existing variables by setting the same variable name
+ - Variables do not appear as block outputs - they're accessed via the prefix
+ `,
+ icon: Variable,
+ category: 'blocks',
+ docsLink: 'https://docs.sim.ai/blocks/variables',
+ subBlocks: [
+ {
+ id: 'variables',
+ title: 'Variable Assignments',
+ type: 'variables-input',
+ layout: 'full',
+ description:
+ 'Select workflow variables and update their values during execution. Access them anywhere using syntax.',
+ required: false,
+ },
+ ],
+ tools: {
+ access: [],
+ },
+ inputs: {
+ variables: {
+ type: 'json',
+ description: 'Array of variable objects with name and value properties',
+ },
+ },
+ outputs: {
+ assignments: {
+ type: 'json',
+ description: 'JSON object mapping variable names to their assigned values',
+ },
+ },
+}
diff --git a/apps/sim/blocks/blocks/wait.ts b/apps/sim/blocks/blocks/wait.ts
new file mode 100644
index 0000000000..a19b43db1f
--- /dev/null
+++ b/apps/sim/blocks/blocks/wait.ts
@@ -0,0 +1,71 @@
+import type { SVGProps } from 'react'
+import { createElement } from 'react'
+import { PauseCircle } from 'lucide-react'
+import type { BlockConfig } from '@/blocks/types'
+
+const WaitIcon = (props: SVGProps) => createElement(PauseCircle, props)
+
+export const WaitBlock: BlockConfig = {
+ type: 'wait',
+ name: 'Wait',
+ description: 'Pause workflow execution for a specified time delay',
+ longDescription:
+ 'Pauses workflow execution for a specified time interval. The wait executes a simple sleep for the configured duration.',
+ bestPractices: `
+ - Use for simple time delays (max 10 minutes)
+ - Configure the wait amount and unit (seconds or minutes)
+ - Time-based waits are interruptible via workflow cancellation
+ - Enter a positive number for the wait amount
+ `,
+ category: 'blocks',
+ bgColor: '#F59E0B',
+ icon: WaitIcon,
+ docsLink: 'https://docs.sim.ai/blocks/wait',
+ subBlocks: [
+ {
+ id: 'timeValue',
+ title: 'Wait Amount',
+ type: 'short-input',
+ layout: 'half',
+ description: 'How long to wait. Max: 600 seconds or 10 minutes',
+ placeholder: '10',
+ value: () => '10',
+ required: true,
+ },
+ {
+ id: 'timeUnit',
+ title: 'Unit',
+ type: 'dropdown',
+ layout: 'half',
+ options: [
+ { label: 'Seconds', id: 'seconds' },
+ { label: 'Minutes', id: 'minutes' },
+ ],
+ value: () => 'seconds',
+ required: true,
+ },
+ ],
+ tools: {
+ access: [],
+ },
+ inputs: {
+ timeValue: {
+ type: 'string',
+ description: 'Wait duration value',
+ },
+ timeUnit: {
+ type: 'string',
+ description: 'Wait duration unit (seconds or minutes)',
+ },
+ },
+ outputs: {
+ waitDuration: {
+ type: 'number',
+ description: 'Wait duration in milliseconds',
+ },
+ status: {
+ type: 'string',
+ description: 'Status of the wait block (waiting, completed, cancelled)',
+ },
+ },
+}
diff --git a/apps/sim/blocks/registry.ts b/apps/sim/blocks/registry.ts
index f0d4465cd6..33eed5dabc 100644
--- a/apps/sim/blocks/registry.ts
+++ b/apps/sim/blocks/registry.ts
@@ -73,7 +73,9 @@ import { ThinkingBlock } from '@/blocks/blocks/thinking'
import { TranslateBlock } from '@/blocks/blocks/translate'
import { TwilioSMSBlock } from '@/blocks/blocks/twilio'
import { TypeformBlock } from '@/blocks/blocks/typeform'
+import { VariablesBlock } from '@/blocks/blocks/variables'
import { VisionBlock } from '@/blocks/blocks/vision'
+import { WaitBlock } from '@/blocks/blocks/wait'
import { WealthboxBlock } from '@/blocks/blocks/wealthbox'
import { WebflowBlock } from '@/blocks/blocks/webflow'
import { WebhookBlock } from '@/blocks/blocks/webhook'
@@ -165,7 +167,9 @@ export const registry: Record = {
translate: TranslateBlock,
twilio_sms: TwilioSMSBlock,
typeform: TypeformBlock,
+ variables: VariablesBlock,
vision: VisionBlock,
+ wait: WaitBlock,
wealthbox: WealthboxBlock,
webflow: WebflowBlock,
webhook: WebhookBlock,
diff --git a/apps/sim/blocks/types.ts b/apps/sim/blocks/types.ts
index 24eef8aeca..4e5e8efba1 100644
--- a/apps/sim/blocks/types.ts
+++ b/apps/sim/blocks/types.ts
@@ -70,6 +70,7 @@ export type SubBlockType =
| 'response-format' // Response structure format
| 'file-upload' // File uploader
| 'input-mapping' // Map parent variables to child workflow input schema
+ | 'variables-input' // Variable assignments for updating workflow variables
export type SubBlockLayout = 'full' | 'half'
diff --git a/apps/sim/components/icons.tsx b/apps/sim/components/icons.tsx
index 294024300e..18e2207f84 100644
--- a/apps/sim/components/icons.tsx
+++ b/apps/sim/components/icons.tsx
@@ -3795,3 +3795,24 @@ export function WebflowIcon(props: SVGProps) {
)
}
+
+export function Variable(props: SVGProps) {
+ return (
+
+
+
+
+
+ )
+}
diff --git a/apps/sim/components/ui/tag-dropdown.tsx b/apps/sim/components/ui/tag-dropdown.tsx
index 89f404cb4d..eecefcccdd 100644
--- a/apps/sim/components/ui/tag-dropdown.tsx
+++ b/apps/sim/components/ui/tag-dropdown.tsx
@@ -585,13 +585,43 @@ export const TagDropdown: React.FC = ({
)
let loopBlockGroup: BlockTagGroup | null = null
+
+ // Check if blockId IS a loop block (for editing loop config like while condition)
+ const isLoopBlock = blocks[blockId]?.type === 'loop'
+ const currentLoop = isLoopBlock ? loops[blockId] : null
+
+ // Check if blockId is INSIDE a loop
const containingLoop = Object.entries(loops).find(([_, loop]) => loop.nodes.includes(blockId))
+
let containingLoopBlockId: string | null = null
- if (containingLoop) {
+
+ // Prioritize current loop if editing the loop block itself
+ if (currentLoop && isLoopBlock) {
+ containingLoopBlockId = blockId
+ const loopType = currentLoop.loopType || 'for'
+ const contextualTags: string[] = ['index', 'currentIteration']
+ if (loopType === 'forEach') {
+ contextualTags.push('currentItem')
+ contextualTags.push('items')
+ }
+
+ const loopBlock = blocks[blockId]
+ if (loopBlock) {
+ const loopBlockName = loopBlock.name || loopBlock.type
+
+ loopBlockGroup = {
+ blockName: loopBlockName,
+ blockId: blockId,
+ blockType: 'loop',
+ tags: contextualTags,
+ distance: 0,
+ }
+ }
+ } else if (containingLoop) {
const [loopId, loop] = containingLoop
containingLoopBlockId = loopId
const loopType = loop.loopType || 'for'
- const contextualTags: string[] = ['index']
+ const contextualTags: string[] = ['index', 'currentIteration']
if (loopType === 'forEach') {
contextualTags.push('currentItem')
contextualTags.push('items')
diff --git a/apps/sim/executor/__test-utils__/executor-mocks.ts b/apps/sim/executor/__test-utils__/executor-mocks.ts
index 179ed7a54b..01c701f442 100644
--- a/apps/sim/executor/__test-utils__/executor-mocks.ts
+++ b/apps/sim/executor/__test-utils__/executor-mocks.ts
@@ -48,6 +48,8 @@ export const setupHandlerMocks = () => {
LoopBlockHandler: createMockHandler('loop'),
ParallelBlockHandler: createMockHandler('parallel'),
WorkflowBlockHandler: createMockHandler('workflow'),
+ VariablesBlockHandler: createMockHandler('variables'),
+ WaitBlockHandler: createMockHandler('wait'),
GenericBlockHandler: createMockHandler('generic'),
ResponseBlockHandler: createMockHandler('response'),
}))
diff --git a/apps/sim/executor/consts.ts b/apps/sim/executor/consts.ts
index d580f8205f..244de72171 100644
--- a/apps/sim/executor/consts.ts
+++ b/apps/sim/executor/consts.ts
@@ -15,6 +15,8 @@ export enum BlockType {
WORKFLOW = 'workflow', // Deprecated - kept for backwards compatibility
WORKFLOW_INPUT = 'workflow_input', // Current workflow block type
STARTER = 'starter',
+ VARIABLES = 'variables',
+ WAIT = 'wait',
}
/**
diff --git a/apps/sim/executor/handlers/condition/condition-handler.test.ts b/apps/sim/executor/handlers/condition/condition-handler.test.ts
index 18c1f0f769..a13c242342 100644
--- a/apps/sim/executor/handlers/condition/condition-handler.test.ts
+++ b/apps/sim/executor/handlers/condition/condition-handler.test.ts
@@ -332,7 +332,7 @@ describe('ConditionBlockHandler', () => {
mockResolver.resolveEnvVariables.mockReturnValue('context.nonExistentProperty.doSomething()')
await expect(handler.execute(mockBlock, inputs, mockContext)).rejects.toThrow(
- /^Evaluation error in condition "if": Cannot read properties of undefined \(reading 'doSomething'\)\. \(Resolved: context\.nonExistentProperty\.doSomething\(\)\)$/
+ /^Evaluation error in condition "if": Evaluation error in condition: Cannot read properties of undefined \(reading 'doSomething'\)\. \(Resolved: context\.nonExistentProperty\.doSomething\(\)\)$/
)
})
diff --git a/apps/sim/executor/handlers/condition/condition-handler.ts b/apps/sim/executor/handlers/condition/condition-handler.ts
index f585e5c9a7..d29c326ca6 100644
--- a/apps/sim/executor/handlers/condition/condition-handler.ts
+++ b/apps/sim/executor/handlers/condition/condition-handler.ts
@@ -8,6 +8,61 @@ import type { SerializedBlock } from '@/serializer/types'
const logger = createLogger('ConditionBlockHandler')
+/**
+ * Evaluates a single condition expression with variable/block reference resolution
+ * Returns true if condition is met, false otherwise
+ */
+export async function evaluateConditionExpression(
+ conditionExpression: string,
+ context: ExecutionContext,
+ block: SerializedBlock,
+ resolver: InputResolver,
+ providedEvalContext?: Record
+): Promise {
+ // Build evaluation context - use provided context or just loop context
+ const evalContext = providedEvalContext || {
+ // Add loop context if applicable
+ ...(context.loopItems.get(block.id) || {}),
+ }
+
+ let resolvedConditionValue = conditionExpression
+ try {
+ // Use full resolution pipeline: variables -> block references -> env vars
+ const resolvedVars = resolver.resolveVariableReferences(conditionExpression, block)
+ const resolvedRefs = resolver.resolveBlockReferences(resolvedVars, context, block)
+ resolvedConditionValue = resolver.resolveEnvVariables(resolvedRefs)
+ logger.info(`Resolved condition: from "${conditionExpression}" to "${resolvedConditionValue}"`)
+ } catch (resolveError: any) {
+ logger.error(`Failed to resolve references in condition: ${resolveError.message}`, {
+ conditionExpression,
+ resolveError,
+ })
+ throw new Error(`Failed to resolve references in condition: ${resolveError.message}`)
+ }
+
+ // Evaluate the RESOLVED condition string
+ try {
+ logger.info(`Evaluating resolved condition: "${resolvedConditionValue}"`, { evalContext })
+ // IMPORTANT: The resolved value (e.g., "some string".length > 0) IS the code to run
+ const conditionMet = new Function(
+ 'context',
+ `with(context) { return ${resolvedConditionValue} }`
+ )(evalContext)
+ logger.info(`Condition evaluated to: ${conditionMet}`)
+ return Boolean(conditionMet)
+ } catch (evalError: any) {
+ logger.error(`Failed to evaluate condition: ${evalError.message}`, {
+ originalCondition: conditionExpression,
+ resolvedCondition: resolvedConditionValue,
+ evalContext,
+ evalError,
+ })
+ throw new Error(
+ `Evaluation error in condition: ${evalError.message}. (Resolved: ${resolvedConditionValue})`
+ )
+ }
+}
+
/**
* Handler for Condition blocks that evaluate expressions to determine execution paths.
*/
@@ -102,35 +157,16 @@ export class ConditionBlockHandler implements BlockHandler {
continue // Should ideally not happen if 'else' exists and has a connection
}
- // 2. Resolve references WITHIN the specific condition's value string
+ // 2. Evaluate the condition using the shared evaluation function
const conditionValueString = String(condition.value || '')
- let resolvedConditionValue = conditionValueString
try {
- // Use full resolution pipeline: variables -> block references -> env vars
- const resolvedVars = this.resolver.resolveVariableReferences(conditionValueString, block)
- const resolvedRefs = this.resolver.resolveBlockReferences(resolvedVars, context, block)
- resolvedConditionValue = this.resolver.resolveEnvVariables(resolvedRefs)
- logger.info(
- `Resolved condition "${condition.title}" (${condition.id}): from "${conditionValueString}" to "${resolvedConditionValue}"`
+ const conditionMet = await evaluateConditionExpression(
+ conditionValueString,
+ context,
+ block,
+ this.resolver,
+ evalContext
)
- } catch (resolveError: any) {
- logger.error(`Failed to resolve references in condition: ${resolveError.message}`, {
- condition,
- resolveError,
- })
- throw new Error(`Failed to resolve references in condition: ${resolveError.message}`)
- }
-
- // 3. Evaluate the RESOLVED condition string
- try {
- logger.info(`Evaluating resolved condition: "${resolvedConditionValue}"`, {
- evalContext, // Log the context being used for evaluation
- })
- // IMPORTANT: The resolved value (e.g., "some string".length > 0) IS the code to run
- const conditionMet = new Function(
- 'context',
- `with(context) { return ${resolvedConditionValue} }`
- )(evalContext)
logger.info(`Condition "${condition.title}" (${condition.id}) met: ${conditionMet}`)
// Find connection for this condition
@@ -143,17 +179,9 @@ export class ConditionBlockHandler implements BlockHandler {
selectedCondition = condition
break // Found the first matching condition
}
- } catch (evalError: any) {
- logger.error(`Failed to evaluate condition: ${evalError.message}`, {
- originalCondition: condition.value,
- resolvedCondition: resolvedConditionValue,
- evalContext,
- evalError,
- })
- // Construct a more informative error message
- throw new Error(
- `Evaluation error in condition "${condition.title}": ${evalError.message}. (Resolved: ${resolvedConditionValue})`
- )
+ } catch (error: any) {
+ logger.error(`Failed to evaluate condition "${condition.title}": ${error.message}`)
+ throw new Error(`Evaluation error in condition "${condition.title}": ${error.message}`)
}
}
diff --git a/apps/sim/executor/handlers/index.ts b/apps/sim/executor/handlers/index.ts
index c2acc8a39f..afc2346f5b 100644
--- a/apps/sim/executor/handlers/index.ts
+++ b/apps/sim/executor/handlers/index.ts
@@ -9,6 +9,8 @@ import { ParallelBlockHandler } from '@/executor/handlers/parallel/parallel-hand
import { ResponseBlockHandler } from '@/executor/handlers/response/response-handler'
import { RouterBlockHandler } from '@/executor/handlers/router/router-handler'
import { TriggerBlockHandler } from '@/executor/handlers/trigger/trigger-handler'
+import { VariablesBlockHandler } from '@/executor/handlers/variables/variables-handler'
+import { WaitBlockHandler } from '@/executor/handlers/wait/wait-handler'
import { WorkflowBlockHandler } from '@/executor/handlers/workflow/workflow-handler'
export {
@@ -23,5 +25,7 @@ export {
ResponseBlockHandler,
RouterBlockHandler,
TriggerBlockHandler,
+ VariablesBlockHandler,
+ WaitBlockHandler,
WorkflowBlockHandler,
}
diff --git a/apps/sim/executor/handlers/loop/loop-handler.ts b/apps/sim/executor/handlers/loop/loop-handler.ts
index e2df1a5be5..29646de176 100644
--- a/apps/sim/executor/handlers/loop/loop-handler.ts
+++ b/apps/sim/executor/handlers/loop/loop-handler.ts
@@ -1,6 +1,7 @@
import { createLogger } from '@/lib/logs/console/logger'
import type { BlockOutput } from '@/blocks/types'
import { BlockType } from '@/executor/consts'
+import { evaluateConditionExpression } from '@/executor/handlers/condition/condition-handler'
import type { PathTracker } from '@/executor/path/path'
import type { InputResolver } from '@/executor/resolver/resolver'
import { Routing } from '@/executor/routing/routing'
@@ -50,6 +51,8 @@ export class LoopBlockHandler implements BlockHandler {
const currentIteration = context.loopIterations.get(block.id) || 1
let maxIterations: number
let forEachItems: any[] | Record | null = null
+ let shouldContinueLoop = true
+
if (loop.loopType === 'forEach') {
if (
!loop.forEachItems ||
@@ -82,16 +85,96 @@ export class LoopBlockHandler implements BlockHandler {
logger.info(
`forEach loop ${block.id} - Items: ${itemsLength}, Max iterations: ${maxIterations}`
)
+ } else if (loop.loopType === 'while' || loop.loopType === 'doWhile') {
+ // For while and doWhile loops, set loop context BEFORE evaluating condition
+ // This makes variables like index, currentIteration available in the condition
+ const loopContext = {
+ index: currentIteration - 1, // 0-based index
+ currentIteration, // 1-based iteration number
+ }
+ context.loopItems.set(block.id, loopContext)
+
+ // Evaluate the condition to determine if we should continue
+ if (!loop.whileCondition || loop.whileCondition.trim() === '') {
+ throw new Error(
+ `${loop.loopType} loop "${block.id}" requires a condition expression. Please provide a valid JavaScript expression.`
+ )
+ }
+
+ // For doWhile loops, skip condition evaluation on the first iteration
+ // For while loops, always evaluate the condition
+ if (loop.loopType === 'doWhile' && currentIteration === 1) {
+ shouldContinueLoop = true
+ } else {
+ // Evaluate the condition at the start of each iteration
+ try {
+ if (!this.resolver) {
+ throw new Error('Resolver is required for while/doWhile loop condition evaluation')
+ }
+ shouldContinueLoop = await evaluateConditionExpression(
+ loop.whileCondition,
+ context,
+ block,
+ this.resolver
+ )
+ } catch (error: any) {
+ throw new Error(
+ `Failed to evaluate ${loop.loopType} loop condition for "${block.id}": ${error.message}`
+ )
+ }
+ }
+
+ // No max iterations for while/doWhile - rely on condition and workflow timeout
+ maxIterations = Number.MAX_SAFE_INTEGER
} else {
maxIterations = loop.iterations || DEFAULT_MAX_ITERATIONS
logger.info(`For loop ${block.id} - Max iterations: ${maxIterations}`)
}
logger.info(
- `Loop ${block.id} - Current iteration: ${currentIteration}, Max iterations: ${maxIterations}`
+ `Loop ${block.id} - Current iteration: ${currentIteration}, Max iterations: ${maxIterations}, Should continue: ${shouldContinueLoop}`
)
- if (currentIteration > maxIterations) {
+ // For while and doWhile loops, check if the condition is false
+ if ((loop.loopType === 'while' || loop.loopType === 'doWhile') && !shouldContinueLoop) {
+ // Mark the loop as completed
+ context.completedLoops.add(block.id)
+
+ // Remove any activated loop-start paths since we're not continuing
+ const loopStartConnections =
+ context.workflow?.connections.filter(
+ (conn) => conn.source === block.id && conn.sourceHandle === 'loop-start-source'
+ ) || []
+
+ for (const conn of loopStartConnections) {
+ context.activeExecutionPath.delete(conn.target)
+ }
+
+ // Activate the loop-end connections (blocks after the loop)
+ const loopEndConnections =
+ context.workflow?.connections.filter(
+ (conn) => conn.source === block.id && conn.sourceHandle === 'loop-end-source'
+ ) || []
+
+ for (const conn of loopEndConnections) {
+ context.activeExecutionPath.add(conn.target)
+ }
+
+ return {
+ loopId: block.id,
+ currentIteration,
+ maxIterations,
+ loopType: loop.loopType,
+ completed: true,
+ message: `${loop.loopType === 'doWhile' ? 'Do-While' : 'While'} loop completed after ${currentIteration} iterations (condition became false)`,
+ } as Record
+ }
+
+ // Only check max iterations for for/forEach loops (while/doWhile have no limit)
+ if (
+ (loop.loopType === 'for' || loop.loopType === 'forEach') &&
+ currentIteration > maxIterations
+ ) {
logger.info(`Loop ${block.id} has reached maximum iterations (${maxIterations})`)
return {
@@ -142,7 +225,23 @@ export class LoopBlockHandler implements BlockHandler {
this.activateChildNodes(block, context, currentIteration)
}
- context.loopIterations.set(block.id, currentIteration)
+ // For while/doWhile loops, now that condition is confirmed true, reset child blocks and increment counter
+ if (loop.loopType === 'while' || loop.loopType === 'doWhile') {
+ // Reset all child blocks for this iteration
+ for (const nodeId of loop.nodes || []) {
+ context.executedBlocks.delete(nodeId)
+ context.blockStates.delete(nodeId)
+ context.activeExecutionPath.delete(nodeId)
+ context.decisions.router.delete(nodeId)
+ context.decisions.condition.delete(nodeId)
+ }
+
+ // Increment the counter for the next iteration
+ context.loopIterations.set(block.id, currentIteration + 1)
+ } else {
+ // For for/forEach loops, keep the counter value - it will be managed by the loop manager
+ context.loopIterations.set(block.id, currentIteration)
+ }
return {
loopId: block.id,
diff --git a/apps/sim/executor/handlers/variables/variables-handler.ts b/apps/sim/executor/handlers/variables/variables-handler.ts
new file mode 100644
index 0000000000..65a842c6d9
--- /dev/null
+++ b/apps/sim/executor/handlers/variables/variables-handler.ts
@@ -0,0 +1,163 @@
+import { createLogger } from '@/lib/logs/console/logger'
+import type { BlockOutput } from '@/blocks/types'
+import { BlockType } from '@/executor/consts'
+import type { BlockHandler, ExecutionContext } from '@/executor/types'
+import type { SerializedBlock } from '@/serializer/types'
+
+const logger = createLogger('VariablesBlockHandler')
+
+export class VariablesBlockHandler implements BlockHandler {
+ canHandle(block: SerializedBlock): boolean {
+ const canHandle = block.metadata?.id === BlockType.VARIABLES
+ logger.info(`VariablesBlockHandler.canHandle: ${canHandle}`, {
+ blockId: block.id,
+ metadataId: block.metadata?.id,
+ expectedType: BlockType.VARIABLES,
+ })
+ return canHandle
+ }
+
+ async execute(
+ block: SerializedBlock,
+ inputs: Record,
+ context: ExecutionContext
+ ): Promise {
+ logger.info(`Executing variables block: ${block.id}`, {
+ blockName: block.metadata?.name,
+ inputsKeys: Object.keys(inputs),
+ variablesInput: inputs.variables,
+ })
+
+ try {
+ // Initialize workflowVariables if not present
+ if (!context.workflowVariables) {
+ context.workflowVariables = {}
+ }
+
+ // Parse variable assignments from the custom input
+ const assignments = this.parseAssignments(inputs.variables)
+
+ // Update context.workflowVariables with new values
+ for (const assignment of assignments) {
+ // Find the variable by ID or name
+ const existingEntry = assignment.variableId
+ ? [assignment.variableId, context.workflowVariables[assignment.variableId]]
+ : Object.entries(context.workflowVariables).find(
+ ([_, v]) => v.name === assignment.variableName
+ )
+
+ if (existingEntry?.[1]) {
+ // Update existing variable value
+ const [id, variable] = existingEntry
+ context.workflowVariables[id] = {
+ ...variable,
+ value: assignment.value,
+ }
+ } else {
+ logger.warn(`Variable "${assignment.variableName}" not found in workflow variables`)
+ }
+ }
+
+ logger.info('Variables updated', {
+ updatedVariables: assignments.map((a) => a.variableName),
+ allVariables: Object.values(context.workflowVariables).map((v: any) => v.name),
+ updatedValues: Object.entries(context.workflowVariables).map(([id, v]: [string, any]) => ({
+ id,
+ name: v.name,
+ value: v.value,
+ })),
+ })
+
+ // Return assignments as a JSON object mapping variable names to values
+ const assignmentsOutput: Record = {}
+ for (const assignment of assignments) {
+ assignmentsOutput[assignment.variableName] = assignment.value
+ }
+
+ return {
+ assignments: assignmentsOutput,
+ }
+ } catch (error: any) {
+ logger.error('Variables block execution failed:', error)
+ throw new Error(`Variables block execution failed: ${error.message}`)
+ }
+ }
+
+ private parseAssignments(
+ assignmentsInput: any
+ ): Array<{ variableId?: string; variableName: string; type: string; value: any }> {
+ const result: Array<{ variableId?: string; variableName: string; type: string; value: any }> =
+ []
+
+ if (!assignmentsInput || !Array.isArray(assignmentsInput)) {
+ return result
+ }
+
+ for (const assignment of assignmentsInput) {
+ if (assignment?.variableName?.trim()) {
+ const name = assignment.variableName.trim()
+ const type = assignment.type || 'string'
+ const value = this.parseValueByType(assignment.value, type)
+
+ result.push({
+ variableId: assignment.variableId,
+ variableName: name,
+ type,
+ value,
+ })
+ }
+ }
+
+ return result
+ }
+
+ private parseValueByType(value: any, type: string): any {
+ // Handle null/undefined early
+ if (value === null || value === undefined) {
+ if (type === 'number') return 0
+ if (type === 'boolean') return false
+ if (type === 'array') return []
+ if (type === 'object') return {}
+ return ''
+ }
+
+ // Handle plain and string types (plain is for backward compatibility)
+ if (type === 'string' || type === 'plain') {
+ return typeof value === 'string' ? value : String(value)
+ }
+
+ if (type === 'number') {
+ if (typeof value === 'number') return value
+ if (typeof value === 'string') {
+ const num = Number(value)
+ return Number.isNaN(num) ? 0 : num
+ }
+ return 0
+ }
+
+ if (type === 'boolean') {
+ if (typeof value === 'boolean') return value
+ if (typeof value === 'string') {
+ return value.toLowerCase() === 'true'
+ }
+ return Boolean(value)
+ }
+
+ if (type === 'object' || type === 'array') {
+ if (typeof value === 'object' && value !== null) {
+ return value
+ }
+ if (typeof value === 'string' && value.trim()) {
+ try {
+ return JSON.parse(value)
+ } catch {
+ return type === 'array' ? [] : {}
+ }
+ }
+ return type === 'array' ? [] : {}
+ }
+
+ // Default: return value as-is
+ return value
+ }
+}
diff --git a/apps/sim/executor/handlers/wait/wait-handler.ts b/apps/sim/executor/handlers/wait/wait-handler.ts
new file mode 100644
index 0000000000..10b97c5fc6
--- /dev/null
+++ b/apps/sim/executor/handlers/wait/wait-handler.ts
@@ -0,0 +1,103 @@
+import { createLogger } from '@/lib/logs/console/logger'
+import { BlockType } from '@/executor/consts'
+import type { BlockHandler, ExecutionContext } from '@/executor/types'
+import type { SerializedBlock } from '@/serializer/types'
+
+const logger = createLogger('WaitBlockHandler')
+
+/**
+ * Helper function to sleep for a specified number of milliseconds
+ * On client-side: checks for cancellation every 100ms (non-blocking for UI)
+ * On server-side: simple sleep without polling (server execution can't be cancelled mid-flight)
+ */
+const sleep = async (ms: number, checkCancelled?: () => boolean): Promise => {
+ const isClientSide = typeof window !== 'undefined'
+
+ // Server-side: simple sleep without polling
+ if (!isClientSide) {
+ await new Promise((resolve) => setTimeout(resolve, ms))
+ return true
+ }
+
+ // Client-side: check for cancellation every 100ms
+ const chunkMs = 100
+ let elapsed = 0
+
+ while (elapsed < ms) {
+ // Check if execution was cancelled
+ if (checkCancelled?.()) {
+ return false // Sleep was interrupted
+ }
+
+ // Sleep for a chunk or remaining time, whichever is smaller
+ const sleepTime = Math.min(chunkMs, ms - elapsed)
+ await new Promise((resolve) => setTimeout(resolve, sleepTime))
+ elapsed += sleepTime
+ }
+
+ return true // Sleep completed normally
+}
+
+/**
+ * Handler for Wait blocks that pause workflow execution for a time delay
+ */
+export class WaitBlockHandler implements BlockHandler {
+ canHandle(block: SerializedBlock): boolean {
+ return block.metadata?.id === BlockType.WAIT
+ }
+
+ async execute(
+ block: SerializedBlock,
+ inputs: Record,
+ context: ExecutionContext
+ ): Promise {
+ logger.info(`Executing Wait block: ${block.id}`, { inputs })
+
+ // Parse the wait duration
+ const timeValue = Number.parseInt(inputs.timeValue || '10', 10)
+ const timeUnit = inputs.timeUnit || 'seconds'
+
+ // Validate time value
+ if (Number.isNaN(timeValue) || timeValue <= 0) {
+ throw new Error('Wait amount must be a positive number')
+ }
+
+ // Calculate wait time in milliseconds
+ let waitMs = timeValue * 1000 // Default to seconds
+ if (timeUnit === 'minutes') {
+ waitMs = timeValue * 60 * 1000
+ }
+
+ // Enforce 10-minute maximum (600,000 ms)
+ const maxWaitMs = 10 * 60 * 1000
+ if (waitMs > maxWaitMs) {
+ const maxDisplay = timeUnit === 'minutes' ? '10 minutes' : '600 seconds'
+ throw new Error(`Wait time exceeds maximum of ${maxDisplay}`)
+ }
+
+ logger.info(`Waiting for ${waitMs}ms (${timeValue} ${timeUnit})`)
+
+ // Actually sleep for the specified duration
+ // The executor updates context.isCancelled when cancel() is called
+ const checkCancelled = () => {
+ // Check if execution was marked as cancelled in the context
+ return (context as any).isCancelled === true
+ }
+
+ const completed = await sleep(waitMs, checkCancelled)
+
+ if (!completed) {
+ logger.info('Wait was interrupted by cancellation')
+ return {
+ waitDuration: waitMs,
+ status: 'cancelled',
+ }
+ }
+
+ logger.info('Wait completed successfully')
+ return {
+ waitDuration: waitMs,
+ status: 'completed',
+ }
+ }
+}
diff --git a/apps/sim/executor/index.ts b/apps/sim/executor/index.ts
index e5d6230cdc..2b00bc5e0e 100644
--- a/apps/sim/executor/index.ts
+++ b/apps/sim/executor/index.ts
@@ -16,6 +16,8 @@ import {
ResponseBlockHandler,
RouterBlockHandler,
TriggerBlockHandler,
+ VariablesBlockHandler,
+ WaitBlockHandler,
WorkflowBlockHandler,
} from '@/executor/handlers'
import { LoopManager } from '@/executor/loops/loops'
@@ -213,6 +215,8 @@ export class Executor {
new ParallelBlockHandler(this.resolver, this.pathTracker),
new ResponseBlockHandler(),
new WorkflowBlockHandler(),
+ new VariablesBlockHandler(),
+ new WaitBlockHandler(),
new GenericBlockHandler(),
]
@@ -1972,10 +1976,11 @@ export class Executor {
? forEachItems.length
: Object.keys(forEachItems).length
}
- } else {
- // For regular loops, use the iterations count
+ } else if (loop.loopType === 'for') {
+ // For 'for' loops, use the iterations count
iterationTotal = loop.iterations || 5
}
+ // For while/doWhile loops, don't set iterationTotal (no max)
iterationType = 'loop'
}
}
@@ -2099,10 +2104,11 @@ export class Executor {
? forEachItems.length
: Object.keys(forEachItems).length
}
- } else {
- // For regular loops, use the iterations count
+ } else if (loop.loopType === 'for') {
+ // For 'for' loops, use the iterations count
iterationTotal = loop.iterations || 5
}
+ // For while/doWhile loops, don't set iterationTotal (no max)
iterationType = 'loop'
}
}
@@ -2226,10 +2232,11 @@ export class Executor {
? forEachItems.length
: Object.keys(forEachItems).length
}
- } else {
- // For regular loops, use the iterations count
+ } else if (loop.loopType === 'for') {
+ // For 'for' loops, use the iterations count
iterationTotal = loop.iterations || 5
}
+ // For while/doWhile loops, don't set iterationTotal (no max)
iterationType = 'loop'
}
}
diff --git a/apps/sim/executor/loops/loops.ts b/apps/sim/executor/loops/loops.ts
index 895c373874..39b0d1c7fb 100644
--- a/apps/sim/executor/loops/loops.ts
+++ b/apps/sim/executor/loops/loops.ts
@@ -81,12 +81,19 @@ export class LoopManager {
)
}
}
+ } else if (loop.loopType === 'while' || loop.loopType === 'doWhile') {
+ // For while and doWhile loops, no max iteration limit
+ // They rely on the condition to stop (and workflow timeout as safety)
+ maxIterations = Number.MAX_SAFE_INTEGER
}
logger.info(`Loop ${loopId} - Current: ${currentIteration}, Max: ${maxIterations}`)
- // Check if we've completed all iterations
- if (currentIteration >= maxIterations) {
+ // Check if we've completed all iterations (only for for/forEach loops)
+ if (
+ currentIteration >= maxIterations &&
+ (loop.loopType === 'for' || loop.loopType === 'forEach')
+ ) {
hasLoopReachedMaxIterations = true
logger.info(`Loop ${loopId} has completed all ${maxIterations} iterations`)
@@ -131,15 +138,21 @@ export class LoopManager {
logger.info(`Loop ${loopId} - Completed and activated end connections`)
} else {
- context.loopIterations.set(loopId, currentIteration + 1)
- logger.info(`Loop ${loopId} - Incremented counter to ${currentIteration + 1}`)
-
- this.resetLoopBlocks(loopId, loop, context)
+ // For while/doWhile loops, DON'T reset yet - let the loop handler check the condition first
+ // The loop handler will decide whether to continue or exit based on the condition
+ if (loop.loopType === 'while' || loop.loopType === 'doWhile') {
+ // Just reset the loop block itself so it can re-evaluate the condition
+ context.executedBlocks.delete(loopId)
+ context.blockStates.delete(loopId)
+ } else {
+ // For for/forEach loops, increment and reset everything as usual
+ context.loopIterations.set(loopId, currentIteration + 1)
- context.executedBlocks.delete(loopId)
- context.blockStates.delete(loopId)
+ this.resetLoopBlocks(loopId, loop, context)
- logger.info(`Loop ${loopId} - Reset for iteration ${currentIteration + 1}`)
+ context.executedBlocks.delete(loopId)
+ context.blockStates.delete(loopId)
+ }
}
}
}
diff --git a/apps/sim/executor/path/path.test.ts b/apps/sim/executor/path/path.test.ts
index 582a75bf28..83f819c3c0 100644
--- a/apps/sim/executor/path/path.test.ts
+++ b/apps/sim/executor/path/path.test.ts
@@ -229,12 +229,20 @@ describe('PathTracker', () => {
})
describe('loop blocks', () => {
- it('should only activate loop-start connections', () => {
+ it('should activate loop-start connections when loop is not completed', () => {
pathTracker.updateExecutionPaths(['loop1'], mockContext)
expect(mockContext.activeExecutionPath.has('block1')).toBe(true)
expect(mockContext.activeExecutionPath.has('block2')).toBe(false)
})
+
+ it('should not activate loop-start connections when loop is completed', () => {
+ mockContext.completedLoops.add('loop1')
+ pathTracker.updateExecutionPaths(['loop1'], mockContext)
+
+ expect(mockContext.activeExecutionPath.has('block1')).toBe(false)
+ expect(mockContext.activeExecutionPath.has('block2')).toBe(false)
+ })
})
describe('regular blocks', () => {
diff --git a/apps/sim/executor/path/path.ts b/apps/sim/executor/path/path.ts
index ee35cf0f6d..6986790c20 100644
--- a/apps/sim/executor/path/path.ts
+++ b/apps/sim/executor/path/path.ts
@@ -286,13 +286,18 @@ export class PathTracker {
* Update paths for loop blocks
*/
private updateLoopPaths(block: SerializedBlock, context: ExecutionContext): void {
+ // Don't activate loop-start connections if the loop has completed
+ // (e.g., while loop condition is false)
+ if (context.completedLoops.has(block.id)) {
+ return
+ }
+
const outgoingConnections = this.getOutgoingConnections(block.id)
for (const conn of outgoingConnections) {
// Only activate loop-start connections
if (conn.sourceHandle === 'loop-start-source') {
context.activeExecutionPath.add(conn.target)
- logger.info(`Loop ${block.id} activated start path to: ${conn.target}`)
}
// loop-end-source connections will be activated by the loop manager
}
diff --git a/apps/sim/hooks/use-collaborative-workflow.ts b/apps/sim/hooks/use-collaborative-workflow.ts
index e9ad0dbfbc..1fe82212f4 100644
--- a/apps/sim/hooks/use-collaborative-workflow.ts
+++ b/apps/sim/hooks/use-collaborative-workflow.ts
@@ -340,8 +340,11 @@ export function useCollaborativeWorkflow() {
if (config.iterations !== undefined) {
workflowStore.updateLoopCount(payload.id, config.iterations)
}
+ // Handle both forEach items and while conditions
if (config.forEachItems !== undefined) {
workflowStore.updateLoopCollection(payload.id, config.forEachItems)
+ } else if (config.whileCondition !== undefined) {
+ workflowStore.updateLoopCollection(payload.id, config.whileCondition)
}
} else if (payload.type === 'parallel') {
const { config } = payload
@@ -1261,7 +1264,7 @@ export function useCollaborativeWorkflow() {
)
const collaborativeUpdateLoopType = useCallback(
- (loopId: string, loopType: 'for' | 'forEach') => {
+ (loopId: string, loopType: 'for' | 'forEach' | 'while' | 'doWhile') => {
const currentBlock = workflowStore.blocks[loopId]
if (!currentBlock || currentBlock.type !== 'loop') return
@@ -1271,13 +1274,20 @@ export function useCollaborativeWorkflow() {
const currentIterations = currentBlock.data?.count || 5
const currentCollection = currentBlock.data?.collection || ''
+ const currentCondition = currentBlock.data?.whileCondition || ''
- const config = {
+ const config: any = {
id: loopId,
nodes: childNodes,
iterations: currentIterations,
loopType,
- forEachItems: currentCollection,
+ }
+
+ // Include the appropriate field based on loop type
+ if (loopType === 'forEach') {
+ config.forEachItems = currentCollection
+ } else if (loopType === 'while' || loopType === 'doWhile') {
+ config.whileCondition = currentCondition
}
executeQueuedOperation('update', 'subflow', { id: loopId, type: 'loop', config }, () =>
@@ -1386,12 +1396,18 @@ export function useCollaborativeWorkflow() {
const currentIterations = currentBlock.data?.count || 5
const currentLoopType = currentBlock.data?.loopType || 'for'
- const config = {
+ const config: any = {
id: nodeId,
nodes: childNodes,
iterations: currentIterations,
loopType: currentLoopType,
- forEachItems: collection,
+ }
+
+ // Add the appropriate field based on loop type
+ if (currentLoopType === 'forEach') {
+ config.forEachItems = collection
+ } else if (currentLoopType === 'while') {
+ config.whileCondition = collection
}
executeQueuedOperation('update', 'subflow', { id: nodeId, type: 'loop', config }, () =>
diff --git a/apps/sim/lib/copilot/tools/server/blocks/get-blocks-metadata-tool.ts b/apps/sim/lib/copilot/tools/server/blocks/get-blocks-metadata-tool.ts
index 9038b53df5..56abc54dfb 100644
--- a/apps/sim/lib/copilot/tools/server/blocks/get-blocks-metadata-tool.ts
+++ b/apps/sim/lib/copilot/tools/server/blocks/get-blocks-metadata-tool.ts
@@ -882,8 +882,9 @@ const SPECIAL_BLOCKS_METADATA: Record = {
loopType: {
type: 'string',
required: true,
- enum: ['for', 'forEach'],
- description: "Loop Type - 'for' runs N times, 'forEach' iterates over collection",
+ enum: ['for', 'forEach', 'while', 'doWhile'],
+ description:
+ "Loop Type - 'for' runs N times, 'forEach' iterates over collection, 'while' runs while condition is true, 'doWhile' runs at least once then checks condition",
},
iterations: {
type: 'number',
@@ -899,6 +900,12 @@ const SPECIAL_BLOCKS_METADATA: Record = {
description: "Collection to iterate over (for 'forEach' loopType)",
example: '',
},
+ condition: {
+ type: 'string',
+ required: false,
+ description: "Condition to evaluate (for 'while' and 'doWhile' loopType)",
+ example: ' < 10',
+ },
maxConcurrency: {
type: 'number',
required: false,
@@ -924,6 +931,8 @@ const SPECIAL_BLOCKS_METADATA: Record = {
options: [
{ label: 'For Loop (count)', id: 'for' },
{ label: 'For Each (collection)', id: 'forEach' },
+ { label: 'While (condition)', id: 'while' },
+ { label: 'Do While (condition)', id: 'doWhile' },
],
},
{
@@ -942,6 +951,14 @@ const SPECIAL_BLOCKS_METADATA: Record = {
placeholder: 'Array or object to iterate over...',
condition: { field: 'loopType', value: 'forEach' },
},
+ {
+ id: 'condition',
+ title: 'Condition',
+ type: 'code',
+ language: 'javascript',
+ placeholder: ' < 10',
+ condition: { field: 'loopType', value: ['while', 'doWhile'] },
+ },
{
id: 'maxConcurrency',
title: 'Max Concurrency',
diff --git a/apps/sim/lib/copilot/tools/server/workflow/edit-workflow.ts b/apps/sim/lib/copilot/tools/server/workflow/edit-workflow.ts
index dbb7338926..8ebfe5372e 100644
--- a/apps/sim/lib/copilot/tools/server/workflow/edit-workflow.ts
+++ b/apps/sim/lib/copilot/tools/server/workflow/edit-workflow.ts
@@ -448,6 +448,8 @@ function applyOperationsToWorkflowState(
block.data.count = params.inputs.iterations
if (params.inputs.collection !== undefined)
block.data.collection = params.inputs.collection
+ if (params.inputs.condition !== undefined)
+ block.data.whileCondition = params.inputs.condition
} else if (block.type === 'parallel') {
block.data = block.data || {}
if (params.inputs.parallelType !== undefined)
@@ -510,6 +512,7 @@ function applyOperationsToWorkflowState(
if (params.inputs?.loopType) block.data.loopType = params.inputs.loopType
if (params.inputs?.iterations) block.data.count = params.inputs.iterations
if (params.inputs?.collection) block.data.collection = params.inputs.collection
+ if (params.inputs?.condition) block.data.whileCondition = params.inputs.condition
} else if (block.type === 'parallel') {
block.data = block.data || {}
if (params.inputs?.parallelType) block.data.parallelType = params.inputs.parallelType
diff --git a/apps/sim/lib/environment.ts b/apps/sim/lib/environment.ts
index 835f54c8bc..127918b813 100644
--- a/apps/sim/lib/environment.ts
+++ b/apps/sim/lib/environment.ts
@@ -1,7 +1,7 @@
/**
* Environment utility functions for consistent environment detection across the application
*/
-import { env, getEnv, isTruthy } from './env'
+import { env, isTruthy } from './env'
/**
* Is the application running in production mode
@@ -21,9 +21,9 @@ export const isTest = env.NODE_ENV === 'test'
/**
* Is this the hosted version of the application
*/
-export const isHosted =
- getEnv('NEXT_PUBLIC_APP_URL') === 'https://www.sim.ai' ||
- getEnv('NEXT_PUBLIC_APP_URL') === 'https://www.staging.sim.ai'
+export const isHosted = true
+// getEnv('NEXT_PUBLIC_APP_URL') === 'https://www.sim.ai' ||
+// getEnv('NEXT_PUBLIC_APP_URL') === 'https://www.staging.sim.ai'
/**
* Is billing enforcement enabled
diff --git a/apps/sim/lib/workflows/autolayout/types.ts b/apps/sim/lib/workflows/autolayout/types.ts
index 5dd3930c71..ed763ae571 100644
--- a/apps/sim/lib/workflows/autolayout/types.ts
+++ b/apps/sim/lib/workflows/autolayout/types.ts
@@ -25,7 +25,9 @@ export interface Loop {
id: string
nodes: string[]
iterations: number
- loopType: 'for' | 'forEach'
+ loopType: 'for' | 'forEach' | 'while' | 'doWhile'
+ forEachItems?: any[] | Record | string // Items or expression
+ whileCondition?: string // JS expression that evaluates to boolean
}
export interface Parallel {
diff --git a/apps/sim/lib/workflows/db-helpers.ts b/apps/sim/lib/workflows/db-helpers.ts
index 5b636f5d41..65aadcd48f 100644
--- a/apps/sim/lib/workflows/db-helpers.ts
+++ b/apps/sim/lib/workflows/db-helpers.ts
@@ -178,10 +178,14 @@ export async function loadWorkflowFromNormalizedTables(
iterations:
typeof (config as Loop).iterations === 'number' ? (config as Loop).iterations : 1,
loopType:
- (config as Loop).loopType === 'for' || (config as Loop).loopType === 'forEach'
+ (config as Loop).loopType === 'for' ||
+ (config as Loop).loopType === 'forEach' ||
+ (config as Loop).loopType === 'while' ||
+ (config as Loop).loopType === 'doWhile'
? (config as Loop).loopType
: 'for',
forEachItems: (config as Loop).forEachItems ?? '',
+ whileCondition: (config as Loop).whileCondition ?? undefined,
}
loops[subflow.id] = loop
} else if (subflow.type === SUBFLOW_TYPES.PARALLEL) {
diff --git a/apps/sim/lib/workflows/json-sanitizer.ts b/apps/sim/lib/workflows/json-sanitizer.ts
index 8ffe055f45..334b3dba64 100644
--- a/apps/sim/lib/workflows/json-sanitizer.ts
+++ b/apps/sim/lib/workflows/json-sanitizer.ts
@@ -309,6 +309,7 @@ export function sanitizeForCopilot(state: WorkflowState): CopilotWorkflowState {
if (block.data?.loopType) loopInputs.loopType = block.data.loopType
if (block.data?.count !== undefined) loopInputs.iterations = block.data.count
if (block.data?.collection !== undefined) loopInputs.collection = block.data.collection
+ if (block.data?.whileCondition !== undefined) loopInputs.condition = block.data.whileCondition
if (block.data?.parallelType) loopInputs.parallelType = block.data.parallelType
inputs = loopInputs
} else {
diff --git a/apps/sim/serializer/types.ts b/apps/sim/serializer/types.ts
index 915f83631b..f27b733271 100644
--- a/apps/sim/serializer/types.ts
+++ b/apps/sim/serializer/types.ts
@@ -44,8 +44,9 @@ export interface SerializedLoop {
id: string
nodes: string[]
iterations: number
- loopType?: 'for' | 'forEach' | 'while'
+ loopType?: 'for' | 'forEach' | 'while' | 'doWhile'
forEachItems?: any[] | Record | string // Items to iterate over or expression to evaluate
+ whileCondition?: string // JS expression that evaluates to boolean (for while and doWhile loops)
}
export interface SerializedParallel {
diff --git a/apps/sim/socket-server/database/operations.ts b/apps/sim/socket-server/database/operations.ts
index 8502d14ae0..e9601db399 100644
--- a/apps/sim/socket-server/database/operations.ts
+++ b/apps/sim/socket-server/database/operations.ts
@@ -298,7 +298,10 @@ async function handleBlockOperationTx(
nodes: [], // Empty initially, will be populated when child blocks are added
iterations: payload.data?.count || DEFAULT_LOOP_ITERATIONS,
loopType: payload.data?.loopType || 'for',
- forEachItems: payload.data?.collection || '',
+ // Set the appropriate field based on loop type
+ ...(payload.data?.loopType === 'while' || payload.data?.loopType === 'doWhile'
+ ? { whileCondition: payload.data?.whileCondition || '' }
+ : { forEachItems: payload.data?.collection || '' }),
}
: {
id: payload.id,
@@ -721,7 +724,10 @@ async function handleBlockOperationTx(
nodes: [], // Empty initially, will be populated when child blocks are added
iterations: payload.data?.count || DEFAULT_LOOP_ITERATIONS,
loopType: payload.data?.loopType || 'for',
- forEachItems: payload.data?.collection || '',
+ // Set the appropriate field based on loop type
+ ...(payload.data?.loopType === 'while' || payload.data?.loopType === 'doWhile'
+ ? { whileCondition: payload.data?.whileCondition || '' }
+ : { forEachItems: payload.data?.collection || '' }),
}
: {
id: payload.id,
@@ -856,18 +862,27 @@ async function handleSubflowOperationTx(
// Also update the corresponding block's data to keep UI in sync
if (payload.type === 'loop' && payload.config.iterations !== undefined) {
// Update the loop block's data.count property
+ const blockData: any = {
+ count: payload.config.iterations,
+ loopType: payload.config.loopType,
+ width: 500,
+ height: 300,
+ type: 'subflowNode',
+ }
+
+ // Add the appropriate field based on loop type
+ if (payload.config.loopType === 'while' || payload.config.loopType === 'doWhile') {
+ // For while and doWhile loops, use whileCondition
+ blockData.whileCondition = payload.config.whileCondition || ''
+ } else {
+ // For for/forEach loops, use collection (block data) which maps to forEachItems (loops store)
+ blockData.collection = payload.config.forEachItems || ''
+ }
+
await tx
.update(workflowBlocks)
.set({
- data: {
- ...payload.config,
- count: payload.config.iterations,
- loopType: payload.config.loopType,
- collection: payload.config.forEachItems,
- width: 500,
- height: 300,
- type: 'subflowNode',
- },
+ data: blockData,
updatedAt: new Date(),
})
.where(and(eq(workflowBlocks.id, payload.id), eq(workflowBlocks.workflowId, workflowId)))
diff --git a/apps/sim/stores/workflows/workflow/store.ts b/apps/sim/stores/workflows/workflow/store.ts
index a8b93deb09..d7a2df4f1c 100644
--- a/apps/sim/stores/workflows/workflow/store.ts
+++ b/apps/sim/stores/workflows/workflow/store.ts
@@ -764,7 +764,7 @@ export const useWorkflowStore = create()(
}
}),
- updateLoopType: (loopId: string, loopType: 'for' | 'forEach') =>
+ updateLoopType: (loopId: string, loopType: 'for' | 'forEach' | 'while' | 'doWhile') =>
set((state) => {
const block = state.blocks[loopId]
if (!block || block.type !== 'loop') return state
@@ -792,14 +792,21 @@ export const useWorkflowStore = create()(
const block = state.blocks[loopId]
if (!block || block.type !== 'loop') return state
+ const loopType = block.data?.loopType || 'for'
+
+ // Update the appropriate field based on loop type
+ const dataUpdate: any = { ...block.data }
+ if (loopType === 'while' || loopType === 'doWhile') {
+ dataUpdate.whileCondition = collection
+ } else {
+ dataUpdate.collection = collection
+ }
+
const newBlocks = {
...state.blocks,
[loopId]: {
...block,
- data: {
- ...block.data,
- collection,
- },
+ data: dataUpdate,
},
}
diff --git a/apps/sim/stores/workflows/workflow/types.ts b/apps/sim/stores/workflows/workflow/types.ts
index 05cb67d572..7e9a9490a1 100644
--- a/apps/sim/stores/workflows/workflow/types.ts
+++ b/apps/sim/stores/workflows/workflow/types.ts
@@ -16,8 +16,9 @@ export function isValidSubflowType(type: string): type is SubflowType {
export interface LoopConfig {
nodes: string[]
iterations: number
- loopType: 'for' | 'forEach'
+ loopType: 'for' | 'forEach' | 'while' | 'doWhile'
forEachItems?: any[] | Record | string
+ whileCondition?: string // JS expression that evaluates to boolean
}
export interface ParallelConfig {
@@ -50,9 +51,10 @@ export interface BlockData {
height?: number
// Loop-specific properties
- collection?: any // The items to iterate over in a loop
+ collection?: any // The items to iterate over in a forEach loop
count?: number // Number of iterations for numeric loops
- loopType?: 'for' | 'forEach' // Type of loop - must match Loop interface
+ loopType?: 'for' | 'forEach' | 'while' | 'doWhile' // Type of loop - must match Loop interface
+ whileCondition?: string // While/DoWhile loop condition (JS expression)
// Parallel-specific properties
parallelType?: 'collection' | 'count' // Type of parallel execution
@@ -121,8 +123,9 @@ export interface Loop {
id: string
nodes: string[]
iterations: number
- loopType: 'for' | 'forEach'
+ loopType: 'for' | 'forEach' | 'while' | 'doWhile'
forEachItems?: any[] | Record | string // Items or expression
+ whileCondition?: string // JS expression that evaluates to boolean
}
export interface Parallel {
@@ -194,7 +197,7 @@ export interface WorkflowActions {
updateBlockLayoutMetrics: (id: string, dimensions: { width: number; height: number }) => void
triggerUpdate: () => void
updateLoopCount: (loopId: string, count: number) => void
- updateLoopType: (loopId: string, loopType: 'for' | 'forEach') => void
+ updateLoopType: (loopId: string, loopType: 'for' | 'forEach' | 'while' | 'doWhile') => void
updateLoopCollection: (loopId: string, collection: string) => void
updateParallelCount: (parallelId: string, count: number) => void
updateParallelCollection: (parallelId: string, collection: string) => void
diff --git a/apps/sim/stores/workflows/workflow/utils.ts b/apps/sim/stores/workflows/workflow/utils.ts
index ea17f5fec6..8771383cdd 100644
--- a/apps/sim/stores/workflows/workflow/utils.ts
+++ b/apps/sim/stores/workflows/workflow/utils.ts
@@ -16,27 +16,38 @@ export function convertLoopBlockToLoop(
const loopBlock = blocks[loopBlockId]
if (!loopBlock || loopBlock.type !== 'loop') return undefined
- // Parse collection if it's a string representation of an array/object
- let forEachItems: any = loopBlock.data?.collection || ''
- if (typeof forEachItems === 'string' && forEachItems.trim()) {
- const trimmed = forEachItems.trim()
- // Try to parse if it looks like JSON
- if (trimmed.startsWith('[') || trimmed.startsWith('{')) {
- try {
- forEachItems = JSON.parse(trimmed)
- } catch {
- // Keep as string if parsing fails - will be evaluated at runtime
- }
- }
- }
+ const loopType = loopBlock.data?.loopType || 'for'
- return {
+ const loop: Loop = {
id: loopBlockId,
nodes: findChildNodes(loopBlockId, blocks),
iterations: loopBlock.data?.count || DEFAULT_LOOP_ITERATIONS,
- loopType: loopBlock.data?.loopType || 'for',
- forEachItems,
+ loopType,
}
+
+ // Set the appropriate field based on loop type
+ if (loopType === 'while' || loopType === 'doWhile') {
+ // For while and doWhile loops, use whileCondition
+ loop.whileCondition = loopBlock.data?.whileCondition || ''
+ } else {
+ // For for/forEach loops, read from collection (block data) and map to forEachItems (loops store)
+ // Parse collection if it's a string representation of an array/object
+ let forEachItems: any = loopBlock.data?.collection || ''
+ if (typeof forEachItems === 'string' && forEachItems.trim()) {
+ const trimmed = forEachItems.trim()
+ // Try to parse if it looks like JSON
+ if (trimmed.startsWith('[') || trimmed.startsWith('{')) {
+ try {
+ forEachItems = JSON.parse(trimmed)
+ } catch {
+ // Keep as string if parsing fails - will be evaluated at runtime
+ }
+ }
+ }
+ loop.forEachItems = forEachItems
+ }
+
+ return loop
}
/**