Skip to content

Commit 5bb9b46

Browse files
authored
improvement(subflows): support multiple blocks in parallel subflow, enhance logs to group by iteration for parallels/loop (#1429)
* feat(changelog): added changelog * move avatar icons in changelog * improvement(parallels): support multiple blocks in parallel subflow, enhance logs to group by iteration for parallels/loops * restore env * added tests * lint * update drizzle --------- Co-authored-by: waleed <waleed>
1 parent 994eb8d commit 5bb9b46

File tree

11 files changed

+931
-184
lines changed

11 files changed

+931
-184
lines changed

apps/sim/app/changelog/components/timeline-list.tsx

Lines changed: 34 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -98,9 +98,40 @@ export default function ChangelogList({ initialEntries }: Props) {
9898
<div className='space-y-10'>
9999
{entries.map((entry) => (
100100
<div key={entry.tag}>
101-
<div className='flex items-baseline justify-between gap-4'>
102-
<div className={`${soehne.className} font-semibold text-[18px] tracking-tight`}>
103-
{entry.tag}
101+
<div className='flex items-center justify-between gap-4'>
102+
<div className='flex items-center gap-2'>
103+
<div className={`${soehne.className} font-semibold text-[18px] tracking-tight`}>
104+
{entry.tag}
105+
</div>
106+
{entry.contributors && entry.contributors.length > 0 && (
107+
<div className='-space-x-2 flex'>
108+
{entry.contributors.slice(0, 5).map((contributor) => (
109+
<a
110+
key={contributor}
111+
href={`https://github.com/${contributor}`}
112+
target='_blank'
113+
rel='noreferrer noopener'
114+
aria-label={`View @${contributor} on GitHub`}
115+
title={`@${contributor}`}
116+
className='block'
117+
>
118+
<Avatar className='size-6 ring-2 ring-background'>
119+
<AvatarImage
120+
src={`https://avatars.githubusercontent.com/${contributor}`}
121+
alt={`@${contributor}`}
122+
className='hover:z-10'
123+
/>
124+
<AvatarFallback>{contributor.slice(0, 2).toUpperCase()}</AvatarFallback>
125+
</Avatar>
126+
</a>
127+
))}
128+
{entry.contributors.length > 5 && (
129+
<div className='relative flex size-6 items-center justify-center rounded-full bg-muted text-[10px] text-foreground ring-2 ring-background hover:z-10'>
130+
+{entry.contributors.length - 5}
131+
</div>
132+
)}
133+
</div>
134+
)}
104135
</div>
105136
<div className={`${inter.className} text-muted-foreground text-xs`}>
106137
{new Date(entry.date).toLocaleDateString('en-US', {
@@ -184,26 +215,6 @@ export default function ChangelogList({ initialEntries }: Props) {
184215
{cleanMarkdown(entry.content)}
185216
</ReactMarkdown>
186217
</div>
187-
188-
{entry.contributors && entry.contributors.length > 0 && (
189-
<div className='-space-x-2 mt-3 flex'>
190-
{entry.contributors.slice(0, 5).map((contributor) => (
191-
<Avatar key={contributor} className='size-6 ring-2 ring-background'>
192-
<AvatarImage
193-
src={`https://avatars.githubusercontent.com/${contributor}`}
194-
alt={`@${contributor}`}
195-
className='hover:z-10'
196-
/>
197-
<AvatarFallback>{contributor.slice(0, 2).toUpperCase()}</AvatarFallback>
198-
</Avatar>
199-
))}
200-
{entry.contributors.length > 5 && (
201-
<div className='relative flex size-6 items-center justify-center rounded-full bg-muted text-[10px] text-foreground ring-2 ring-background hover:z-10'>
202-
+{entry.contributors.length - 5}
203-
</div>
204-
)}
205-
</div>
206-
)}
207218
</div>
208219
))}
209220

apps/sim/executor/handlers/loop/loop-handler.test.ts

Lines changed: 15 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -82,35 +82,29 @@ describe('LoopBlockHandler', () => {
8282
it('should initialize loop on first execution', async () => {
8383
const result = await handler.execute(mockBlock, {}, mockContext)
8484

85-
// After execution, the counter is incremented for the next iteration
8685
expect(mockContext.loopIterations.get('loop-1')).toBe(1)
8786
expect(mockContext.activeExecutionPath.has('inner-block')).toBe(true)
8887

89-
// Type guard to check if result has the expected structure
9088
if (typeof result === 'object' && result !== null) {
9189
const response = result as any
92-
expect(response.currentIteration).toBe(0) // Still shows current iteration as 0
90+
expect(response.currentIteration).toBe(1)
9391
expect(response.maxIterations).toBe(3)
9492
expect(response.completed).toBe(false)
9593
}
9694
})
9795

9896
it('should activate loop-end-source when iterations complete', async () => {
99-
// Set to last iteration
100-
mockContext.loopIterations.set('loop-1', 3)
97+
mockContext.loopIterations.set('loop-1', 4)
10198

10299
const result = await handler.execute(mockBlock, {}, mockContext)
103100

104-
// The loop handler no longer marks loops as completed - that's handled by the loop manager
105101
expect(mockContext.completedLoops.has('loop-1')).toBe(false)
106-
// The loop handler also doesn't activate end connections anymore
107102
expect(mockContext.activeExecutionPath.has('after-loop')).toBe(false)
108-
// But it should not activate the inner block either since we're at max iterations
109103
expect(mockContext.activeExecutionPath.has('inner-block')).toBe(false)
110104

111105
if (typeof result === 'object' && result !== null) {
112106
const response = result as any
113-
expect(response.completed).toBe(false) // Not completed until all blocks execute
107+
expect(response.completed).toBe(false)
114108
expect(response.message).toContain('Final iteration')
115109
}
116110
})
@@ -131,7 +125,7 @@ describe('LoopBlockHandler', () => {
131125
if (typeof result === 'object' && result !== null) {
132126
const response = result as any
133127
expect(response.loopType).toBe('forEach')
134-
expect(response.maxIterations).toBe(3) // Limited by items length
128+
expect(response.maxIterations).toBe(3)
135129
}
136130
})
137131

@@ -153,28 +147,26 @@ describe('LoopBlockHandler', () => {
153147
})
154148

155149
it('should limit forEach loops by collection size, not iterations parameter', async () => {
156-
// This tests the fix for the bug where forEach loops were using the iterations count
157-
// instead of the actual collection size
158150
mockContext.workflow!.loops['loop-1'] = {
159151
id: 'loop-1',
160152
nodes: ['inner-block'],
161-
iterations: 10, // High iteration count
153+
iterations: 10,
162154
loopType: 'forEach',
163-
forEachItems: ['a', 'b'], // Only 2 items
155+
forEachItems: ['a', 'b'],
164156
}
165157

166-
// First execution
167158
let result = await handler.execute(mockBlock, {}, mockContext)
168159
expect(mockContext.loopIterations.get('loop-1')).toBe(1)
169160
expect(mockContext.loopItems.get('loop-1')).toBe('a')
170161

171162
if (typeof result === 'object' && result !== null) {
172163
const response = result as any
173-
expect(response.maxIterations).toBe(2) // Should be limited to 2, not 10
164+
expect(response.maxIterations).toBe(2)
174165
expect(response.completed).toBe(false)
175166
}
176167

177-
// Second execution
168+
mockContext.loopIterations.set('loop-1', 2)
169+
178170
result = await handler.execute(mockBlock, {}, mockContext)
179171
expect(mockContext.loopIterations.get('loop-1')).toBe(2)
180172
expect(mockContext.loopItems.get('loop-1')).toBe('b')
@@ -184,7 +176,10 @@ describe('LoopBlockHandler', () => {
184176
expect(response.completed).toBe(false)
185177
}
186178

187-
// Third execution should complete the loop
179+
// Manually increment iteration for third execution (exceeds max)
180+
mockContext.loopIterations.set('loop-1', 3)
181+
182+
// Third execution should exceed the loop limit
188183
result = await handler.execute(mockBlock, {}, mockContext)
189184
// The loop handler no longer marks loops as completed - that's handled by the loop manager
190185
expect(mockContext.completedLoops.has('loop-1')).toBe(false)
@@ -196,7 +191,7 @@ describe('LoopBlockHandler', () => {
196191
nodes: ['inner-block'],
197192
iterations: 5,
198193
loopType: 'forEach',
199-
forEachItems: '', // Empty collection
194+
forEachItems: '',
200195
}
201196

202197
await expect(handler.execute(mockBlock, {}, mockContext)).rejects.toThrow(
@@ -210,7 +205,7 @@ describe('LoopBlockHandler', () => {
210205
nodes: ['inner-block'],
211206
iterations: 5,
212207
loopType: 'forEach',
213-
forEachItems: [], // Empty array
208+
forEachItems: [],
214209
}
215210

216211
await expect(handler.execute(mockBlock, {}, mockContext)).rejects.toThrow(
@@ -223,40 +218,34 @@ describe('LoopBlockHandler', () => {
223218
it('should activate children when in active path', async () => {
224219
const handlerWithPathTracker = new LoopBlockHandler(undefined, mockPathTracker as any)
225220

226-
// Mock PathTracker to return true (block is in active path)
227221
mockPathTracker.isInActivePath.mockReturnValue(true)
228222

229223
await handlerWithPathTracker.execute(mockBlock, {}, mockContext)
230224

231-
// Should activate children when in active path
232225
expect(mockContext.activeExecutionPath.has('inner-block')).toBe(true)
233226
expect(mockPathTracker.isInActivePath).toHaveBeenCalledWith('loop-1', mockContext)
234227
})
235228

236229
it('should not activate children when not in active path', async () => {
237230
const handlerWithPathTracker = new LoopBlockHandler(undefined, mockPathTracker as any)
238231

239-
// Mock PathTracker to return false (block is not in active path)
240232
mockPathTracker.isInActivePath.mockReturnValue(false)
241233

242234
await handlerWithPathTracker.execute(mockBlock, {}, mockContext)
243235

244-
// Should not activate children when not in active path
245236
expect(mockContext.activeExecutionPath.has('inner-block')).toBe(false)
246237
expect(mockPathTracker.isInActivePath).toHaveBeenCalledWith('loop-1', mockContext)
247238
})
248239

249240
it('should handle PathTracker errors gracefully', async () => {
250241
const handlerWithPathTracker = new LoopBlockHandler(undefined, mockPathTracker as any)
251242

252-
// Mock PathTracker to throw error
253243
mockPathTracker.isInActivePath.mockImplementation(() => {
254244
throw new Error('PathTracker error')
255245
})
256246

257247
await handlerWithPathTracker.execute(mockBlock, {}, mockContext)
258248

259-
// Should default to activating children when PathTracker fails
260249
expect(mockContext.activeExecutionPath.has('inner-block')).toBe(true)
261250
})
262251
})

apps/sim/executor/handlers/loop/loop-handler.ts

Lines changed: 11 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,6 @@ export class LoopBlockHandler implements BlockHandler {
3232
): Promise<BlockOutput> {
3333
logger.info(`Executing loop block: ${block.id}`)
3434

35-
// Get the loop configuration from the workflow
3635
const loop = context.workflow?.loops?.[block.id]
3736
if (!loop) {
3837
logger.error(`Loop configuration not found for block ${block.id}`, {
@@ -43,13 +42,12 @@ export class LoopBlockHandler implements BlockHandler {
4342
throw new Error(`Loop configuration not found for block ${block.id}`)
4443
}
4544

46-
// Initialize loop iteration if not already done
4745
if (!context.loopIterations.has(block.id)) {
48-
context.loopIterations.set(block.id, 0)
49-
logger.info(`Initialized loop ${block.id} with 0 iterations`)
46+
context.loopIterations.set(block.id, 1)
47+
logger.info(`Initialized loop ${block.id} starting at iteration 1`)
5048
}
5149

52-
const currentIteration = context.loopIterations.get(block.id) || 0
50+
const currentIteration = context.loopIterations.get(block.id) || 1
5351
let maxIterations: number
5452
let forEachItems: any[] | Record<string, any> | null = null
5553
if (loop.loopType === 'forEach') {
@@ -75,7 +73,6 @@ export class LoopBlockHandler implements BlockHandler {
7573
)
7674
}
7775

78-
// For forEach, max iterations = items length
7976
const itemsLength = Array.isArray(forEachItems)
8077
? forEachItems.length
8178
: Object.keys(forEachItems).length
@@ -94,12 +91,9 @@ export class LoopBlockHandler implements BlockHandler {
9491
`Loop ${block.id} - Current iteration: ${currentIteration}, Max iterations: ${maxIterations}`
9592
)
9693

97-
// Check if we've reached the maximum iterations
98-
if (currentIteration >= maxIterations) {
94+
if (currentIteration > maxIterations) {
9995
logger.info(`Loop ${block.id} has reached maximum iterations (${maxIterations})`)
10096

101-
// Don't mark as completed here - let the loop manager handle it after all blocks execute
102-
// Just return that this is the final iteration
10397
return {
10498
loopId: block.id,
10599
currentIteration: currentIteration - 1, // Report the actual last iteration number
@@ -110,28 +104,20 @@ export class LoopBlockHandler implements BlockHandler {
110104
} as Record<string, any>
111105
}
112106

113-
// For forEach loops, set the current item BEFORE incrementing
114107
if (loop.loopType === 'forEach' && forEachItems) {
115-
// Store the full items array for access via <loop.items>
116108
context.loopItems.set(`${block.id}_items`, forEachItems)
117109

110+
const arrayIndex = currentIteration - 1
118111
const currentItem = Array.isArray(forEachItems)
119-
? forEachItems[currentIteration]
120-
: Object.entries(forEachItems)[currentIteration]
112+
? forEachItems[arrayIndex]
113+
: Object.entries(forEachItems)[arrayIndex]
121114
context.loopItems.set(block.id, currentItem)
122115
logger.info(
123-
`Loop ${block.id} - Set current item for iteration ${currentIteration}:`,
116+
`Loop ${block.id} - Set current item for iteration ${currentIteration} (index ${arrayIndex}):`,
124117
currentItem
125118
)
126119
}
127120

128-
// Increment the iteration counter for the NEXT iteration
129-
// This happens AFTER we've set up the current iteration's data
130-
context.loopIterations.set(block.id, currentIteration + 1)
131-
logger.info(
132-
`Loop ${block.id} - Incremented counter for next iteration: ${currentIteration + 1}`
133-
)
134-
135121
// Use routing strategy to determine if this block requires active path checking
136122
const blockType = block.metadata?.id
137123
if (Routing.requiresActivePathCheck(blockType || '')) {
@@ -141,12 +127,10 @@ export class LoopBlockHandler implements BlockHandler {
141127
isInActivePath = this.pathTracker.isInActivePath(block.id, context)
142128
} catch (error) {
143129
logger.warn(`PathTracker check failed for ${blockType} block ${block.id}:`, error)
144-
// Default to true to maintain existing behavior if PathTracker fails
145130
isInActivePath = true
146131
}
147132
}
148133

149-
// Only activate child nodes if this block is in the active execution path
150134
if (isInActivePath) {
151135
this.activateChildNodes(block, context, currentIteration)
152136
} else {
@@ -155,17 +139,18 @@ export class LoopBlockHandler implements BlockHandler {
155139
)
156140
}
157141
} else {
158-
// Regular blocks always activate their children
159142
this.activateChildNodes(block, context, currentIteration)
160143
}
161144

145+
context.loopIterations.set(block.id, currentIteration)
146+
162147
return {
163148
loopId: block.id,
164149
currentIteration,
165150
maxIterations,
166151
loopType: loop.loopType || 'for',
167152
completed: false,
168-
message: `Starting iteration ${currentIteration + 1} of ${maxIterations}`,
153+
message: `Starting iteration ${currentIteration} of ${maxIterations}`,
169154
} as Record<string, any>
170155
}
171156

@@ -177,7 +162,6 @@ export class LoopBlockHandler implements BlockHandler {
177162
context: ExecutionContext,
178163
currentIteration: number
179164
): void {
180-
// Loop is still active, activate the loop-start-source connection
181165
const loopStartConnections =
182166
context.workflow?.connections.filter(
183167
(conn) => conn.source === block.id && conn.sourceHandle === 'loop-start-source'

0 commit comments

Comments
 (0)