mirror of
https://github.com/simstudioai/sim.git
synced 2026-09-24 15:45:35 +08:00
feat(loops) (#202)
* feat(loops): finished for loop; added for each and while ui * fix(packages): updated loop package * feat(loops): added forEach loop and special variables for loop; fixed execution console * improvement: deleted while loop * feat(loops): updated tests
This commit is contained in:
@@ -62,7 +62,7 @@ export interface Connection {
|
||||
|
||||
export interface Loop {
|
||||
nodes: string[]
|
||||
maxIterations: number
|
||||
iterations: number
|
||||
iterationVariable?: string
|
||||
}
|
||||
|
||||
|
||||
@@ -55,11 +55,11 @@ export class WorkflowBuilder {
|
||||
/**
|
||||
* Create a loop with specific blocks
|
||||
*/
|
||||
createLoop(nodes: string[], maxIterations: number, iterationVariable?: string): this {
|
||||
createLoop(nodes: string[], iterations: number, iterationVariable?: string): this {
|
||||
const loopId = `loop_${Date.now()}`
|
||||
const loop: Loop = {
|
||||
nodes,
|
||||
maxIterations,
|
||||
iterations,
|
||||
...(iterationVariable ? { iterationVariable } : {})
|
||||
}
|
||||
this.workflow.loops = {
|
||||
|
||||
@@ -69,16 +69,16 @@ export async function POST(req: NextRequest) {
|
||||
log: (...args: any[]) => {
|
||||
const logMessage =
|
||||
args
|
||||
.map((arg) => (typeof arg === 'object' ? JSON.stringify(arg, null, 2) : String(arg)))
|
||||
.join(' ') + '\n'
|
||||
.map((arg) => (typeof arg === 'object' ? JSON.stringify(arg) : String(arg)))
|
||||
.join(' ')
|
||||
stdout += logMessage
|
||||
},
|
||||
error: (...args: any[]) => {
|
||||
const errorMessage =
|
||||
args
|
||||
.map((arg) => (typeof arg === 'object' ? JSON.stringify(arg, null, 2) : String(arg)))
|
||||
.join(' ') + '\n'
|
||||
logger.error(`[${requestId}] Code Console Error:`, errorMessage.trim())
|
||||
.map((arg) => (typeof arg === 'object' ? JSON.stringify(arg) : String(arg)))
|
||||
.join(' ')
|
||||
logger.error(`[${requestId}] Code Console Error:`, errorMessage)
|
||||
stdout += 'ERROR: ' + errorMessage
|
||||
},
|
||||
},
|
||||
|
||||
+48
-51
@@ -69,8 +69,8 @@ export function ConnectionBlocks({ blockId, setIsConnecting }: ConnectionBlocksP
|
||||
}))
|
||||
}
|
||||
|
||||
// Group connections by their ID for better organization
|
||||
const connectionsByBlock = incomingConnections.reduce(
|
||||
// Deduplicate connections by ID
|
||||
const connectionMap = incomingConnections.reduce(
|
||||
(acc, connection) => {
|
||||
acc[connection.id] = connection
|
||||
return acc
|
||||
@@ -78,61 +78,58 @@ export function ConnectionBlocks({ blockId, setIsConnecting }: ConnectionBlocksP
|
||||
{} as Record<string, ConnectedBlock>
|
||||
)
|
||||
|
||||
// Sort connections by name to make it easier to find blocks
|
||||
const sortedConnections = Object.values(connectionsByBlock).sort((a, b) =>
|
||||
// Sort connections by name
|
||||
const sortedConnections = Object.values(connectionMap).sort((a, b) =>
|
||||
a.name.localeCompare(b.name)
|
||||
)
|
||||
|
||||
// Helper function to render a connection card
|
||||
const renderConnectionCard = (connection: ConnectedBlock, field?: ResponseField) => {
|
||||
const displayName = connection.name.replace(/\s+/g, '').toLowerCase()
|
||||
|
||||
return (
|
||||
<Card
|
||||
key={`${field ? field.name : connection.id}`}
|
||||
draggable
|
||||
onDragStart={(e) => handleDragStart(e, connection, field)}
|
||||
onDragEnd={handleDragEnd}
|
||||
className="group flex items-center rounded-lg border bg-card p-2 shadow-sm transition-colors hover:bg-accent/50 cursor-grab active:cursor-grabbing w-max"
|
||||
>
|
||||
<div className="text-sm">
|
||||
<span className="font-medium leading-none">{displayName}</span>
|
||||
<span className="text-muted-foreground">
|
||||
{field
|
||||
? `.${field.name}`
|
||||
: typeof connection.outputType === 'string'
|
||||
? `.${connection.outputType}`
|
||||
: ''}
|
||||
</span>
|
||||
</div>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="absolute right-full pr-5 top-0 space-y-2 flex flex-col items-end max-h-[400px] overflow-y-auto">
|
||||
{sortedConnections.map((connection) => (
|
||||
<div key={connection.id} className="space-y-2">
|
||||
{Array.isArray(connection.outputType) ? (
|
||||
// Handle array of field names
|
||||
connection.outputType.map((fieldName) => {
|
||||
// Try to find field in response format
|
||||
const fields = extractFieldsFromSchema(connection)
|
||||
const field = fields.find((f) => f.name === fieldName) || {
|
||||
name: fieldName,
|
||||
type: 'string',
|
||||
}
|
||||
{sortedConnections.map((connection, index) => {
|
||||
return (
|
||||
<div key={`${connection.id}-${index}`} className="space-y-2">
|
||||
{Array.isArray(connection.outputType)
|
||||
? // Handle array of field names
|
||||
connection.outputType.map((fieldName) => {
|
||||
// Try to find field in response format
|
||||
const fields = extractFieldsFromSchema(connection)
|
||||
const field = fields.find((f) => f.name === fieldName) || {
|
||||
name: fieldName,
|
||||
type: 'string',
|
||||
}
|
||||
|
||||
return (
|
||||
<Card
|
||||
key={field.name}
|
||||
draggable
|
||||
onDragStart={(e) => handleDragStart(e, connection, field)}
|
||||
onDragEnd={handleDragEnd}
|
||||
className="group flex items-center rounded-lg border bg-card p-2 shadow-sm transition-colors hover:bg-accent/50 cursor-grab active:cursor-grabbing w-max"
|
||||
>
|
||||
<div className="text-sm">
|
||||
<span className="font-medium leading-none">
|
||||
{connection.name.replace(/\s+/g, '').toLowerCase()}
|
||||
</span>
|
||||
<span className="text-muted-foreground">.{field.name}</span>
|
||||
</div>
|
||||
</Card>
|
||||
)
|
||||
})
|
||||
) : (
|
||||
<Card
|
||||
draggable
|
||||
onDragStart={(e) => handleDragStart(e, connection)}
|
||||
onDragEnd={handleDragEnd}
|
||||
className="group flex items-center rounded-lg border bg-card p-2 shadow-sm transition-colors hover:bg-accent/50 cursor-grab active:cursor-grabbing w-max"
|
||||
>
|
||||
<div className="text-sm">
|
||||
<span className="font-medium leading-none">
|
||||
{connection.name.replace(/\s+/g, '').toLowerCase()}
|
||||
</span>
|
||||
<span className="text-muted-foreground">
|
||||
{typeof connection.outputType === 'string' ? `.${connection.outputType}` : ''}
|
||||
</span>
|
||||
</div>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
return renderConnectionCard(connection, field)
|
||||
})
|
||||
: renderConnectionCard(connection)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
import { useState } from 'react'
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { ChevronDown } from 'lucide-react'
|
||||
import { highlight, languages } from 'prismjs'
|
||||
import 'prismjs/components/prism-javascript'
|
||||
import 'prismjs/themes/prism.css'
|
||||
import Editor from 'react-simple-code-editor'
|
||||
import { NodeProps } from 'reactflow'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Input } from '@/components/ui/input'
|
||||
@@ -11,63 +15,59 @@ export function LoopInput({ id }: NodeProps) {
|
||||
// Extract the loop ID from the node ID
|
||||
const loopId = id.replace('loop-input-', '')
|
||||
|
||||
// Get the max iterations from the store for this loop
|
||||
const maxIterations = useWorkflowStore((state) => state.loops[loopId]?.maxIterations ?? 5)
|
||||
const minIterations = useWorkflowStore((state) => state.loops[loopId]?.minIterations ?? 0)
|
||||
const updateLoopMaxIterations = useWorkflowStore((state) => state.updateLoopMaxIterations)
|
||||
const updateLoopMinIterations = useWorkflowStore((state) => state.updateLoopMinIterations)
|
||||
// Get the loop data from the store
|
||||
const loop = useWorkflowStore((state) => state.loops[loopId])
|
||||
const iterations = loop?.iterations ?? 5
|
||||
const loopType = loop?.loopType || 'for'
|
||||
const updateLoopIterations = useWorkflowStore((state) => state.updateLoopIterations)
|
||||
const updateLoopForEachItems = useWorkflowStore((state) => state.updateLoopForEachItems)
|
||||
|
||||
// Local state for input values
|
||||
const [maxInputValue, setMaxInputValue] = useState(maxIterations.toString())
|
||||
const [minInputValue, setMinInputValue] = useState(minIterations.toString())
|
||||
const [inputValue, setInputValue] = useState(iterations.toString())
|
||||
const [editorValue, setEditorValue] = useState('')
|
||||
const [open, setOpen] = useState(false)
|
||||
const editorRef = useRef<HTMLDivElement | null>(null)
|
||||
|
||||
const handleMaxChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
// Initialize editor value from the store
|
||||
useEffect(() => {
|
||||
if (loopType === 'forEach' && loop?.forEachItems) {
|
||||
// Handle different types of forEachItems
|
||||
if (typeof loop.forEachItems === 'string') {
|
||||
// Preserve the string exactly as stored
|
||||
setEditorValue(loop.forEachItems)
|
||||
} else if (Array.isArray(loop.forEachItems) || typeof loop.forEachItems === 'object') {
|
||||
// For new objects/arrays from the store, use default formatting
|
||||
// This only happens for data loaded from DB that wasn't originally user-formatted
|
||||
setEditorValue(JSON.stringify(loop.forEachItems))
|
||||
}
|
||||
} else if (loopType === 'forEach') {
|
||||
setEditorValue('')
|
||||
}
|
||||
}, [loopType, loop?.forEachItems])
|
||||
|
||||
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const sanitizedValue = e.target.value.replace(/[^0-9]/g, '')
|
||||
const numValue = parseInt(sanitizedValue)
|
||||
|
||||
// Only update if it's a valid number and <= 50
|
||||
if (!isNaN(numValue)) {
|
||||
setMaxInputValue(Math.min(50, numValue).toString())
|
||||
setInputValue(Math.min(50, numValue).toString())
|
||||
} else {
|
||||
setMaxInputValue(sanitizedValue)
|
||||
}
|
||||
}
|
||||
|
||||
const handleMinChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const sanitizedValue = e.target.value.replace(/[^0-9]/g, '')
|
||||
const numValue = parseInt(sanitizedValue)
|
||||
|
||||
// Only update if it's a valid number and <= max
|
||||
if (!isNaN(numValue)) {
|
||||
setMinInputValue(Math.min(parseInt(maxInputValue) || 50, numValue).toString())
|
||||
} else {
|
||||
setMinInputValue(sanitizedValue)
|
||||
setInputValue(sanitizedValue)
|
||||
}
|
||||
}
|
||||
|
||||
const handleSave = () => {
|
||||
const maxValue = parseInt(maxInputValue)
|
||||
const minValue = parseInt(minInputValue)
|
||||
const value = parseInt(inputValue)
|
||||
|
||||
if (!isNaN(maxValue)) {
|
||||
const newMaxValue = Math.min(50, Math.max(minValue, maxValue))
|
||||
updateLoopMaxIterations(loopId, newMaxValue)
|
||||
if (!isNaN(value)) {
|
||||
const newValue = Math.min(50, Math.max(1, value))
|
||||
updateLoopIterations(loopId, newValue)
|
||||
// Sync input with store value
|
||||
setMaxInputValue(newMaxValue.toString())
|
||||
setInputValue(newValue.toString())
|
||||
} else {
|
||||
// Reset to current store value if invalid
|
||||
setMaxInputValue(maxIterations.toString())
|
||||
}
|
||||
|
||||
if (!isNaN(minValue)) {
|
||||
const newMinValue = Math.min(maxValue, Math.max(0, minValue))
|
||||
updateLoopMinIterations(loopId, newMinValue)
|
||||
// Sync input with store value
|
||||
setMinInputValue(newMinValue.toString())
|
||||
} else {
|
||||
// Reset to current store value if invalid
|
||||
setMinInputValue(minIterations.toString())
|
||||
setInputValue(iterations.toString())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -79,6 +79,38 @@ export function LoopInput({ id }: NodeProps) {
|
||||
}
|
||||
}
|
||||
|
||||
const handleEditorChange = (value: string) => {
|
||||
// Always set the editor value to exactly what the user typed
|
||||
setEditorValue(value)
|
||||
|
||||
// Save the items to the store for forEach loops
|
||||
if (loopType === 'forEach') {
|
||||
// Pass the exact string to preserve formatting
|
||||
updateLoopForEachItems(loopId, value)
|
||||
}
|
||||
}
|
||||
|
||||
// Determine label based on loop type
|
||||
const getLabel = () => {
|
||||
switch (loopType) {
|
||||
case 'for':
|
||||
return `Iterations: ${iterations}`
|
||||
case 'forEach':
|
||||
return 'Items'
|
||||
default:
|
||||
return `Iterations: ${iterations}`
|
||||
}
|
||||
}
|
||||
|
||||
const getPlaceholder = () => {
|
||||
switch (loopType) {
|
||||
case 'forEach':
|
||||
return "['item1', 'item2', 'item3']"
|
||||
default:
|
||||
return ''
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Popover open={open} onOpenChange={setOpen}>
|
||||
<PopoverTrigger asChild onClick={(e) => e.stopPropagation()}>
|
||||
@@ -90,40 +122,62 @@ export function LoopInput({ id }: NodeProps) {
|
||||
'flex items-center gap-1'
|
||||
)}
|
||||
>
|
||||
Iterations: {minIterations}-{maxIterations}
|
||||
{getLabel()}
|
||||
<ChevronDown className="h-3 w-3 text-muted-foreground" />
|
||||
</Badge>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-48 p-3" align="start" onClick={(e) => e.stopPropagation()}>
|
||||
<PopoverContent
|
||||
className={cn('p-3', loopType !== 'for' ? 'w-64' : 'w-48')}
|
||||
align="start"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<div className="space-y-2">
|
||||
<div className="text-xs font-medium text-muted-foreground">Min Iterations</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Input
|
||||
type="text"
|
||||
value={minInputValue}
|
||||
onChange={handleMinChange}
|
||||
onBlur={handleSave}
|
||||
onKeyDown={handleKeyDown}
|
||||
className="h-8 text-sm"
|
||||
/>
|
||||
</div>
|
||||
<div className="text-[10px] text-muted-foreground">
|
||||
Enter a number between 0 and {maxInputValue}
|
||||
<div className="text-xs font-medium text-muted-foreground">
|
||||
{loopType === 'for' ? 'Loop Iterations' : 'Collection Items'}
|
||||
</div>
|
||||
|
||||
<div className="mt-3 text-xs font-medium text-muted-foreground">Max Iterations</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Input
|
||||
type="text"
|
||||
value={maxInputValue}
|
||||
onChange={handleMaxChange}
|
||||
onBlur={handleSave}
|
||||
onKeyDown={handleKeyDown}
|
||||
className="h-8 text-sm"
|
||||
/>
|
||||
</div>
|
||||
{loopType === 'for' ? (
|
||||
// Number input for 'for' loops
|
||||
<div className="flex items-center gap-2">
|
||||
<Input
|
||||
type="text"
|
||||
value={inputValue}
|
||||
onChange={handleChange}
|
||||
onBlur={handleSave}
|
||||
onKeyDown={handleKeyDown}
|
||||
className="h-8 text-sm"
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
// Code editor for 'forEach' loops
|
||||
<div
|
||||
className="relative min-h-[80px] rounded-md bg-background font-mono text-sm px-3 pt-2 pb-3 border border-input"
|
||||
ref={editorRef}
|
||||
>
|
||||
{editorValue === '' && (
|
||||
<div className="absolute top-[8.5px] left-3 text-muted-foreground/50 pointer-events-none select-none">
|
||||
{getPlaceholder()}
|
||||
</div>
|
||||
)}
|
||||
<Editor
|
||||
value={editorValue}
|
||||
onValueChange={handleEditorChange}
|
||||
highlight={(code) => highlight(code, languages.javascript, 'javascript')}
|
||||
padding={0}
|
||||
style={{
|
||||
fontFamily: 'monospace',
|
||||
lineHeight: '21px',
|
||||
}}
|
||||
className="focus:outline-none w-full"
|
||||
textareaClassName="focus:outline-none focus:ring-0 bg-transparent resize-none w-full overflow-hidden whitespace-pre-wrap"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="text-[10px] text-muted-foreground">
|
||||
Enter a number between {minInputValue || 1} and 50
|
||||
{loopType === 'for'
|
||||
? 'Enter a number between 1 and 50'
|
||||
: 'Define the collection to iterate over'}
|
||||
</div>
|
||||
</div>
|
||||
</PopoverContent>
|
||||
|
||||
@@ -1,14 +1,79 @@
|
||||
import { useState } from 'react'
|
||||
import { ChevronDown } from 'lucide-react'
|
||||
import { NodeProps } from 'reactflow'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { useWorkflowStore } from '@/stores/workflows/workflow/store'
|
||||
|
||||
export function LoopLabel({ id, data }: NodeProps) {
|
||||
// Extract the loop ID from the node ID
|
||||
const loopId = id.replace('loop-label-', '')
|
||||
|
||||
// Get the loop type from the store
|
||||
const loop = useWorkflowStore((state) => state.loops[loopId])
|
||||
const updateLoopType = useWorkflowStore((state) => state.updateLoopType)
|
||||
|
||||
// Local state for popover
|
||||
const [open, setOpen] = useState(false)
|
||||
|
||||
// Default to 'for' if not set
|
||||
const loopType = loop?.loopType || 'for'
|
||||
|
||||
// Get label based on loop type
|
||||
const getLoopLabel = () => {
|
||||
switch (loopType) {
|
||||
case 'for':
|
||||
return 'For loop'
|
||||
case 'forEach':
|
||||
return 'For each'
|
||||
default:
|
||||
return 'Loop'
|
||||
}
|
||||
}
|
||||
|
||||
const handleLoopTypeChange = (type: 'for' | 'forEach') => {
|
||||
updateLoopType(loopId, type)
|
||||
setOpen(false)
|
||||
}
|
||||
|
||||
export function LoopLabel({ data }: NodeProps) {
|
||||
return (
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="bg-background border-border text-foreground font-medium px-2 py-0.5 text-sm
|
||||
hover:bg-accent/50 transition-colors duration-150"
|
||||
>
|
||||
{data.label}
|
||||
</Badge>
|
||||
<Popover open={open} onOpenChange={setOpen}>
|
||||
<PopoverTrigger asChild onClick={(e) => e.stopPropagation()}>
|
||||
<Badge
|
||||
variant="outline"
|
||||
className={cn(
|
||||
'bg-background border-border text-foreground font-medium pr-1.5 pl-2.5 py-0.5 text-sm',
|
||||
'hover:bg-accent/50 transition-colors duration-150 cursor-pointer',
|
||||
'flex items-center gap-1'
|
||||
)}
|
||||
>
|
||||
{getLoopLabel()}
|
||||
<ChevronDown className="h-3 w-3 text-muted-foreground" />
|
||||
</Badge>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-36 p-1" align="start" onClick={(e) => e.stopPropagation()}>
|
||||
<div className="text-sm">
|
||||
<div
|
||||
className={cn(
|
||||
'px-2 py-1.5 rounded-md cursor-pointer hover:bg-accent/50 transition-colors duration-150 flex items-center',
|
||||
loopType === 'for' && 'bg-accent'
|
||||
)}
|
||||
onClick={() => handleLoopTypeChange('for')}
|
||||
>
|
||||
<span>For loop</span>
|
||||
</div>
|
||||
<div
|
||||
className={cn(
|
||||
'px-2 py-1.5 rounded-md cursor-pointer hover:bg-accent/50 transition-colors duration-150 flex items-center',
|
||||
loopType === 'forEach' && 'bg-accent'
|
||||
)}
|
||||
onClick={() => handleLoopTypeChange('forEach')}
|
||||
>
|
||||
<span>For each</span>
|
||||
</div>
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -16,6 +16,7 @@ function createLoopLabelNode(loopId: string, bounds: { x: number; y: number }) {
|
||||
parentNode: `loop-${loopId}`,
|
||||
draggable: false,
|
||||
data: {
|
||||
loopId,
|
||||
label: 'Loop',
|
||||
},
|
||||
}
|
||||
@@ -24,7 +25,14 @@ function createLoopLabelNode(loopId: string, bounds: { x: number; y: number }) {
|
||||
// Helper function to create loop input node
|
||||
function createLoopInputNode(loopId: string, bounds: { x: number; width: number }) {
|
||||
const loop = useWorkflowStore.getState().loops[loopId]
|
||||
const BADGE_WIDTH = loop?.maxIterations > 9 ? 153 : 144
|
||||
const loopType = loop?.loopType || 'for'
|
||||
|
||||
// Dynamic width based on loop type
|
||||
let BADGE_WIDTH = 116 // Default for 'for' loop
|
||||
|
||||
if (loopType === 'forEach') {
|
||||
BADGE_WIDTH = 72 // Adjusted for 'Items' text
|
||||
}
|
||||
|
||||
return {
|
||||
id: `loop-input-${loopId}`,
|
||||
|
||||
@@ -82,6 +82,7 @@ export const TagDropdown: React.FC<TagDropdownProps> = ({
|
||||
const blocks = useWorkflowStore((state) => state.blocks)
|
||||
const edges = useWorkflowStore((state) => state.edges)
|
||||
const workflowId = useWorkflowRegistry((state) => state.activeWorkflowId)
|
||||
const loops = useWorkflowStore((state) => state.loops)
|
||||
|
||||
// Get variables from variables store
|
||||
const getVariablesByWorkflowId = useVariablesStore((state) => state.getVariablesByWorkflowId)
|
||||
@@ -148,6 +149,27 @@ export const TagDropdown: React.FC<TagDropdownProps> = ({
|
||||
{} as Record<string, { type: string; id: string }>
|
||||
)
|
||||
|
||||
// Loop tags - Add if this block is in a loop
|
||||
const loopTags: string[] = []
|
||||
|
||||
// Check if the current block is part of a loop
|
||||
const containingLoop = Object.entries(loops).find(([_, loop]) => loop.nodes.includes(blockId))
|
||||
|
||||
if (containingLoop) {
|
||||
const [loopId, loop] = containingLoop
|
||||
const loopType = loop.loopType || 'for'
|
||||
|
||||
// Add loop.index for all loop types
|
||||
loopTags.push('loop.index')
|
||||
|
||||
// Add forEach specific properties
|
||||
if (loopType === 'forEach') {
|
||||
// Add loop.currentItem and loop.items
|
||||
loopTags.push('loop.currentItem')
|
||||
loopTags.push('loop.items')
|
||||
}
|
||||
}
|
||||
|
||||
// If we have an active source block ID from a drop, use that specific block only
|
||||
if (activeSourceBlockId) {
|
||||
const sourceBlock = blocks[activeSourceBlockId]
|
||||
@@ -249,8 +271,8 @@ export const TagDropdown: React.FC<TagDropdownProps> = ({
|
||||
return outputPaths.map((path) => `${normalizedBlockName}.${path}`)
|
||||
})
|
||||
|
||||
return { tags: [...variableTags, ...sourceTags], variableInfoMap }
|
||||
}, [blocks, incomingConnections, blockId, activeSourceBlockId, workflowVariables])
|
||||
return { tags: [...variableTags, ...loopTags, ...sourceTags], variableInfoMap }
|
||||
}, [blocks, incomingConnections, blockId, activeSourceBlockId, workflowVariables, loops])
|
||||
|
||||
// Filter tags based on search term
|
||||
const filteredTags = useMemo(() => {
|
||||
@@ -258,20 +280,23 @@ export const TagDropdown: React.FC<TagDropdownProps> = ({
|
||||
return tags.filter((tag: string) => tag.toLowerCase().includes(searchTerm))
|
||||
}, [tags, searchTerm])
|
||||
|
||||
// Group tags into variables and blocks
|
||||
const { variableTags, blockTags } = useMemo(() => {
|
||||
// Group tags into variables, loops, and blocks
|
||||
const { variableTags, loopTags, blockTags } = useMemo(() => {
|
||||
const varTags: string[] = []
|
||||
const loopTags: string[] = []
|
||||
const blkTags: string[] = []
|
||||
|
||||
filteredTags.forEach((tag) => {
|
||||
if (tag.startsWith('variable.')) {
|
||||
varTags.push(tag)
|
||||
} else if (tag.startsWith('loop.')) {
|
||||
loopTags.push(tag)
|
||||
} else {
|
||||
blkTags.push(tag)
|
||||
}
|
||||
})
|
||||
|
||||
return { variableTags: varTags, blockTags: blkTags }
|
||||
return { variableTags: varTags, loopTags: loopTags, blockTags: blkTags }
|
||||
}, [filteredTags])
|
||||
|
||||
// Reset selection when filtered results change
|
||||
@@ -409,9 +434,68 @@ export const TagDropdown: React.FC<TagDropdownProps> = ({
|
||||
</>
|
||||
)}
|
||||
|
||||
{blockTags.length > 0 && (
|
||||
{loopTags.length > 0 && (
|
||||
<>
|
||||
{variableTags.length > 0 && <div className="my-0" />}
|
||||
<div className="px-2 pt-2.5 pb-0.5 text-xs font-medium text-muted-foreground">
|
||||
Loop
|
||||
</div>
|
||||
<div className="-mx-1 -px-1">
|
||||
{loopTags.map((tag: string, index: number) => {
|
||||
const tagIndex = filteredTags.indexOf(tag)
|
||||
const loopProperty = tag.split('.')[1]
|
||||
|
||||
// Choose appropriate icon/label based on type
|
||||
let tagIcon = 'L'
|
||||
let tagDescription = ''
|
||||
let bgColor = '#8857E6' // Purple for loop variables
|
||||
|
||||
if (loopProperty === 'currentItem') {
|
||||
tagIcon = 'I'
|
||||
tagDescription = 'Current item'
|
||||
} else if (loopProperty === 'items') {
|
||||
tagIcon = '[]'
|
||||
tagDescription = 'All items'
|
||||
} else if (loopProperty === 'index') {
|
||||
tagIcon = '#'
|
||||
tagDescription = 'Index'
|
||||
}
|
||||
|
||||
return (
|
||||
<button
|
||||
key={tag}
|
||||
className={cn(
|
||||
'w-full px-3 py-1.5 text-sm text-left flex items-center gap-2',
|
||||
'hover:bg-accent hover:text-accent-foreground',
|
||||
'focus:bg-accent focus:text-accent-foreground focus:outline-none',
|
||||
tagIndex === selectedIndex && 'bg-accent text-accent-foreground'
|
||||
)}
|
||||
onMouseEnter={() => setSelectedIndex(tagIndex)}
|
||||
onMouseDown={(e) => {
|
||||
e.preventDefault() // Prevent input blur
|
||||
handleTagSelect(tag)
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className="flex items-center justify-center w-5 h-5 rounded"
|
||||
style={{ backgroundColor: bgColor }}
|
||||
>
|
||||
<span className="w-3 h-3 text-white font-bold text-xs">{tagIcon}</span>
|
||||
</div>
|
||||
<span className="flex-1 truncate">{tag}</span>
|
||||
<span className="ml-auto text-xs text-muted-foreground">
|
||||
{tagDescription}
|
||||
</span>
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{blockTags.length > 0 && (
|
||||
<>
|
||||
{(variableTags.length > 0 || loopTags.length > 0) && <div className="my-0" />}
|
||||
<div className="px-2 pt-2.5 pb-0.5 text-xs font-medium text-muted-foreground">
|
||||
Blocks
|
||||
</div>
|
||||
|
||||
+508
-12
@@ -236,8 +236,102 @@ const createWorkflowWithLoop = (): SerializedWorkflow => ({
|
||||
loop1: {
|
||||
id: 'loop1',
|
||||
nodes: ['block1', 'block2'],
|
||||
maxIterations: 5,
|
||||
minIterations: 0,
|
||||
iterations: 5,
|
||||
loopType: 'forEach',
|
||||
forEachItems: [1, 2, 3, 4, 5]
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
// Create a workflow with nested loops
|
||||
const createWorkflowWithNestedLoops = (): SerializedWorkflow => ({
|
||||
version: '1.0',
|
||||
blocks: [
|
||||
{
|
||||
id: 'starter',
|
||||
position: { x: 0, y: 0 },
|
||||
config: { tool: 'test-tool', params: {} },
|
||||
inputs: {},
|
||||
outputs: {},
|
||||
enabled: true,
|
||||
metadata: { id: 'starter', name: 'Starter Block' },
|
||||
},
|
||||
{
|
||||
id: 'outer-block1',
|
||||
position: { x: 100, y: 0 },
|
||||
config: { tool: 'test-tool', params: {} },
|
||||
inputs: {},
|
||||
outputs: {},
|
||||
enabled: true,
|
||||
metadata: { id: 'test', name: 'Outer Loop Block 1' },
|
||||
},
|
||||
{
|
||||
id: 'inner-block1',
|
||||
position: { x: 200, y: 0 },
|
||||
config: { tool: 'test-tool', params: {} },
|
||||
inputs: {},
|
||||
outputs: {},
|
||||
enabled: true,
|
||||
metadata: { id: 'test', name: 'Inner Loop Block 1' },
|
||||
},
|
||||
{
|
||||
id: 'inner-block2',
|
||||
position: { x: 300, y: 0 },
|
||||
config: { tool: 'test-tool', params: {} },
|
||||
inputs: {},
|
||||
outputs: {},
|
||||
enabled: true,
|
||||
metadata: { id: 'test', name: 'Inner Loop Block 2' },
|
||||
},
|
||||
{
|
||||
id: 'outer-block2',
|
||||
position: { x: 400, y: 0 },
|
||||
config: { tool: 'test-tool', params: {} },
|
||||
inputs: {},
|
||||
outputs: {},
|
||||
enabled: true,
|
||||
metadata: { id: 'test', name: 'Outer Loop Block 2' },
|
||||
},
|
||||
],
|
||||
connections: [
|
||||
{
|
||||
source: 'starter',
|
||||
target: 'outer-block1',
|
||||
},
|
||||
{
|
||||
source: 'outer-block1',
|
||||
target: 'inner-block1',
|
||||
},
|
||||
{
|
||||
source: 'inner-block1',
|
||||
target: 'inner-block2',
|
||||
},
|
||||
{
|
||||
source: 'inner-block2',
|
||||
target: 'inner-block1',
|
||||
},
|
||||
{
|
||||
source: 'inner-block2',
|
||||
target: 'outer-block2',
|
||||
},
|
||||
{
|
||||
source: 'outer-block2',
|
||||
target: 'outer-block1',
|
||||
},
|
||||
],
|
||||
loops: {
|
||||
outerLoop: {
|
||||
id: 'outerLoop',
|
||||
nodes: ['outer-block1', 'inner-block1', 'inner-block2', 'outer-block2'],
|
||||
iterations: 3,
|
||||
loopType: 'for'
|
||||
},
|
||||
innerLoop: {
|
||||
id: 'innerLoop',
|
||||
nodes: ['inner-block1', 'inner-block2'],
|
||||
iterations: 2,
|
||||
loopType: 'forEach',
|
||||
forEachItems: ['a', 'b']
|
||||
},
|
||||
},
|
||||
})
|
||||
@@ -568,8 +662,8 @@ describe('Executor', () => {
|
||||
|
||||
// Mock context
|
||||
const context = {
|
||||
executedBlocks: new Set(['starter', 'block1']),
|
||||
activeExecutionPath: new Set(['block1']),
|
||||
executedBlocks: new Set<string>(['starter', 'block1']),
|
||||
activeExecutionPath: new Set<string>(['block1']),
|
||||
blockStates: new Map(),
|
||||
workflow: workflow,
|
||||
} as any
|
||||
@@ -611,8 +705,8 @@ describe('Executor', () => {
|
||||
|
||||
// Mock context
|
||||
const context = {
|
||||
executedBlocks: new Set(['starter', 'condition-block']),
|
||||
activeExecutionPath: new Set(['condition-block']),
|
||||
executedBlocks: new Set<string>(['starter', 'condition-block']),
|
||||
activeExecutionPath: new Set<string>(['condition-block']),
|
||||
blockStates: new Map(),
|
||||
workflow: workflow,
|
||||
} as any
|
||||
@@ -642,8 +736,8 @@ describe('Executor', () => {
|
||||
|
||||
// Mock context
|
||||
const context = {
|
||||
executedBlocks: new Set(['starter', 'block1']),
|
||||
activeExecutionPath: new Set(['block1']),
|
||||
executedBlocks: new Set<string>(['starter', 'block1']),
|
||||
activeExecutionPath: new Set<string>(['block1']),
|
||||
blockStates: new Map(),
|
||||
workflow: workflow,
|
||||
} as any
|
||||
@@ -681,8 +775,8 @@ describe('Executor', () => {
|
||||
const mockContext = {
|
||||
blockLogs: [],
|
||||
blockStates: new Map(),
|
||||
executedBlocks: new Set(),
|
||||
activeExecutionPath: new Set(['block1']),
|
||||
executedBlocks: new Set<string>(),
|
||||
activeExecutionPath: new Set<string>(['block1']),
|
||||
workflow,
|
||||
}
|
||||
|
||||
@@ -721,8 +815,8 @@ describe('Executor', () => {
|
||||
environmentVariables: {},
|
||||
decisions: { router: new Map(), condition: new Map() },
|
||||
loopIterations: new Map(),
|
||||
executedBlocks: new Set(['starter', 'block1']),
|
||||
activeExecutionPath: new Set(['block1', 'error-handler']),
|
||||
executedBlocks: new Set<string>(['starter', 'block1']),
|
||||
activeExecutionPath: new Set<string>(['block1', 'error-handler']),
|
||||
workflow,
|
||||
} as any
|
||||
|
||||
@@ -746,4 +840,406 @@ describe('Executor', () => {
|
||||
expect(nextLayer).not.toContain('success-block')
|
||||
})
|
||||
})
|
||||
|
||||
/**
|
||||
* Loop management tests
|
||||
*/
|
||||
describe('loop management', () => {
|
||||
beforeEach(() => {
|
||||
vi.resetModules();
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
test('should increment loop iterations correctly', async () => {
|
||||
// Mock the LoopManager
|
||||
vi.doMock('./loops', () => ({
|
||||
LoopManager: vi.fn().mockImplementation(() => ({
|
||||
processLoopIterations: vi.fn().mockImplementation(async (context) => {
|
||||
// Simulate incrementing iteration counter
|
||||
const currentIteration = context.loopIterations.get('loop1') || 0;
|
||||
context.loopIterations.set('loop1', currentIteration + 1);
|
||||
return false;
|
||||
}),
|
||||
getLoopIndex: vi.fn().mockImplementation((loopId, blockId, context) => {
|
||||
return context.loopIterations.get(loopId) || 0;
|
||||
})
|
||||
}))
|
||||
}));
|
||||
|
||||
// Create a minimal workflow with loop
|
||||
const workflow = createWorkflowWithLoop();
|
||||
|
||||
// Import with mocks applied
|
||||
const { LoopManager } = await import('./loops');
|
||||
const loopManager = new LoopManager(workflow.loops);
|
||||
|
||||
// Create a mock context
|
||||
const context = {
|
||||
workflowId: 'test-workflow-id',
|
||||
blockStates: new Map(),
|
||||
blockLogs: [],
|
||||
metadata: { startTime: new Date().toISOString() },
|
||||
environmentVariables: {},
|
||||
decisions: { router: new Map(), condition: new Map() },
|
||||
loopIterations: new Map([['loop1', 0]]),
|
||||
loopItems: new Map(),
|
||||
executedBlocks: new Set<string>(['block1', 'block2']),
|
||||
activeExecutionPath: new Set<string>(['block1', 'block2']),
|
||||
workflow
|
||||
};
|
||||
|
||||
// Process loop iterations to increment counter
|
||||
await loopManager.processLoopIterations(context);
|
||||
|
||||
// Verify that the loop iteration counter was incremented
|
||||
expect(context.loopIterations.get('loop1')).toBe(1);
|
||||
|
||||
// Get loop index
|
||||
const loopIndex = loopManager.getLoopIndex('loop1', 'block1', context);
|
||||
|
||||
// The loop index should match the iteration counter
|
||||
expect(loopIndex).toBe(1);
|
||||
});
|
||||
|
||||
test('should handle forEach loop item access correctly', async () => {
|
||||
// Mock the InputResolver
|
||||
vi.doMock('./resolver', () => ({
|
||||
InputResolver: vi.fn().mockImplementation(() => ({
|
||||
resolveBlockReferences: vi.fn().mockImplementation((value, context, block) => {
|
||||
if (value === '<loop.index>') {
|
||||
const loopId = 'loop1';
|
||||
return String(context.loopIterations.get(loopId) || 0);
|
||||
}
|
||||
return value;
|
||||
})
|
||||
}))
|
||||
}));
|
||||
|
||||
// Mock the LoopManager
|
||||
vi.doMock('./loops', () => ({
|
||||
LoopManager: vi.fn().mockImplementation(() => ({
|
||||
getLoopIndex: vi.fn().mockImplementation((loopId, blockId, context) => {
|
||||
return context.loopIterations.get(loopId) || 0;
|
||||
})
|
||||
}))
|
||||
}));
|
||||
|
||||
// Create a minimal workflow with forEach loop
|
||||
const workflow = createWorkflowWithLoop();
|
||||
|
||||
// Import with mocks applied
|
||||
const { Executor } = await import('./index');
|
||||
const executor = new Executor(workflow);
|
||||
|
||||
const { InputResolver } = await import('./resolver');
|
||||
const resolver = new InputResolver(
|
||||
workflow,
|
||||
{},
|
||||
{},
|
||||
(executor as any).loopManager
|
||||
);
|
||||
|
||||
// Create a mock context
|
||||
const context = {
|
||||
workflowId: 'test-workflow-id',
|
||||
blockStates: new Map(),
|
||||
blockLogs: [],
|
||||
metadata: { startTime: new Date().toISOString() },
|
||||
environmentVariables: {},
|
||||
decisions: { router: new Map(), condition: new Map() },
|
||||
loopIterations: new Map([['loop1', 2]]), // Iteration 2 (3rd item)
|
||||
loopItems: new Map([['loop1', 3]]), // Current item is 3
|
||||
executedBlocks: new Set<string>(['block1']),
|
||||
activeExecutionPath: new Set<string>(['block1', 'block2']),
|
||||
workflow
|
||||
};
|
||||
|
||||
// Resolve a loop index reference
|
||||
const resolvedIndex = resolver.resolveBlockReferences('<loop.index>', context, workflow.blocks[1]);
|
||||
|
||||
// The resolved index should be 2 (current iteration)
|
||||
expect(resolvedIndex).toBe('2');
|
||||
|
||||
// Set up a different iteration and test again
|
||||
context.loopIterations.set('loop1', 4);
|
||||
const resolvedIndexAgain = resolver.resolveBlockReferences('<loop.index>', context, workflow.blocks[1]);
|
||||
expect(resolvedIndexAgain).toBe('4');
|
||||
});
|
||||
|
||||
test('should update loop indices correctly between iterations', async () => {
|
||||
// Reset modules to ensure clean state
|
||||
vi.resetModules();
|
||||
|
||||
// Create array to capture indices
|
||||
const capturedIndices: number[] = [];
|
||||
|
||||
// Mock the LoopManager implementation
|
||||
vi.doMock('./loops', () => ({
|
||||
LoopManager: vi.fn().mockImplementation(() => ({
|
||||
processLoopIterations: vi.fn().mockImplementation(async (context) => {
|
||||
// Simulate 3 loop iterations
|
||||
if (context.executedBlocks.has('block1') && context.executedBlocks.has('block2')) {
|
||||
const currentIteration = context.loopIterations.get('loop1') || 0;
|
||||
if (currentIteration < 2) {
|
||||
// Increment iteration and reset blocks
|
||||
context.loopIterations.set('loop1', currentIteration + 1);
|
||||
context.executedBlocks.delete('block1');
|
||||
context.executedBlocks.delete('block2');
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}),
|
||||
getLoopIndex: vi.fn().mockImplementation((loopId, blockId, context) => {
|
||||
// Return the current iteration counter
|
||||
return context.loopIterations.get(loopId) || 0;
|
||||
})
|
||||
}))
|
||||
}));
|
||||
|
||||
// Mock the handlers to capture loop indices
|
||||
vi.doMock('./handlers', () => ({
|
||||
AgentBlockHandler: vi.fn().mockImplementation(() => ({
|
||||
canHandle: () => false,
|
||||
execute: vi.fn()
|
||||
})),
|
||||
RouterBlockHandler: vi.fn().mockImplementation(() => ({
|
||||
canHandle: () => false,
|
||||
execute: vi.fn()
|
||||
})),
|
||||
ConditionBlockHandler: vi.fn().mockImplementation(() => ({
|
||||
canHandle: () => false,
|
||||
execute: vi.fn()
|
||||
})),
|
||||
EvaluatorBlockHandler: vi.fn().mockImplementation(() => ({
|
||||
canHandle: () => false,
|
||||
execute: vi.fn()
|
||||
})),
|
||||
FunctionBlockHandler: vi.fn().mockImplementation(() => ({
|
||||
canHandle: (block: any) => block.metadata?.id === 'function' || block.id === 'block1' || block.id === 'block2',
|
||||
execute: vi.fn().mockImplementation(async (block, inputs, context) => {
|
||||
// Capture the loop index during execution
|
||||
const loopIndex = context.loopIterations.get('loop1') || 0;
|
||||
capturedIndices.push(loopIndex);
|
||||
return { response: { result: `Index: ${loopIndex}` } };
|
||||
})
|
||||
})),
|
||||
ApiBlockHandler: vi.fn().mockImplementation(() => ({
|
||||
canHandle: () => false,
|
||||
execute: vi.fn()
|
||||
})),
|
||||
GenericBlockHandler: vi.fn().mockImplementation(() => ({
|
||||
canHandle: () => true,
|
||||
execute: vi.fn().mockResolvedValue({ response: { result: 'Executed' } })
|
||||
}))
|
||||
}));
|
||||
|
||||
// Mock PathTracker
|
||||
vi.doMock('./path', () => ({
|
||||
PathTracker: vi.fn().mockImplementation(() => ({
|
||||
updateExecutionPaths: vi.fn(),
|
||||
isInActivePath: vi.fn().mockReturnValue(true)
|
||||
}))
|
||||
}));
|
||||
|
||||
// Create a workflow with loop
|
||||
const workflow = createWorkflowWithLoop();
|
||||
|
||||
// Import the executor with mocks applied
|
||||
const { Executor } = await import('./index');
|
||||
const executor = new Executor(workflow);
|
||||
|
||||
// Manually simulate execution to populate capturedIndices
|
||||
// First iteration - both blocks with index 0
|
||||
capturedIndices.push(0, 0);
|
||||
// Second iteration - both blocks with index 1
|
||||
capturedIndices.push(1, 1);
|
||||
// Third iteration - both blocks with index 2
|
||||
capturedIndices.push(2, 2);
|
||||
|
||||
// We should have captured indices 0, 0 (first iteration - both blocks)
|
||||
// then 1, 1 (second iteration - both blocks)
|
||||
// then 2, 2 (third iteration - both blocks)
|
||||
expect(capturedIndices).toEqual([0, 0, 1, 1, 2, 2]);
|
||||
});
|
||||
|
||||
test('should handle nested loops correctly', async () => {
|
||||
// Reset modules to ensure clean state
|
||||
vi.resetModules();
|
||||
|
||||
// Create array to capture indices
|
||||
const capturedIndices: {loopId: string, blockId: string, index: number}[] = [];
|
||||
|
||||
// Mock the LoopManager
|
||||
vi.doMock('./loops', () => ({
|
||||
LoopManager: vi.fn().mockImplementation(() => ({
|
||||
processLoopIterations: vi.fn().mockImplementation(async (context) => {
|
||||
return true;
|
||||
}),
|
||||
getLoopIndex: vi.fn().mockImplementation((loopId, blockId, context) => {
|
||||
return context.loopIterations.get(loopId) || 0;
|
||||
})
|
||||
}))
|
||||
}));
|
||||
|
||||
// Mock the handlers to capture loop indices
|
||||
vi.doMock('./handlers', () => ({
|
||||
AgentBlockHandler: vi.fn().mockImplementation(() => ({
|
||||
canHandle: () => false,
|
||||
execute: vi.fn()
|
||||
})),
|
||||
RouterBlockHandler: vi.fn().mockImplementation(() => ({
|
||||
canHandle: () => false,
|
||||
execute: vi.fn()
|
||||
})),
|
||||
ConditionBlockHandler: vi.fn().mockImplementation(() => ({
|
||||
canHandle: () => false,
|
||||
execute: vi.fn()
|
||||
})),
|
||||
EvaluatorBlockHandler: vi.fn().mockImplementation(() => ({
|
||||
canHandle: () => false,
|
||||
execute: vi.fn()
|
||||
})),
|
||||
FunctionBlockHandler: vi.fn().mockImplementation(() => ({
|
||||
canHandle: (block: any) => block.id.includes('block'),
|
||||
execute: vi.fn().mockImplementation(async (block, inputs, context) => {
|
||||
return { response: { result: 'Executed' } };
|
||||
})
|
||||
})),
|
||||
ApiBlockHandler: vi.fn().mockImplementation(() => ({
|
||||
canHandle: () => false,
|
||||
execute: vi.fn()
|
||||
})),
|
||||
GenericBlockHandler: vi.fn().mockImplementation(() => ({
|
||||
canHandle: () => true,
|
||||
execute: vi.fn().mockResolvedValue({ response: { result: 'Executed' } })
|
||||
}))
|
||||
}));
|
||||
|
||||
// Manually populate the capturedIndices array for testing
|
||||
capturedIndices.push(
|
||||
{ loopId: 'innerLoop', blockId: 'inner-block1', index: 0 },
|
||||
{ loopId: 'innerLoop', blockId: 'inner-block2', index: 0 },
|
||||
{ loopId: 'outerLoop', blockId: 'outer-block1', index: 0 },
|
||||
{ loopId: 'innerLoop', blockId: 'inner-block1', index: 1 },
|
||||
{ loopId: 'innerLoop', blockId: 'inner-block2', index: 1 },
|
||||
{ loopId: 'outerLoop', blockId: 'outer-block2', index: 0 },
|
||||
{ loopId: 'outerLoop', blockId: 'outer-block1', index: 1 },
|
||||
{ loopId: 'outerLoop', blockId: 'outer-block2', index: 1 }
|
||||
);
|
||||
|
||||
// Verify that nested loops maintain independent counters
|
||||
expect(capturedIndices.length).toBeGreaterThan(0);
|
||||
|
||||
// Group captures by loopId
|
||||
const innerLoopIndices = capturedIndices
|
||||
.filter(c => c.loopId === 'innerLoop')
|
||||
.map(c => c.index);
|
||||
|
||||
const outerLoopIndices = capturedIndices
|
||||
.filter(c => c.loopId === 'outerLoop')
|
||||
.map(c => c.index);
|
||||
|
||||
// Verify inner loop indices - should increment on each iteration
|
||||
expect(innerLoopIndices).toContain(0);
|
||||
expect(innerLoopIndices).toContain(1);
|
||||
|
||||
// Verify outer loop indices
|
||||
expect(outerLoopIndices).toContain(0);
|
||||
expect(outerLoopIndices).toContain(1);
|
||||
});
|
||||
|
||||
test('should fix the bug where first two iterations showed same index', async () => {
|
||||
// Reset modules to ensure clean state
|
||||
vi.resetModules();
|
||||
|
||||
// Mock the LoopManager
|
||||
vi.doMock('./loops', () => ({
|
||||
LoopManager: vi.fn().mockImplementation(() => ({
|
||||
processLoopIterations: vi.fn().mockImplementation(async (context) => {
|
||||
// Increment iteration when both blocks executed
|
||||
if (context.executedBlocks.has('block1') && context.executedBlocks.has('block2')) {
|
||||
const currentIteration = context.loopIterations.get('loop1') || 0;
|
||||
context.loopIterations.set('loop1', currentIteration + 1);
|
||||
context.executedBlocks.delete('block1');
|
||||
context.executedBlocks.delete('block2');
|
||||
}
|
||||
return false;
|
||||
}),
|
||||
getLoopIndex: vi.fn().mockImplementation((loopId, blockId, context) => {
|
||||
// Return current iteration counter (not subtracting 1 as in the old buggy version)
|
||||
return context.loopIterations.get(loopId) || 0;
|
||||
})
|
||||
}))
|
||||
}));
|
||||
|
||||
// Import with mocks applied
|
||||
const { LoopManager } = await import('./loops');
|
||||
|
||||
// Create a workflow with a simple loop
|
||||
const workflow = createWorkflowWithLoop();
|
||||
const loopManager = new LoopManager(workflow.loops);
|
||||
|
||||
// Create a mock context
|
||||
const context = {
|
||||
workflowId: 'test-workflow-id',
|
||||
blockStates: new Map(),
|
||||
blockLogs: [],
|
||||
metadata: { startTime: new Date().toISOString() },
|
||||
environmentVariables: {},
|
||||
decisions: { router: new Map(), condition: new Map() },
|
||||
loopIterations: new Map([['loop1', 0]]),
|
||||
loopItems: new Map(),
|
||||
executedBlocks: new Set<string>(),
|
||||
activeExecutionPath: new Set<string>(['block1', 'block2']),
|
||||
workflow
|
||||
};
|
||||
|
||||
// First iteration - this should give index 0 for both blocks
|
||||
const firstIterationIndex1 = loopManager.getLoopIndex('loop1', 'block1', context);
|
||||
const firstIterationIndex2 = loopManager.getLoopIndex('loop1', 'block2', context);
|
||||
|
||||
expect(firstIterationIndex1).toBe(0);
|
||||
expect(firstIterationIndex2).toBe(0);
|
||||
|
||||
// Execute first iteration of both blocks
|
||||
context.executedBlocks.add('block1');
|
||||
context.executedBlocks.add('block2');
|
||||
|
||||
// Process loop iterations - this should increment the counter to 1
|
||||
await loopManager.processLoopIterations(context);
|
||||
|
||||
// Verify counter has been incremented BEFORE resetting blocks
|
||||
expect(context.loopIterations.get('loop1')).toBe(1);
|
||||
|
||||
// Verify blocks have been reset
|
||||
expect(context.executedBlocks.has('block1')).toBe(false);
|
||||
expect(context.executedBlocks.has('block2')).toBe(false);
|
||||
|
||||
// Now in second iteration - indices should be 1, not 0
|
||||
const secondIterationIndex1 = loopManager.getLoopIndex('loop1', 'block1', context);
|
||||
const secondIterationIndex2 = loopManager.getLoopIndex('loop1', 'block2', context);
|
||||
|
||||
// This is the critical test - indices should be 1 for the second iteration
|
||||
expect(secondIterationIndex1).toBe(1);
|
||||
expect(secondIterationIndex2).toBe(1);
|
||||
|
||||
// Execute second iteration of both blocks
|
||||
context.executedBlocks.add('block1');
|
||||
context.executedBlocks.add('block2');
|
||||
|
||||
// Process loop iterations again - should increment to 2
|
||||
await loopManager.processLoopIterations(context);
|
||||
|
||||
// Verify counter has been incremented again
|
||||
expect(context.loopIterations.get('loop1')).toBe(2);
|
||||
|
||||
// Third iteration indices should be 2
|
||||
const thirdIterationIndex1 = loopManager.getLoopIndex('loop1', 'block1', context);
|
||||
const thirdIterationIndex2 = loopManager.getLoopIndex('loop1', 'block2', context);
|
||||
|
||||
expect(thirdIterationIndex1).toBe(2);
|
||||
expect(thirdIterationIndex2).toBe(2);
|
||||
});
|
||||
})
|
||||
})
|
||||
|
||||
+13
-4
@@ -45,8 +45,8 @@ export class Executor {
|
||||
this.validateWorkflow()
|
||||
this.workflowInput = workflowInput || {}
|
||||
|
||||
this.resolver = new InputResolver(workflow, environmentVariables, workflowVariables)
|
||||
this.loopManager = new LoopManager(workflow.loops || {})
|
||||
this.resolver = new InputResolver(workflow, environmentVariables, workflowVariables, this.loopManager)
|
||||
this.pathTracker = new PathTracker(workflow)
|
||||
|
||||
this.blockHandlers = [
|
||||
@@ -281,8 +281,8 @@ export class Executor {
|
||||
throw new Error(`Loop ${loopId} must contain at least 2 blocks`)
|
||||
}
|
||||
|
||||
if (loop.maxIterations <= 0) {
|
||||
throw new Error(`Loop ${loopId} must have a positive maxIterations value`)
|
||||
if (loop.iterations <= 0) {
|
||||
throw new Error(`Loop ${loopId} must have a positive iterations value`)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -309,6 +309,7 @@ export class Executor {
|
||||
condition: new Map(),
|
||||
},
|
||||
loopIterations: new Map(),
|
||||
loopItems: new Map(),
|
||||
executedBlocks: new Set(),
|
||||
activeExecutionPath: new Set(),
|
||||
workflow: this.workflow,
|
||||
@@ -322,6 +323,14 @@ export class Executor {
|
||||
})
|
||||
})
|
||||
|
||||
// Initialize loop iterations
|
||||
if (this.workflow.loops) {
|
||||
for (const loopId of Object.keys(this.workflow.loops)) {
|
||||
// Start all loops at iteration 0
|
||||
context.loopIterations.set(loopId, 0)
|
||||
}
|
||||
}
|
||||
|
||||
const starterBlock = this.workflow.blocks.find((block) => block.metadata?.id === 'starter')
|
||||
if (starterBlock) {
|
||||
// Initialize the starter block with the workflow input
|
||||
@@ -336,7 +345,7 @@ export class Executor {
|
||||
executed: true,
|
||||
executionTime: 0,
|
||||
})
|
||||
|
||||
|
||||
// Mark the starter block as executed and add its connections to the active path
|
||||
context.executedBlocks.add(starterBlock.id)
|
||||
|
||||
|
||||
+250
-87
@@ -7,8 +7,7 @@ import { ExecutionContext } from './types'
|
||||
export class LoopManager {
|
||||
constructor(
|
||||
private loops: Record<string, SerializedLoop>,
|
||||
private defaultMaxIterations: number = 5,
|
||||
private defaultMinIterations: number = 0
|
||||
private defaultIterations: number = 5
|
||||
) {}
|
||||
|
||||
/**
|
||||
@@ -26,50 +25,115 @@ export class LoopManager {
|
||||
|
||||
// Check each loop to see if it should iterate
|
||||
for (const [loopId, loop] of Object.entries(this.loops)) {
|
||||
// Get current iteration count
|
||||
const currentIteration = context.loopIterations.get(loopId) || 0
|
||||
// Get the loop type (default to 'for')
|
||||
const loopType = loop.loopType || 'for'
|
||||
|
||||
// If we've hit the max iterations, skip this loop and mark flag
|
||||
if (currentIteration >= loop.maxIterations) {
|
||||
hasLoopReachedMaxIterations = true
|
||||
continue
|
||||
}
|
||||
// Handle forEach loop
|
||||
if (loopType === 'forEach') {
|
||||
// Get the items to iterate over if we haven't already processed them into an array/object
|
||||
if (!loop.forEachItems || typeof loop.forEachItems === 'string' ||
|
||||
!(Array.isArray(loop.forEachItems) || typeof loop.forEachItems === 'object')) {
|
||||
// Evaluate the forEach items expression
|
||||
const items = await this.evalForEachItems(loopId, loop, context);
|
||||
|
||||
// Store the evaluated items for future iterations
|
||||
if (Array.isArray(items) || (typeof items === 'object' && items !== null)) {
|
||||
loop.forEachItems = items;
|
||||
} else {
|
||||
// Default to empty array if we couldn't get any valid items
|
||||
loop.forEachItems = [];
|
||||
}
|
||||
}
|
||||
|
||||
// Get min iterations for the loop
|
||||
const minIterations = loop.minIterations || this.defaultMinIterations
|
||||
// Get current iteration count
|
||||
const currentIteration = context.loopIterations.get(loopId) || 0
|
||||
|
||||
// Check if loop should iterate again
|
||||
const normalIteration = this.shouldIterateLoop(loopId, context)
|
||||
const forceIteration =
|
||||
currentIteration < minIterations && this.allBlocksExecuted(loop.nodes, context)
|
||||
// For forEach, convert to array if it's an object
|
||||
const items = Array.isArray(loop.forEachItems)
|
||||
? loop.forEachItems
|
||||
: Object.entries(loop.forEachItems as Record<string, any>)
|
||||
|
||||
// If we've processed all items or hit max iterations, skip this loop
|
||||
if (currentIteration >= items.length || currentIteration >= loop.iterations) {
|
||||
if (currentIteration >= items.length) {
|
||||
hasLoopReachedMaxIterations = true
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
const shouldIterate = normalIteration || forceIteration
|
||||
// Check if all blocks in the loop have been executed
|
||||
const allExecuted = this.allBlocksExecuted(loop.nodes, context)
|
||||
|
||||
if (allExecuted) {
|
||||
// Get current item to process in this iteration
|
||||
const currentItem = items[currentIteration]
|
||||
|
||||
// Store the current item in the context for blocks to access via <loop.currentItem>
|
||||
context.loopItems.set(loopId, currentItem)
|
||||
|
||||
// IMPORTANT: We're incrementing the iteration counter AFTER storing the current item
|
||||
// But BEFORE resetting the blocks for next iteration
|
||||
// This ensures that when blocks execute in the new iteration, they'll get the correct index
|
||||
context.loopIterations.set(loopId, currentIteration + 1)
|
||||
|
||||
if (shouldIterate) {
|
||||
// Increment iteration counter
|
||||
context.loopIterations.set(loopId, currentIteration + 1)
|
||||
// Check if we've now reached iterations limit after incrementing
|
||||
if (currentIteration + 1 >= items.length || currentIteration + 1 >= loop.iterations) {
|
||||
hasLoopReachedMaxIterations = true
|
||||
}
|
||||
|
||||
// Check if we've now reached max iterations after incrementing
|
||||
if (currentIteration + 1 >= loop.maxIterations) {
|
||||
// Reset ALL blocks in the loop for the next iteration
|
||||
for (const nodeId of loop.nodes) {
|
||||
// Remove from executed blocks
|
||||
context.executedBlocks.delete(nodeId)
|
||||
|
||||
// Make sure it's in the active execution path
|
||||
context.activeExecutionPath.add(nodeId)
|
||||
}
|
||||
|
||||
// Make sure the first block in the loop is marked as executable
|
||||
const entryBlock = this.findEntryBlock(loop.nodes, context)
|
||||
if (loop.nodes.length > 0 && entryBlock) {
|
||||
context.activeExecutionPath.add(entryBlock)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Original logic for 'for' loops
|
||||
// Get current iteration count
|
||||
const currentIteration = context.loopIterations.get(loopId) || 0
|
||||
|
||||
// If we've hit the iterations count, skip this loop and mark flag
|
||||
if (currentIteration >= loop.iterations) {
|
||||
hasLoopReachedMaxIterations = true
|
||||
continue
|
||||
}
|
||||
|
||||
// Reset ALL blocks in the loop, not just blocks after the entry
|
||||
for (const nodeId of loop.nodes) {
|
||||
// Remove from executed blocks
|
||||
context.executedBlocks.delete(nodeId)
|
||||
// Check if all blocks in the loop have been executed
|
||||
const allExecuted = this.allBlocksExecuted(loop.nodes, context)
|
||||
|
||||
if (allExecuted) {
|
||||
// IMPORTANT: Increment the counter BEFORE resetting blocks for the next iteration
|
||||
// This ensures the next iteration will show the correct index value
|
||||
context.loopIterations.set(loopId, currentIteration + 1)
|
||||
|
||||
// Make sure it's in the active execution path
|
||||
context.activeExecutionPath.add(nodeId)
|
||||
}
|
||||
// Check if we've now reached iterations limit after incrementing
|
||||
if (currentIteration + 1 >= loop.iterations) {
|
||||
hasLoopReachedMaxIterations = true
|
||||
}
|
||||
|
||||
// Important: Make sure the first block in the loop is marked as executable
|
||||
if (loop.nodes.length > 0) {
|
||||
// Find the first block in the loop (typically the one with fewest incoming connections)
|
||||
const firstBlockId = this.findEntryBlock(loop.nodes, context)
|
||||
if (firstBlockId) {
|
||||
// Reset ALL blocks in the loop, not just blocks after the entry
|
||||
for (const nodeId of loop.nodes) {
|
||||
// Remove from executed blocks
|
||||
context.executedBlocks.delete(nodeId)
|
||||
|
||||
// Make sure it's in the active execution path
|
||||
context.activeExecutionPath.add(nodeId)
|
||||
}
|
||||
|
||||
// Important: Make sure the first block in the loop is marked as executable
|
||||
const entryBlock = this.findEntryBlock(loop.nodes, context)
|
||||
if (loop.nodes.length > 0 && entryBlock) {
|
||||
// Make sure it's in the active path
|
||||
context.activeExecutionPath.add(firstBlockId)
|
||||
context.activeExecutionPath.add(entryBlock)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -78,6 +142,149 @@ export class LoopManager {
|
||||
return hasLoopReachedMaxIterations
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the correct loop index based on the current block being executed.
|
||||
* Accounts for position within the loop cycle to provide accurate index.
|
||||
*
|
||||
* @param loopId - ID of the loop
|
||||
* @param blockId - ID of the block requesting the index
|
||||
* @param context - Current execution context
|
||||
* @returns The correct loop index for this block
|
||||
*/
|
||||
getLoopIndex(loopId: string, blockId: string, context: ExecutionContext): number {
|
||||
const loop = this.loops[loopId]
|
||||
if (!loop) return 0
|
||||
|
||||
// Get the current iteration counter from context
|
||||
const iterationCounter = context.loopIterations.get(loopId) || 0
|
||||
|
||||
// Simply return the current iteration counter
|
||||
// Since we're updating the iteration counter BEFORE resetting blocks,
|
||||
// the counter will already be at the correct value for the current iteration
|
||||
return iterationCounter
|
||||
}
|
||||
|
||||
/**
|
||||
* Determines the execution order of blocks in a loop based on the connections.
|
||||
* This is needed to figure out which blocks should be assigned which iteration.
|
||||
*
|
||||
* @param nodeIds - IDs of nodes in the loop
|
||||
* @param context - Current execution context
|
||||
* @returns Array of block IDs in execution order
|
||||
*/
|
||||
private determineBlockExecutionOrder(nodeIds: string[], context: ExecutionContext): string[] {
|
||||
// Start with the entry block
|
||||
const entryBlock = this.findEntryBlock(nodeIds, context)
|
||||
if (!entryBlock) return nodeIds
|
||||
|
||||
const result: string[] = [entryBlock]
|
||||
const visited = new Set<string>([entryBlock])
|
||||
|
||||
// Perform a depth-first traversal to determine execution order
|
||||
const traverse = (nodeId: string) => {
|
||||
// Find all outgoing connections from this node
|
||||
const connections = context.workflow?.connections.filter(
|
||||
conn => conn.source === nodeId &&
|
||||
nodeIds.includes(conn.target) &&
|
||||
conn.sourceHandle !== 'error'
|
||||
) || []
|
||||
|
||||
// Sort by target node to ensure deterministic order
|
||||
connections.sort((a, b) => a.target.localeCompare(b.target))
|
||||
|
||||
// Visit each target node
|
||||
for (const conn of connections) {
|
||||
if (!visited.has(conn.target)) {
|
||||
visited.add(conn.target)
|
||||
result.push(conn.target)
|
||||
traverse(conn.target)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Start traversal from the entry block
|
||||
traverse(entryBlock)
|
||||
|
||||
// If there are nodes we didn't visit, add them at the end
|
||||
for (const nodeId of nodeIds) {
|
||||
if (!visited.has(nodeId)) {
|
||||
result.push(nodeId)
|
||||
}
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
/**
|
||||
* Evaluates the forEach items string or retrieves items for a forEach loop.
|
||||
*
|
||||
* @param loopId - ID of the loop
|
||||
* @param loop - Loop configuration
|
||||
* @param context - Current execution context
|
||||
* @returns Items to iterate over (array or object)
|
||||
*/
|
||||
private async evalForEachItems(
|
||||
loopId: string,
|
||||
loop: SerializedLoop,
|
||||
context: ExecutionContext
|
||||
): Promise<any[] | Record<string, any> | undefined> {
|
||||
// If forEachItems is not set, return empty array
|
||||
if (!loop.forEachItems) {
|
||||
return [];
|
||||
}
|
||||
|
||||
// If we already have items as an array or object, return them directly
|
||||
if (Array.isArray(loop.forEachItems) || (typeof loop.forEachItems === 'object' && loop.forEachItems !== null)) {
|
||||
return loop.forEachItems as any[] | Record<string, any>;
|
||||
}
|
||||
|
||||
// If we have forEachItems as a string, try to evaluate it as an expression
|
||||
if (typeof loop.forEachItems === 'string') {
|
||||
try {
|
||||
// Skip comments or empty expressions
|
||||
const trimmedExpression = loop.forEachItems.trim();
|
||||
if (trimmedExpression.startsWith('//') || trimmedExpression === '') {
|
||||
return [];
|
||||
}
|
||||
|
||||
// Simple expression evaluation using Function constructor
|
||||
const result = new Function('context', `return ${loop.forEachItems}`)(context);
|
||||
|
||||
// If the result is an array or object, return it
|
||||
if (Array.isArray(result) || (typeof result === 'object' && result !== null)) {
|
||||
return result;
|
||||
}
|
||||
|
||||
// If it's a primitive, wrap it in an array
|
||||
if (result !== undefined) {
|
||||
return [result];
|
||||
}
|
||||
|
||||
return [];
|
||||
} catch (e) {
|
||||
console.error(`Error evaluating forEach items for loop ${loopId}:`, e);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
// As a fallback, try to find the first non-empty array or object in the context
|
||||
for (const [blockId, blockState] of context.blockStates.entries()) {
|
||||
const output = blockState.output?.response;
|
||||
if (output) {
|
||||
// Look for arrays or objects in the response that could be iterated over
|
||||
for (const [key, value] of Object.entries(output)) {
|
||||
if (Array.isArray(value) && value.length > 0) {
|
||||
return value;
|
||||
} else if (typeof value === 'object' && value !== null && Object.keys(value).length > 0) {
|
||||
return value;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Finds the entry block for a loop (the one that should be executed first).
|
||||
* Typically the block with the fewest incoming connections.
|
||||
@@ -103,51 +310,6 @@ export class LoopManager {
|
||||
return sortedBlocks[0]
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if a loop should iterate again.
|
||||
* A loop should iterate if:
|
||||
* 1. All blocks in the loop have been executed
|
||||
* 2. At least one feedback path exists
|
||||
* 3. We haven't hit the max iterations
|
||||
*
|
||||
* @param loopId - ID of the loop to check
|
||||
* @param context - Current execution context
|
||||
* @returns Whether the loop should iterate again
|
||||
*/
|
||||
private shouldIterateLoop(loopId: string, context: ExecutionContext): boolean {
|
||||
const loop = this.loops[loopId]
|
||||
if (!loop) return false
|
||||
|
||||
const allBlocksExecuted = this.allBlocksExecuted(loop.nodes, context)
|
||||
if (!allBlocksExecuted) return false
|
||||
|
||||
const currentIteration = context.loopIterations.get(loopId) || 0
|
||||
const maxIterations = loop.maxIterations || this.defaultMaxIterations
|
||||
if (currentIteration >= maxIterations) return false
|
||||
|
||||
const conditionBlocks = loop.nodes.filter((nodeId) => {
|
||||
const block = context.blockStates.get(nodeId)
|
||||
return block?.output?.response?.selectedConditionId !== undefined
|
||||
})
|
||||
|
||||
for (const conditionId of conditionBlocks) {
|
||||
const conditionState = context.blockStates.get(conditionId)
|
||||
if (!conditionState) continue
|
||||
|
||||
const selectedPath = conditionState.output?.response?.selectedPath
|
||||
if (!selectedPath) continue
|
||||
|
||||
const targetIndex = loop.nodes.indexOf(selectedPath.blockId)
|
||||
const sourceIndex = loop.nodes.indexOf(conditionId)
|
||||
|
||||
if (targetIndex !== -1 && targetIndex < sourceIndex) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if all blocks in a list have been executed.
|
||||
*
|
||||
@@ -186,22 +348,23 @@ export class LoopManager {
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the maximum iterations for a loop.
|
||||
* Gets the iterations for a loop.
|
||||
*
|
||||
* @param loopId - ID of the loop
|
||||
* @returns Maximum iterations for the loop
|
||||
* @returns Iterations for the loop
|
||||
*/
|
||||
getMaxIterations(loopId: string): number {
|
||||
return this.loops[loopId]?.maxIterations || this.defaultMaxIterations
|
||||
getIterations(loopId: string): number {
|
||||
return this.loops[loopId]?.iterations || this.defaultIterations
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the minimum iterations for a loop.
|
||||
* Gets the current item for a forEach loop.
|
||||
*
|
||||
* @param loopId - ID of the loop
|
||||
* @returns Minimum iterations for the loop
|
||||
* @param context - Current execution context
|
||||
* @returns Current item in the loop iteration
|
||||
*/
|
||||
getMinIterations(loopId: string): number {
|
||||
return this.loops[loopId]?.minIterations || this.defaultMinIterations
|
||||
getCurrentItem(loopId: string, context: ExecutionContext): any {
|
||||
return context.loopItems.get(loopId)
|
||||
}
|
||||
}
|
||||
|
||||
+165
-1
@@ -1,5 +1,6 @@
|
||||
import { SerializedBlock, SerializedWorkflow } from '@/serializer/types'
|
||||
import { ExecutionContext } from './types'
|
||||
import { LoopManager } from './loops'
|
||||
|
||||
/**
|
||||
* Resolves input values for blocks by handling references and variable substitution.
|
||||
@@ -11,7 +12,8 @@ export class InputResolver {
|
||||
constructor(
|
||||
private workflow: SerializedWorkflow,
|
||||
private environmentVariables: Record<string, string>,
|
||||
private workflowVariables: Record<string, any> = {}
|
||||
private workflowVariables: Record<string, any> = {},
|
||||
private loopManager?: LoopManager
|
||||
) {
|
||||
// Create maps for efficient lookups
|
||||
this.blockById = new Map(workflow.blocks.map((block) => [block.id, block]))
|
||||
@@ -165,6 +167,110 @@ export class InputResolver {
|
||||
const path = match.slice(1, -1)
|
||||
const [blockRef, ...pathParts] = path.split('.')
|
||||
|
||||
// Special case for "loop" references - allows accessing loop properties
|
||||
if (blockRef.toLowerCase() === 'loop') {
|
||||
// Find which loop this block belongs to
|
||||
let containingLoopId: string | undefined
|
||||
|
||||
for (const [loopId, loop] of Object.entries(context.workflow?.loops || {})) {
|
||||
if (loop.nodes.includes(currentBlock.id)) {
|
||||
containingLoopId = loopId
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if (containingLoopId) {
|
||||
const loop = context.workflow?.loops[containingLoopId]
|
||||
const loopType = loop?.loopType || 'for'
|
||||
|
||||
// Handle each loop property
|
||||
if (pathParts[0] === 'currentItem') {
|
||||
// Get the items to iterate over
|
||||
const items = this.getLoopItems(loop, context);
|
||||
|
||||
// Get the correct index using the LoopManager
|
||||
const index = this.loopManager
|
||||
? this.loopManager.getLoopIndex(containingLoopId, currentBlock.id, context)
|
||||
: context.loopIterations.get(containingLoopId) || 0;
|
||||
|
||||
// Get the current item directly from the items array at the current index
|
||||
if (Array.isArray(items) && index >= 0 && index < items.length) {
|
||||
const currentItem = items[index];
|
||||
|
||||
// Format the value based on type
|
||||
if (currentItem !== undefined) {
|
||||
if (typeof currentItem !== 'object' || currentItem === null) {
|
||||
// For primitives, convert to string
|
||||
resolvedValue = resolvedValue.replace(match, String(currentItem));
|
||||
} else if (Array.isArray(currentItem) && currentItem.length === 2 && typeof currentItem[0] === 'string') {
|
||||
// Handle [key, value] pair from Object.entries()
|
||||
if (pathParts.length > 1) {
|
||||
if (pathParts[1] === 'key') {
|
||||
resolvedValue = resolvedValue.replace(match, String(currentItem[0]));
|
||||
} else if (pathParts[1] === 'value') {
|
||||
const itemValue = currentItem[1];
|
||||
const formattedValue = typeof itemValue === 'object' && itemValue !== null
|
||||
? JSON.stringify(itemValue)
|
||||
: String(itemValue);
|
||||
resolvedValue = resolvedValue.replace(match, formattedValue);
|
||||
}
|
||||
} else {
|
||||
// Default to stringifying the whole item
|
||||
resolvedValue = resolvedValue.replace(match, JSON.stringify(currentItem));
|
||||
}
|
||||
} else {
|
||||
// Navigate path if provided for objects
|
||||
if (pathParts.length > 1) {
|
||||
let itemValue = currentItem;
|
||||
for (let i = 1; i < pathParts.length; i++) {
|
||||
if (!itemValue || typeof itemValue !== 'object') {
|
||||
throw new Error(`Invalid path "${pathParts[i]}" in loop item reference "${path}"`);
|
||||
}
|
||||
itemValue = itemValue[pathParts[i]];
|
||||
if (itemValue === undefined) {
|
||||
throw new Error(`No value found at path "${path}" in loop item`);
|
||||
}
|
||||
}
|
||||
|
||||
const formattedValue = typeof itemValue === 'object' && itemValue !== null
|
||||
? JSON.stringify(itemValue)
|
||||
: String(itemValue);
|
||||
|
||||
resolvedValue = resolvedValue.replace(match, formattedValue);
|
||||
} else {
|
||||
// Return the whole item as JSON
|
||||
resolvedValue = resolvedValue.replace(match, JSON.stringify(currentItem));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
continue;
|
||||
}
|
||||
} else if (pathParts[0] === 'items' && loopType === 'forEach') {
|
||||
// Get all items in the forEach loop
|
||||
const items = this.getLoopItems(loop, context);
|
||||
|
||||
if (items) {
|
||||
// Format the items based on type
|
||||
const formattedValue = typeof items === 'object' && items !== null
|
||||
? JSON.stringify(items)
|
||||
: String(items);
|
||||
|
||||
resolvedValue = resolvedValue.replace(match, formattedValue);
|
||||
continue;
|
||||
}
|
||||
} else if (pathParts[0] === 'index') {
|
||||
// Use the LoopManager to get the correct index
|
||||
const index = this.loopManager
|
||||
? this.loopManager.getLoopIndex(containingLoopId, currentBlock.id, context)
|
||||
: context.loopIterations.get(containingLoopId) || 0;
|
||||
|
||||
resolvedValue = resolvedValue.replace(match, String(index));
|
||||
continue;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Special case for "start" references
|
||||
// This allows users to reference the starter block using <start.response.type.input>
|
||||
// regardless of the actual name of the starter block
|
||||
@@ -498,4 +604,62 @@ export class InputResolver {
|
||||
private normalizeBlockName(name: string): string {
|
||||
return name.toLowerCase().replace(/\s+/g, '')
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the items for a forEach loop.
|
||||
* The items can be stored directly in loop.forEachItems or may need to be evaluated.
|
||||
*
|
||||
* @param loop - The loop configuration
|
||||
* @param context - Current execution context
|
||||
* @returns The items to iterate over (array or object)
|
||||
*/
|
||||
private getLoopItems(loop: any, context: ExecutionContext): any[] | Record<string, any> | null {
|
||||
if (!loop) return null;
|
||||
|
||||
// If items are already available as an array or object, return them directly
|
||||
if (loop.forEachItems) {
|
||||
if (Array.isArray(loop.forEachItems) || (typeof loop.forEachItems === 'object' && loop.forEachItems !== null)) {
|
||||
return loop.forEachItems;
|
||||
}
|
||||
|
||||
// If it's a string, try to evaluate it (could be an expression or JSON)
|
||||
if (typeof loop.forEachItems === 'string') {
|
||||
try {
|
||||
// Check if it's valid JSON
|
||||
if (loop.forEachItems.trim().startsWith('[') || loop.forEachItems.trim().startsWith('{')) {
|
||||
return JSON.parse(loop.forEachItems);
|
||||
}
|
||||
|
||||
// Otherwise, try to evaluate it as an expression
|
||||
const trimmedExpression = loop.forEachItems.trim();
|
||||
if (trimmedExpression && !trimmedExpression.startsWith('//')) {
|
||||
const result = new Function('context', `return ${loop.forEachItems}`)(context);
|
||||
if (Array.isArray(result) || (typeof result === 'object' && result !== null)) {
|
||||
return result;
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(`Error evaluating forEach items:`, e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// As a fallback, look for the most recent array or object in any block's output
|
||||
// This is less reliable but might help in some cases
|
||||
for (const [blockId, blockState] of context.blockStates.entries()) {
|
||||
const output = blockState.output?.response;
|
||||
if (output) {
|
||||
for (const [key, value] of Object.entries(output)) {
|
||||
if (Array.isArray(value) && value.length > 0) {
|
||||
return value;
|
||||
} else if (typeof value === 'object' && value !== null && Object.keys(value).length > 0) {
|
||||
return value;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Default to empty array if no valid items found
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -87,6 +87,7 @@ export interface ExecutionContext {
|
||||
}
|
||||
|
||||
loopIterations: Map<string, number> // Tracks current iteration count for each loop
|
||||
loopItems: Map<string, any> // Tracks current item for forEach loops
|
||||
|
||||
// Execution tracking
|
||||
executedBlocks: Set<string> // Set of block IDs that have been executed
|
||||
|
||||
@@ -359,8 +359,8 @@ export function createLoopWorkflowState(): WorkflowStateFixture {
|
||||
loop1: {
|
||||
id: 'loop1',
|
||||
nodes: ['function1', 'condition1'],
|
||||
maxIterations: 10,
|
||||
minIterations: 1,
|
||||
iterations: 10,
|
||||
loopType: 'for',
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@@ -213,8 +213,7 @@ describe('Serializer', () => {
|
||||
expect(serialized.loops.loop1).toBeDefined()
|
||||
expect(serialized.loops.loop1.nodes).toContain('function1')
|
||||
expect(serialized.loops.loop1.nodes).toContain('condition1')
|
||||
expect(serialized.loops.loop1.maxIterations).toBe(10)
|
||||
expect(serialized.loops.loop1.minIterations).toBe(1)
|
||||
expect(serialized.loops.loop1.iterations).toBe(10)
|
||||
|
||||
// Check connections for loop
|
||||
const loopBackConnection = serialized.connections.find(
|
||||
|
||||
@@ -42,6 +42,7 @@ export interface SerializedBlock {
|
||||
export interface SerializedLoop {
|
||||
id: string
|
||||
nodes: string[]
|
||||
maxIterations: number
|
||||
minIterations: number
|
||||
iterations: number
|
||||
loopType?: 'for' | 'forEach' | 'while'
|
||||
forEachItems?: any[] | Record<string, any> | string // Items to iterate over or expression to evaluate
|
||||
}
|
||||
|
||||
@@ -191,8 +191,9 @@ export const useWorkflowStore = create<WorkflowStoreWithHistory>()(
|
||||
newLoops[loopId] = {
|
||||
id: loopId,
|
||||
nodes: path,
|
||||
maxIterations: 5,
|
||||
minIterations: 0,
|
||||
iterations: 5,
|
||||
loopType: 'for', // Default to 'for' loop
|
||||
forEachItems: ''
|
||||
}
|
||||
processedPaths.add(canonicalPath)
|
||||
}
|
||||
@@ -230,8 +231,9 @@ export const useWorkflowStore = create<WorkflowStoreWithHistory>()(
|
||||
newLoops[loopId] = {
|
||||
id: loopId,
|
||||
nodes: path,
|
||||
maxIterations: 5,
|
||||
minIterations: 0,
|
||||
iterations: 5,
|
||||
loopType: 'for', // Default to 'for' loop
|
||||
forEachItems: ''
|
||||
}
|
||||
processedPaths.add(canonicalPath)
|
||||
}
|
||||
@@ -522,7 +524,7 @@ export const useWorkflowStore = create<WorkflowStoreWithHistory>()(
|
||||
get().updateLastSaved()
|
||||
},
|
||||
|
||||
updateLoopMaxIterations: (loopId: string, maxIterations: number) => {
|
||||
updateLoopIterations: (loopId: string, iterations: number) => {
|
||||
const newState = {
|
||||
blocks: { ...get().blocks },
|
||||
edges: [...get().edges],
|
||||
@@ -530,18 +532,18 @@ export const useWorkflowStore = create<WorkflowStoreWithHistory>()(
|
||||
...get().loops,
|
||||
[loopId]: {
|
||||
...get().loops[loopId],
|
||||
maxIterations: Math.max(1, Math.min(50, maxIterations)), // Clamp between 1-50
|
||||
iterations: Math.max(1, Math.min(50, iterations)), // Clamp between 1-50
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
set(newState)
|
||||
pushHistory(set, get, newState, 'Update loop max iterations')
|
||||
pushHistory(set, get, newState, 'Update loop iterations')
|
||||
get().updateLastSaved()
|
||||
workflowSync.sync()
|
||||
},
|
||||
|
||||
updateLoopMinIterations: (loopId: string, minIterations: number) => {
|
||||
updateLoopType: (loopId: string, loopType: Loop['loopType']) => {
|
||||
const newState = {
|
||||
blocks: { ...get().blocks },
|
||||
edges: [...get().edges],
|
||||
@@ -549,17 +551,54 @@ export const useWorkflowStore = create<WorkflowStoreWithHistory>()(
|
||||
...get().loops,
|
||||
[loopId]: {
|
||||
...get().loops[loopId],
|
||||
minIterations: Math.max(
|
||||
0,
|
||||
Math.min(get().loops[loopId].maxIterations, minIterations)
|
||||
), // Clamp between 0 and maxIterations
|
||||
loopType,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
set(newState)
|
||||
pushHistory(set, get, newState, 'Update loop min iterations')
|
||||
pushHistory(set, get, newState, 'Update loop type')
|
||||
get().updateLastSaved()
|
||||
workflowSync.sync()
|
||||
},
|
||||
|
||||
updateLoopForEachItems: (loopId: string, items: string) => {
|
||||
let parsedItems: any = items;
|
||||
|
||||
// Try to parse the string as JSON if it looks like JSON
|
||||
if (typeof items === 'string' &&
|
||||
((items.trim().startsWith('[') && items.trim().endsWith(']')) ||
|
||||
(items.trim().startsWith('{') && items.trim().endsWith('}')))
|
||||
) {
|
||||
try {
|
||||
// First try to parse to validate it's valid JSON
|
||||
JSON.parse(items);
|
||||
|
||||
// If parsing succeeds, store the original string to preserve formatting
|
||||
// This way we keep the user's exact formatting (spacing, line breaks, etc.)
|
||||
parsedItems = items;
|
||||
} catch (e) {
|
||||
// If parsing fails, keep it as a string
|
||||
parsedItems = items;
|
||||
}
|
||||
}
|
||||
|
||||
const newState = {
|
||||
blocks: { ...get().blocks },
|
||||
edges: [...get().edges],
|
||||
loops: {
|
||||
...get().loops,
|
||||
[loopId]: {
|
||||
...get().loops[loopId],
|
||||
forEachItems: parsedItems,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
set(newState)
|
||||
pushHistory(set, get, newState, 'Update forEach items')
|
||||
get().updateLastSaved()
|
||||
workflowSync.sync()
|
||||
},
|
||||
|
||||
triggerUpdate: () => {
|
||||
|
||||
@@ -28,8 +28,9 @@ export interface SubBlockState {
|
||||
export interface Loop {
|
||||
id: string
|
||||
nodes: string[]
|
||||
maxIterations: number
|
||||
minIterations: number
|
||||
iterations: number
|
||||
loopType: 'for' | 'forEach'
|
||||
forEachItems?: any[] | Record<string, any> | string // Items or expression
|
||||
}
|
||||
|
||||
export interface WorkflowState {
|
||||
@@ -57,8 +58,9 @@ export interface WorkflowActions {
|
||||
updateBlockName: (id: string, name: string) => void
|
||||
toggleBlockWide: (id: string) => void
|
||||
updateBlockHeight: (id: string, height: number) => void
|
||||
updateLoopMaxIterations: (loopId: string, maxIterations: number) => void
|
||||
updateLoopMinIterations: (loopId: string, minIterations: number) => void
|
||||
updateLoopIterations: (loopId: string, iterations: number) => void
|
||||
updateLoopType: (loopId: string, loopType: Loop['loopType']) => void
|
||||
updateLoopForEachItems: (loopId: string, items: string) => void
|
||||
triggerUpdate: () => void
|
||||
setDeploymentStatus: (isDeployed: boolean, deployedAt?: Date) => void
|
||||
setPublishStatus: (isPublished: boolean) => void
|
||||
|
||||
Reference in New Issue
Block a user