feat(while, vars, wait): add while subflow, variables block, wait block (#1754)

* Add variables block

* Add wait block

* While loop v1

* While loop v1

* Do while loops

* Copilot user input rerender fix

* Fix while and dowhile

* Vars block dropdown

* While loop docs

* Remove vars block coloring

* Fix lint

* Link docs to wait

* Fix build fail
This commit is contained in:
Siddharth Ganesan
2025-10-28 11:59:47 -07:00
committed by GitHub
parent ef5b6999ab
commit aace3066aa
47 changed files with 1618 additions and 183 deletions

View File

@@ -16,7 +16,7 @@ Blocks are the building components you connect together to create AI workflows.
## Core Block Types
Sim provides seven core block types that handle the essential functions of AI workflows:
Sim provides essential block types that handle the core functions of AI workflows:
### Processing Blocks
- **[Agent](/blocks/agent)** - Chat with AI models (OpenAI, Anthropic, Google, local models)
@@ -28,6 +28,10 @@ Sim provides seven core block types that handle the essential functions of AI wo
- **[Router](/blocks/router)** - Use AI to intelligently route requests to different paths
- **[Evaluator](/blocks/evaluator)** - Score and assess content quality using AI
### Control Flow Blocks
- **[Variables](/blocks/variables)** - Set and manage workflow-scoped variables
- **[Wait](/blocks/wait)** - Pause workflow execution for a specified time delay
### Output Blocks
- **[Response](/blocks/response)** - Format and return final results from your workflow
@@ -123,4 +127,10 @@ Each block type has specific configuration options:
<Card title="Condition Block" href="/blocks/condition">
Create branching logic based on data evaluation
</Card>
<Card title="Variables Block" href="/blocks/variables">
Set and manage workflow-scoped variables
</Card>
<Card title="Wait Block" href="/blocks/wait">
Pause workflow execution for specified time delays
</Card>
</Cards>

View File

@@ -9,7 +9,7 @@ import { Image } from '@/components/ui/image'
The Loop block is a container block in Sim that allows you to create iterative workflows by executing a group of blocks repeatedly. Loops enable iterative processing in your workflows.
The Loop block supports two types of iteration:
The Loop block supports four types of iteration:
<Callout type="info">
Loop blocks are container nodes that can hold other blocks inside them. The blocks inside a loop will execute multiple times based on your configuration.
@@ -27,7 +27,7 @@ The Loop block enables you to:
<strong>Repeat operations</strong>: Execute blocks a fixed number of times
</Step>
<Step>
<strong>Sequential processing</strong>: Handle data transformation in ordered iterations
<strong>Loop on conditions</strong>: Continue executing while or until a condition is met
</Step>
<Step>
<strong>Aggregate results</strong>: Collect outputs from all loop iterations
@@ -47,9 +47,9 @@ The Loop block executes contained blocks through sequential iteration:
### Loop Type
Choose between two types of loops:
Choose between four types of loops:
<Tabs items={['For Loop', 'ForEach Loop']}>
<Tabs items={['For Loop', 'ForEach Loop', 'While Loop', 'Do-While Loop']}>
<Tab>
**For Loop (Iterations)** - A numeric loop that executes a fixed number of times:
@@ -96,6 +96,54 @@ Choose between two types of loops:
- Iteration 3: Process "orange"
```
</Tab>
<Tab>
**While Loop (Condition-based)** - Continues executing while a condition evaluates to true:
<div className="flex justify-center">
<Image
src="/static/blocks/loop-3.png"
alt="While Loop with condition"
width={500}
height={400}
className="my-6"
/>
</div>
Use this when you need to loop until a specific condition is met. The condition is checked **before** each iteration.
```
Example: While <variable.i> < 10
- Check condition → Execute if true
- Inside loop: Increment <variable.i>
- Inside loop: Variables assigns i = <variable.i> + 1
- Check condition → Execute if true
- Check condition → Exit if false
```
</Tab>
<Tab>
**Do-While Loop (Condition-based)** - Executes at least once, then continues while a condition is true:
<div className="flex justify-center">
<Image
src="/static/blocks/loop-3.png"
alt="Do-While Loop with condition"
width={500}
height={400}
className="my-6"
/>
</div>
Use this when you need to execute at least once, then loop until a condition is met. The condition is checked **after** each iteration.
```
Example: Do-while <variable.i> < 10
- Execute blocks
- Inside loop: Increment <variable.i>
- Inside loop: Variables assigns i = <variable.i> + 1
- Check condition → Continue if true
- Check condition → Exit if false
```
</Tab>
</Tabs>
## How to Use Loops
@@ -139,6 +187,19 @@ After a loop completes, you can access aggregated results:
</ol>
</div>
### Counter with While Loop
<div className="mb-4 rounded-md border p-4">
<h4 className="font-medium">Scenario: Process items with counter-based loop</h4>
<ol className="list-decimal pl-5 text-sm">
<li>Initialize workflow variable: `i = 0`</li>
<li>While loop with condition: `<variable.i>` \< 10</li>
<li>Inside loop: Agent processes item at index `<variable.i>`</li>
<li>Inside loop: Variables increments `i = <variable.i> + 1`</li>
<li>Loop continues while i is less than 10</li>
</ol>
</div>
## Advanced Features
### Limitations
@@ -162,7 +223,7 @@ After a loop completes, you can access aggregated results:
<Tab>
<ul className="list-disc space-y-2 pl-6">
<li>
<strong>Loop Type</strong>: Choose between 'for' or 'forEach'
<strong>Loop Type</strong>: Choose between 'for', 'forEach', 'while', or 'doWhile'
</li>
<li>
<strong>Iterations</strong>: Number of times to execute (for loops)
@@ -170,6 +231,9 @@ After a loop completes, you can access aggregated results:
<li>
<strong>Collection</strong>: Array or object to iterate over (forEach loops)
</li>
<li>
<strong>Condition</strong>: Boolean expression to evaluate (while/do-while loops)
</li>
</ul>
</Tab>
<Tab>

View File

@@ -11,6 +11,8 @@
"parallel",
"response",
"router",
"variables",
"wait",
"workflow"
]
}

View File

@@ -0,0 +1,123 @@
---
title: Variables
---
import { Callout } from 'fumadocs-ui/components/callout'
import { Step, Steps } from 'fumadocs-ui/components/steps'
import { Image } from '@/components/ui/image'
The Variables block updates workflow variables during execution. Variables must first be initialized in your workflow's Variables section, then you can use this block to update their values as your workflow runs.
<div className="flex justify-center">
<Image
src="/static/blocks/variables.png"
alt="Variables Block"
width={500}
height={350}
className="my-6"
/>
</div>
<Callout>
Access variables anywhere in your workflow using `<variable.variableName>` syntax.
</Callout>
## Overview
The Variables block enables you to:
<Steps>
<Step>
<strong>Update workflow variables</strong>: Change variable values during execution
</Step>
<Step>
<strong>Store dynamic data</strong>: Capture block outputs into variables
</Step>
<Step>
<strong>Maintain state</strong>: Track counters, flags, and intermediate results
</Step>
</Steps>
## How to Use Variables
### 1. Initialize in Workflow Variables
First, create your variables in the workflow's Variables section (accessible from the workflow settings):
```
customerEmail = ""
retryCount = 0
currentStatus = "pending"
```
### 2. Update with Variables Block
Use the Variables block to update these values during execution:
```
customerEmail = <api.email>
retryCount = <variable.retryCount> + 1
currentStatus = "processing"
```
### 3. Access Anywhere
Reference variables in any block:
```
Agent prompt: "Send email to <variable.customerEmail>"
Condition: <variable.retryCount> < 5
API body: {"status": "<variable.currentStatus>"}
```
## Example Use Cases
### Loop Counter and State
<div className="mb-4 rounded-md border p-4">
<h4 className="font-medium">Scenario: Track progress through loop iterations</h4>
<ol className="list-decimal pl-5 text-sm">
<li>Initialize in workflow: `itemsProcessed = 0`, `lastResult = ""`</li>
<li>Loop iterates over items</li>
<li>Inside loop: Agent processes current item</li>
<li>Inside loop: Variables updates `itemsProcessed = <variable.itemsProcessed> + 1`</li>
<li>Inside loop: Variables updates `lastResult = <agent.content>`</li>
<li>Next iteration: Access `<variable.lastResult>` to compare with current result</li>
</ol>
</div>
### Retry Logic
<div className="mb-4 rounded-md border p-4">
<h4 className="font-medium">Scenario: Track API retry attempts</h4>
<ol className="list-decimal pl-5 text-sm">
<li>Initialize in workflow: `retryCount = 0`</li>
<li>API block attempts request</li>
<li>If failed, Variables increments: `retryCount = <variable.retryCount> + 1`</li>
<li>Condition checks if `<variable.retryCount>` \< 3 to retry or fail</li>
</ol>
</div>
### Dynamic Configuration
<div className="mb-4 rounded-md border p-4">
<h4 className="font-medium">Scenario: Store user context for workflow</h4>
<ol className="list-decimal pl-5 text-sm">
<li>Initialize in workflow: `userId = ""`, `userTier = ""`</li>
<li>API fetches user profile</li>
<li>Variables stores: `userId = <api.id>`, `userTier = <api.tier>`</li>
<li>Agent personalizes response using `<variable.userTier>`</li>
<li>API uses `<variable.userId>` for logging</li>
</ol>
</div>
## Outputs
- **`<variables.assignments>`**: JSON object with all variable assignments from this block
## Best Practices
- **Initialize in workflow settings**: Always create variables in the workflow Variables section before using them
- **Update dynamically**: Use Variables blocks to update values based on block outputs or calculations
- **Use in loops**: Perfect for tracking state across iterations
- **Name descriptively**: Use clear names like `currentIndex`, `totalProcessed`, or `lastError`

View File

@@ -0,0 +1,99 @@
---
title: Wait
---
import { Callout } from 'fumadocs-ui/components/callout'
import { Step, Steps } from 'fumadocs-ui/components/steps'
import { Image } from '@/components/ui/image'
The Wait block pauses your workflow for a specified amount of time before continuing to the next block. Use it to add delays between actions, respect API rate limits, or space out operations.
<div className="flex justify-center">
<Image
src="/static/blocks/wait.png"
alt="Wait Block"
width={500}
height={350}
className="my-6"
/>
</div>
## Overview
The Wait block enables you to:
<Steps>
<Step>
<strong>Add time delays</strong>: Pause execution between workflow steps
</Step>
<Step>
<strong>Respect rate limits</strong>: Space out API calls to stay within limits
</Step>
<Step>
<strong>Schedule sequences</strong>: Create timed workflows with delays between actions
</Step>
</Steps>
## Configuration
### Wait Amount
Enter the duration to pause execution:
- **Input**: Positive number
- **Maximum**: 600 seconds (10 minutes) or 10 minutes
### Unit
Choose the time unit:
- **Seconds**: For short, precise delays
- **Minutes**: For longer pauses
<Callout type="info">
Wait blocks can be cancelled by stopping the workflow. The maximum wait time is 10 minutes.
</Callout>
## Outputs
- **`<wait.waitDuration>`**: The wait duration in milliseconds
- **`<wait.status>`**: Status of the wait ('waiting', 'completed', or 'cancelled')
## Example Use Cases
### API Rate Limiting
<div className="mb-4 rounded-md border p-4">
<h4 className="font-medium">Scenario: Stay within API rate limits</h4>
<ol className="list-decimal pl-5 text-sm">
<li>API block makes first request</li>
<li>Wait block pauses for 2 seconds</li>
<li>API block makes second request</li>
<li>Process continues without hitting rate limits</li>
</ol>
</div>
### Timed Notifications
<div className="mb-4 rounded-md border p-4">
<h4 className="font-medium">Scenario: Send follow-up messages</h4>
<ol className="list-decimal pl-5 text-sm">
<li>Function sends initial email</li>
<li>Wait block pauses for 5 minutes</li>
<li>Function sends follow-up email</li>
</ol>
</div>
### Processing Delays
<div className="mb-4 rounded-md border p-4">
<h4 className="font-medium">Scenario: Wait for external system</h4>
<ol className="list-decimal pl-5 text-sm">
<li>API block triggers job in external system</li>
<li>Wait block pauses for 30 seconds</li>
<li>API block checks job completion status</li>
</ol>
</div>
## Best Practices
- **Keep waits reasonable**: Use Wait for delays up to 10 minutes. For longer delays, consider scheduled workflows
- **Monitor execution time**: Remember that waits extend total workflow duration

Binary file not shown.

After

Width:  |  Height:  |  Size: 118 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 101 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 49 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

View File

@@ -25,7 +25,8 @@ const BlockDataSchema = z.object({
height: z.number().optional(),
collection: z.unknown().optional(),
count: z.number().optional(),
loopType: z.enum(['for', 'forEach']).optional(),
loopType: z.enum(['for', 'forEach', 'while', 'doWhile']).optional(),
whileCondition: z.string().optional(),
parallelType: z.enum(['collection', 'count']).optional(),
type: z.string().optional(),
})
@@ -78,8 +79,9 @@ const LoopSchema = z.object({
id: z.string(),
nodes: z.array(z.string()),
iterations: z.number(),
loopType: z.enum(['for', 'forEach']),
loopType: z.enum(['for', 'forEach', 'while', 'doWhile']),
forEachItems: z.union([z.array(z.any()), z.record(z.any()), z.string()]).optional(),
whileCondition: z.string().optional(),
})
const ParallelSchema = z.object({

View File

@@ -266,7 +266,9 @@ function PinnedLogs({
<ChevronLeft className='h-4 w-4' />
</button>
<span className='px-2 text-muted-foreground text-xs'>
{currentIterationIndex + 1} / {iterationInfo.totalIterations}
{iterationInfo.totalIterations !== undefined
? `${currentIterationIndex + 1} / ${iterationInfo.totalIterations}`
: `${currentIterationIndex + 1}`}
</span>
<button
onClick={goToNextIteration}

View File

@@ -423,10 +423,12 @@ export function ConsoleEntry({ entry, consoleWidth }: ConsoleEntryProps) {
</span>
</div>
{/* Iteration tag - only show if iteration context exists */}
{entry.iterationCurrent !== undefined && entry.iterationTotal !== undefined && (
{entry.iterationCurrent !== undefined && (
<div className='flex h-5 items-center rounded-lg bg-secondary px-2'>
<span className='font-normal text-muted-foreground text-xs leading-normal'>
{entry.iterationCurrent}/{entry.iterationTotal}
{entry.iterationTotal !== undefined
? `${entry.iterationCurrent}/${entry.iterationTotal}`
: `${entry.iterationCurrent}`}
</span>
</div>
)}

View File

@@ -1396,6 +1396,8 @@ const UserInput = forwardRef<UserInputRef, UserInputProps>(
const caret = e.target.selectionStart ?? newValue.length
const active = getActiveMentionQueryAtPosition(caret, newValue)
if (active) {
// Sync workflow blocks when user types @
ensureWorkflowBlocksLoaded()
setShowMentionMenu(true)
setInAggregated(false)
if (openSubmenuFor) {
@@ -1827,6 +1829,8 @@ const UserInput = forwardRef<UserInputRef, UserInputProps>(
const pos = textarea.selectionStart ?? message.length
const needsSpaceBefore = pos > 0 && !/\s/.test(message.charAt(pos - 1))
insertAtCursor(needsSpaceBefore ? ' @' : '@')
// Sync workflow blocks when user clicks @ button
ensureWorkflowBlocksLoaded()
// Open the menu at top level
setShowMentionMenu(true)
setOpenSubmenuFor(null)
@@ -1904,58 +1908,33 @@ const UserInput = forwardRef<UserInputRef, UserInputProps>(
>([])
const [isLoadingWorkflowBlocks, setIsLoadingWorkflowBlocks] = useState(false)
// Sync workflow blocks from store whenever they change
useEffect(() => {
const syncWorkflowBlocks = async () => {
if (!workflowId || !workflowStoreBlocks || Object.keys(workflowStoreBlocks).length === 0) {
setWorkflowBlocks([])
logger.debug('No workflow blocks to sync', {
workflowId,
hasBlocks: !!workflowStoreBlocks,
blockCount: Object.keys(workflowStoreBlocks || {}).length,
})
return
}
try {
// Map to display with block registry icons/colors
const { registry: blockRegistry } = await import('@/blocks/registry')
const mapped = Object.values(workflowStoreBlocks).map((b: any) => {
const reg = (blockRegistry as any)[b.type]
return {
id: b.id,
name: b.name || b.id,
type: b.type,
iconComponent: reg?.icon,
bgColor: reg?.bgColor || '#6B7280',
}
})
setWorkflowBlocks(mapped)
logger.debug('Synced workflow blocks for mention menu', {
count: mapped.length,
blocks: mapped.map((b) => b.name),
})
} catch (error) {
logger.debug('Failed to sync workflow blocks:', error)
}
// Sync workflow blocks only when user opens the mention menu
const ensureWorkflowBlocksLoaded = async () => {
if (!workflowId || !workflowStoreBlocks || Object.keys(workflowStoreBlocks).length === 0) {
setWorkflowBlocks([])
return
}
syncWorkflowBlocks()
}, [workflowStoreBlocks, workflowId])
const ensureWorkflowBlocksLoaded = async () => {
// Since blocks are now synced from store via useEffect, this can be a no-op
// or just ensure the blocks are loaded in the store
if (!workflowId) return
// Debug: Log current state
logger.debug('ensureWorkflowBlocksLoaded called', {
workflowId,
storeBlocksCount: Object.keys(workflowStoreBlocks || {}).length,
workflowBlocksCount: workflowBlocks.length,
})
// Blocks will be automatically synced from the store
try {
setIsLoadingWorkflowBlocks(true)
// Map to display with block registry icons/colors
const { registry: blockRegistry } = await import('@/blocks/registry')
const mapped = Object.values(workflowStoreBlocks).map((b: any) => {
const reg = (blockRegistry as any)[b.type]
return {
id: b.id,
name: b.name || b.id,
type: b.type,
iconComponent: reg?.icon,
bgColor: reg?.bgColor || '#6B7280',
}
})
setWorkflowBlocks(mapped)
} catch (error) {
logger.error('Failed to sync workflow blocks:', error)
} finally {
setIsLoadingWorkflowBlocks(false)
}
}
const insertWorkflowBlockMention = (blk: { id: string; name: string }) => {

View File

@@ -13,7 +13,7 @@ import 'prismjs/components/prism-javascript'
import 'prismjs/themes/prism.css'
type IterationType = 'loop' | 'parallel'
type LoopType = 'for' | 'forEach'
type LoopType = 'for' | 'forEach' | 'while' | 'doWhile'
type ParallelType = 'count' | 'collection'
interface IterationNodeData {
@@ -46,14 +46,20 @@ interface IterationBadgesProps {
const CONFIG = {
loop: {
typeLabels: { for: 'For Loop', forEach: 'For Each' },
typeLabels: {
for: 'For Loop',
forEach: 'For Each',
while: 'While Loop',
doWhile: 'Do While Loop',
},
typeKey: 'loopType' as const,
storeKey: 'loops' as const,
maxIterations: 100,
configKeys: {
iterations: 'iterations' as const,
items: 'forEachItems' as const,
},
condition: 'whileCondition' as const,
} as any,
},
parallel: {
typeLabels: { count: 'Parallel Count', collection: 'Parallel Each' },
@@ -78,17 +84,30 @@ export function IterationBadges({ nodeId, data, iterationType }: IterationBadges
// Determine current type and values
const currentType = (data?.[config.typeKey] ||
(iterationType === 'loop' ? 'for' : 'count')) as any
// Determine if we're in count mode, collection mode, or condition mode
const isCountMode =
(iterationType === 'loop' && currentType === 'for') ||
(iterationType === 'parallel' && currentType === 'count')
const isConditionMode =
iterationType === 'loop' && (currentType === 'while' || currentType === 'doWhile')
const configIterations = (nodeConfig as any)?.[config.configKeys.iterations] ?? data?.count ?? 5
const configCollection = (nodeConfig as any)?.[config.configKeys.items] ?? data?.collection ?? ''
const configCondition =
iterationType === 'loop'
? ((nodeConfig as any)?.whileCondition ?? (data as any)?.whileCondition ?? '')
: ''
const iterations = configIterations
const collectionString =
typeof configCollection === 'string' ? configCollection : JSON.stringify(configCollection) || ''
const conditionString = typeof configCondition === 'string' ? configCondition : ''
// State management
const [tempInputValue, setTempInputValue] = useState<string | null>(null)
const inputValue = tempInputValue ?? iterations.toString()
const editorValue = collectionString
const editorValue = isConditionMode ? conditionString : collectionString
const [typePopoverOpen, setTypePopoverOpen] = useState(false)
const [configPopoverOpen, setConfigPopoverOpen] = useState(false)
const [showTagDropdown, setShowTagDropdown] = useState(false)
@@ -190,11 +209,6 @@ export function IterationBadges({ nodeId, data, iterationType }: IterationBadges
[nodeId, iterationType, collaborativeUpdateIterationCollection, isPreview]
)
// Determine if we're in count mode or collection mode
const isCountMode =
(iterationType === 'loop' && currentType === 'for') ||
(iterationType === 'parallel' && currentType === 'count')
// Get type options
const typeOptions = Object.entries(config.typeLabels)
@@ -259,7 +273,7 @@ export function IterationBadges({ nodeId, data, iterationType }: IterationBadges
)}
style={{ pointerEvents: isPreview ? 'none' : 'auto' }}
>
{isCountMode ? `Iterations: ${iterations}` : 'Items'}
{isCountMode ? `Iterations: ${iterations}` : isConditionMode ? 'Condition' : 'Items'}
{!isPreview && <ChevronDown className='h-3 w-3 text-muted-foreground' />}
</Badge>
</PopoverTrigger>
@@ -273,7 +287,9 @@ export function IterationBadges({ nodeId, data, iterationType }: IterationBadges
<div className='font-medium text-muted-foreground text-xs'>
{isCountMode
? `${iterationType === 'loop' ? 'Loop' : 'Parallel'} Iterations`
: `${iterationType === 'loop' ? 'Collection' : 'Parallel'} Items`}
: isConditionMode
? 'While Condition'
: `${iterationType === 'loop' ? 'Collection' : 'Parallel'} Items`}
</div>
{isCountMode ? (
@@ -289,6 +305,44 @@ export function IterationBadges({ nodeId, data, iterationType }: IterationBadges
autoFocus
/>
</div>
) : isConditionMode ? (
// Code editor for while condition
<div ref={editorContainerRef} className='relative'>
<div className='relative min-h-[80px] rounded-md border border-input bg-background px-3 pt-2 pb-3 font-mono text-sm'>
{conditionString === '' && (
<div className='pointer-events-none absolute top-[8.5px] left-3 select-none text-muted-foreground/50'>
{'<counter.value> < 10'}
</div>
)}
<Editor
value={conditionString}
onValueChange={handleEditorChange}
highlight={(code) => 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'
/>
</div>
<div className='mt-2 text-[10px] text-muted-foreground'>
JavaScript expression that evaluates to true/false. Type "{'<'}" to reference
blocks.
</div>
{showTagDropdown && (
<TagDropdown
visible={showTagDropdown}
onSelect={handleTagSelect}
blockId={nodeId}
activeSourceBlockId={null}
inputValue={conditionString}
cursorPosition={cursorPosition}
onClose={() => setShowTagDropdown(false)}
/>
)}
</div>
) : (
// Code editor for collection-based mode
<div ref={editorContainerRef} className='relative'>

View File

@@ -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'

View File

@@ -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<VariableAssignment, 'id'> = {
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<VariableAssignment[]>(blockId, subBlockId)
const { variables: workflowVariables } = useVariablesStore()
const accessiblePrefixes = useAccessibleReferencePrefixes(blockId)
const [showTags, setShowTags] = useState(false)
const [cursorPosition, setCursorPosition] = useState(0)
const [activeFieldId, setActiveFieldId] = useState<string | null>(null)
const [activeSourceBlockId, setActiveSourceBlockId] = useState<string | null>(null)
const valueInputRefs = useRef<Record<string, HTMLInputElement | HTMLTextAreaElement>>({})
const overlayRefs = useRef<Record<string, HTMLDivElement>>({})
const [dragHighlight, setDragHighlight] = useState<Record<string, boolean>>({})
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<VariableAssignment>) => {
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 (
<div className='flex items-center justify-center rounded-md border border-border/40 border-dashed bg-muted/20 p-4 text-center text-muted-foreground text-sm'>
No variable assignments defined
</div>
)
}
return (
<div className='space-y-2'>
{assignments && assignments.length > 0 ? (
<div className='space-y-2'>
{assignments.map((assignment) => {
return (
<div
key={assignment.id}
className='group relative rounded-lg border border-border/60 bg-background p-3 transition-all hover:border-border'
>
{!isPreview && !disabled && (
<Button
variant='ghost'
size='icon'
className='absolute top-2 right-2 h-6 w-6 opacity-0 group-hover:opacity-100'
onClick={() => removeAssignment(assignment.id)}
>
<Trash className='h-3.5 w-3.5' />
</Button>
)}
<div className='space-y-3'>
<div className='space-y-1.5'>
<Label className='text-muted-foreground text-xs'>Variable</Label>
<Select
value={assignment.variableId || assignment.variableName || ''}
onValueChange={(value) => {
if (value === '__new__') {
return
}
handleVariableSelect(assignment.id, value)
}}
disabled={isPreview || disabled}
>
<SelectTrigger className='h-9 bg-white dark:bg-background'>
<SelectValue placeholder='Select a variable...' />
</SelectTrigger>
<SelectContent>
{(() => {
const availableVars = getAvailableVariablesFor(assignment.id)
return availableVars.length > 0 ? (
availableVars.map((variable) => (
<SelectItem key={variable.id} value={variable.id}>
<div className='flex items-center gap-2'>
<span>{variable.name}</span>
<Badge variant='outline' className='text-[10px]'>
{variable.type}
</Badge>
</div>
</SelectItem>
))
) : (
<div className='p-2 text-center text-muted-foreground text-sm'>
{currentWorkflowVariables.length > 0
? 'All variables have been assigned.'
: 'No variables defined in this workflow.'}
{currentWorkflowVariables.length === 0 && (
<>
<br />
Add them in the Variables panel.
</>
)}
</div>
)
})()}
</SelectContent>
</Select>
</div>
<div className='space-y-1.5'>
<Label className='text-muted-foreground text-xs'>Type</Label>
<Input
value={assignment.type || 'string'}
disabled={true}
className='h-9 bg-muted/50 text-muted-foreground'
/>
</div>
<div className='relative space-y-1.5'>
<Label className='text-muted-foreground text-xs'>Value</Label>
{assignment.type === 'object' || assignment.type === 'array' ? (
<Textarea
ref={(el) => {
if (el) valueInputRefs.current[assignment.id] = el
}}
value={assignment.value || ''}
onChange={(e) =>
handleValueInputChange(
assignment.id,
e.target.value,
e.target.selectionStart ?? undefined
)
}
placeholder={
assignment.type === 'object'
? '{\n "key": "value"\n}'
: '[\n 1, 2, 3\n]'
}
disabled={isPreview || disabled}
className={cn(
'min-h-[120px] border border-input bg-white font-mono text-sm dark:bg-background',
dragHighlight[assignment.id] && 'ring-2 ring-blue-500 ring-offset-2',
isConnecting && 'ring-2 ring-blue-500 ring-offset-2'
)}
onDrop={(e) => handleDrop(e, assignment.id)}
onDragOver={(e) => handleDragOver(e, assignment.id)}
onDragLeave={(e) => handleDragLeave(e, assignment.id)}
/>
) : (
<div className='relative'>
<Input
ref={(el) => {
if (el) valueInputRefs.current[assignment.id] = el
}}
value={assignment.value || ''}
onChange={(e) =>
handleValueInputChange(
assignment.id,
e.target.value,
e.target.selectionStart ?? undefined
)
}
placeholder={`${assignment.type} value`}
disabled={isPreview || disabled}
className={cn(
'h-9 bg-white text-transparent caret-foreground dark:bg-background',
dragHighlight[assignment.id] && 'ring-2 ring-blue-500 ring-offset-2',
isConnecting && 'ring-2 ring-blue-500 ring-offset-2'
)}
onDrop={(e) => handleDrop(e, assignment.id)}
onDragOver={(e) => handleDragOver(e, assignment.id)}
onDragLeave={(e) => handleDragLeave(e, assignment.id)}
/>
<div
ref={(el) => {
if (el) overlayRefs.current[assignment.id] = el
}}
className='pointer-events-none absolute inset-0 flex items-center overflow-hidden bg-transparent px-3 text-sm'
>
<div className='w-full whitespace-nowrap'>
{formatDisplayText(assignment.value || '', {
accessiblePrefixes,
highlightAll: !accessiblePrefixes,
})}
</div>
</div>
</div>
)}
{showTags && activeFieldId === assignment.id && (
<TagDropdown
visible={showTags}
onSelect={handleTagSelect}
blockId={blockId}
activeSourceBlockId={activeSourceBlockId}
inputValue={assignment.value || ''}
cursorPosition={cursorPosition}
onClose={() => setShowTags(false)}
className='absolute top-full left-0 z-50 mt-1'
/>
)}
</div>
</div>
</div>
)
})}
</div>
) : null}
{!isPreview && !disabled && (
<Button
onClick={addAssignment}
variant='outline'
size='sm'
className='w-full border-dashed'
disabled={hasNoWorkflowVariables || allVariablesAssigned}
>
{!hasNoWorkflowVariables && <Plus className='mr-2 h-4 w-4' />}
{hasNoWorkflowVariables
? 'No variables found'
: allVariablesAssigned
? 'All Variables Assigned'
: 'Add Variable Assignment'}
</Button>
)}
</div>
)
}

View File

@@ -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'
@@ -473,6 +474,18 @@ export const SubBlock = memo(
/>
)
}
case 'variables-input': {
return (
<VariablesInput
blockId={blockId}
subBlockId={config.id}
isPreview={isPreview}
previewValue={previewValue}
disabled={isDisabled}
isConnecting={isConnecting}
/>
)
}
case 'response-format':
return (
<ResponseFormat

View File

@@ -0,0 +1,47 @@
import { Variable } from '@/components/icons'
import type { BlockConfig } from '@/blocks/types'
export const VariablesBlock: BlockConfig = {
type: 'variables',
name: 'Variables',
description: 'Set workflow-scoped variables',
longDescription:
'Set workflow-scoped variables that can be accessed throughout the workflow using <variable.variableName> 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 <variable.variableName> 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 <variable.> 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 <variable.variableName> 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',
},
},
}

View File

@@ -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<SVGSVGElement>) => 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)',
},
},
}

View File

@@ -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<string, BlockConfig> = {
translate: TranslateBlock,
twilio_sms: TwilioSMSBlock,
typeform: TypeformBlock,
variables: VariablesBlock,
vision: VisionBlock,
wait: WaitBlock,
wealthbox: WealthboxBlock,
webflow: WebflowBlock,
webhook: WebhookBlock,

View File

@@ -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'

View File

@@ -3795,3 +3795,24 @@ export function WebflowIcon(props: SVGProps<SVGSVGElement>) {
</svg>
)
}
export function Variable(props: SVGProps<SVGSVGElement>) {
return (
<svg
{...props}
xmlns='http://www.w3.org/2000/svg'
width='24'
height='24'
viewBox='0 0 24 24'
fill='none'
stroke='currentColor'
strokeWidth='2'
strokeLinecap='round'
strokeLinejoin='round'
>
<path d='M7 8l-4 4 4 4' />
<path d='M17 8l4 4-4 4' />
<line x1='14' y1='4' x2='10' y2='20' />
</svg>
)
}

View File

@@ -585,13 +585,43 @@ export const TagDropdown: React.FC<TagDropdownProps> = ({
)
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')

View File

@@ -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'),
}))

View File

@@ -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',
}
/**

View File

@@ -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\(\)\)$/
)
})

View File

@@ -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<string, any>
): Promise<boolean> {
// 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}`)
}
}

View File

@@ -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,
}

View File

@@ -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<string, any> | 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<string, any>
}
// 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,

View File

@@ -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<string, any>,
context: ExecutionContext
): Promise<BlockOutput> {
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<string, any> = {}
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
}
}

View File

@@ -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<boolean> => {
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<string, any>,
context: ExecutionContext
): Promise<any> {
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',
}
}
}

View File

@@ -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'
}
}

View File

@@ -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}`)
// 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)
this.resetLoopBlocks(loopId, loop, context)
this.resetLoopBlocks(loopId, loop, context)
context.executedBlocks.delete(loopId)
context.blockStates.delete(loopId)
logger.info(`Loop ${loopId} - Reset for iteration ${currentIteration + 1}`)
context.executedBlocks.delete(loopId)
context.blockStates.delete(loopId)
}
}
}
}

View File

@@ -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', () => {

View File

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

View File

@@ -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 }, () =>

View File

@@ -882,8 +882,9 @@ const SPECIAL_BLOCKS_METADATA: Record<string, any> = {
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<string, any> = {
description: "Collection to iterate over (for 'forEach' loopType)",
example: '<previousblock.items>',
},
condition: {
type: 'string',
required: false,
description: "Condition to evaluate (for 'while' and 'doWhile' loopType)",
example: '<loop.currentIteration> < 10',
},
maxConcurrency: {
type: 'number',
required: false,
@@ -924,6 +931,8 @@ const SPECIAL_BLOCKS_METADATA: Record<string, any> = {
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<string, any> = {
placeholder: 'Array or object to iterate over...',
condition: { field: 'loopType', value: 'forEach' },
},
{
id: 'condition',
title: 'Condition',
type: 'code',
language: 'javascript',
placeholder: '<counter.value> < 10',
condition: { field: 'loopType', value: ['while', 'doWhile'] },
},
{
id: 'maxConcurrency',
title: 'Max Concurrency',

View File

@@ -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

View File

@@ -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

View File

@@ -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, any> | string // Items or expression
whileCondition?: string // JS expression that evaluates to boolean
}
export interface Parallel {

View File

@@ -180,10 +180,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) {

View File

@@ -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 {

View File

@@ -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, any> | 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 {

View File

@@ -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)))

View File

@@ -764,7 +764,7 @@ export const useWorkflowStore = create<WorkflowStore>()(
}
}),
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<WorkflowStore>()(
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,
},
}

View File

@@ -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, any> | 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, any> | 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

View File

@@ -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
}
/**