From 198e2c21fd18cd1e06fac53394a623a18bf082d0 Mon Sep 17 00:00:00 2001 From: Vasyl Abramovych Date: Sat, 28 Feb 2026 19:09:12 -0800 Subject: [PATCH 1/4] feat(executor): support nested loop DAG construction and edge wiring Wire inner loop sentinel nodes into outer loop sentinel chains so that nested loops execute correctly. Resolves boundary-node detection to use effective sentinel IDs for nested loops, handles loop-exit edges from inner sentinel-end to outer sentinel-end, and recursively clears execution state for all nested loop scopes between iterations. NOTE: loop-in-loop nesting only; parallel nesting is not yet supported. Made-with: Cursor --- apps/sim/executor/dag/builder.ts | 4 +- .../executor/dag/construction/edges.test.ts | 233 ++++++++++++++++++ apps/sim/executor/dag/construction/edges.ts | 87 ++++++- apps/sim/executor/orchestrators/loop.ts | 61 ++++- apps/sim/executor/orchestrators/node.ts | 2 +- 5 files changed, 358 insertions(+), 29 deletions(-) diff --git a/apps/sim/executor/dag/builder.ts b/apps/sim/executor/dag/builder.ts index f2a43917d3..9064707579 100644 --- a/apps/sim/executor/dag/builder.ts +++ b/apps/sim/executor/dag/builder.ts @@ -8,7 +8,7 @@ import type { DAGEdge, NodeMetadata } from '@/executor/dag/types' import { buildParallelSentinelStartId, buildSentinelStartId, - extractBaseBlockId, + normalizeNodeId, } from '@/executor/utils/subflow-utils' import type { SerializedBlock, @@ -156,7 +156,7 @@ export class DAGBuilder { } const hasConnections = Array.from(sentinelStartNode.outgoingEdges.values()).some((edge) => - nodes.includes(extractBaseBlockId(edge.target)) + nodes.includes(normalizeNodeId(edge.target)) ) if (!hasConnections) { diff --git a/apps/sim/executor/dag/construction/edges.test.ts b/apps/sim/executor/dag/construction/edges.test.ts index a04e784258..8edd4cfb09 100644 --- a/apps/sim/executor/dag/construction/edges.test.ts +++ b/apps/sim/executor/dag/construction/edges.test.ts @@ -1102,4 +1102,237 @@ describe('EdgeConstructor', () => { }) }) }) + + describe('Nested loop wiring', () => { + it('should wire inner loop sentinels into outer loop sentinel chain', () => { + const outerLoopId = 'outer-loop' + const innerLoopId = 'inner-loop' + const functionId = 'func-1' + const innerFunctionId = 'func-2' + + const outerSentinelStart = `loop-${outerLoopId}-sentinel-start` + const outerSentinelEnd = `loop-${outerLoopId}-sentinel-end` + const innerSentinelStart = `loop-${innerLoopId}-sentinel-start` + const innerSentinelEnd = `loop-${innerLoopId}-sentinel-end` + + const outerLoop: SerializedLoop = { + id: outerLoopId, + nodes: [functionId, innerLoopId], + iterations: 5, + loopType: 'for', + } + const innerLoop: SerializedLoop = { + id: innerLoopId, + nodes: [innerFunctionId], + iterations: 3, + loopType: 'for', + } + + const dag = createMockDAG([ + functionId, + innerFunctionId, + outerSentinelStart, + outerSentinelEnd, + innerSentinelStart, + innerSentinelEnd, + ]) + dag.loopConfigs.set(outerLoopId, outerLoop) + dag.loopConfigs.set(innerLoopId, innerLoop) + + const workflow = createMockWorkflow( + [ + createMockBlock(functionId), + createMockBlock(innerFunctionId), + createMockBlock(innerLoopId, 'loop'), + ], + [{ source: functionId, target: innerLoopId }], + { [outerLoopId]: outerLoop, [innerLoopId]: innerLoop } + ) + + edgeConstructor.execute( + workflow, + dag, + new Set(), + new Set([functionId, innerLoopId, innerFunctionId]), + new Set([ + functionId, + innerFunctionId, + innerLoopId, + outerSentinelStart, + outerSentinelEnd, + innerSentinelStart, + innerSentinelEnd, + ]), + new Map() + ) + + const outerStartNode = dag.nodes.get(outerSentinelStart)! + const outerStartTargets = Array.from(outerStartNode.outgoingEdges.values()).map( + (e) => e.target + ) + expect(outerStartTargets).toContain(functionId) + + const funcNode = dag.nodes.get(functionId)! + const funcTargets = Array.from(funcNode.outgoingEdges.values()).map((e) => e.target) + expect(funcTargets).toContain(innerSentinelStart) + + const innerEndNode = dag.nodes.get(innerSentinelEnd)! + const innerEndEdges = Array.from(innerEndNode.outgoingEdges.values()) + const exitEdge = innerEndEdges.find((e) => e.target === outerSentinelEnd) + expect(exitEdge).toBeDefined() + expect(exitEdge!.sourceHandle).toBe('loop_exit') + + const backEdge = innerEndEdges.find((e) => e.target === innerSentinelStart) + expect(backEdge).toBeDefined() + expect(backEdge!.sourceHandle).toBe('loop_continue') + + const outerEndNode = dag.nodes.get(outerSentinelEnd)! + const outerBackEdge = Array.from(outerEndNode.outgoingEdges.values()).find( + (e) => e.target === outerSentinelStart + ) + expect(outerBackEdge).toBeDefined() + expect(outerBackEdge!.sourceHandle).toBe('loop_continue') + }) + + it('should correctly identify boundary nodes when inner loop is the only node', () => { + const outerLoopId = 'outer-loop' + const innerLoopId = 'inner-loop' + const innerFunctionId = 'func-inner' + + const outerSentinelStart = `loop-${outerLoopId}-sentinel-start` + const outerSentinelEnd = `loop-${outerLoopId}-sentinel-end` + const innerSentinelStart = `loop-${innerLoopId}-sentinel-start` + const innerSentinelEnd = `loop-${innerLoopId}-sentinel-end` + + const outerLoop: SerializedLoop = { + id: outerLoopId, + nodes: [innerLoopId], + iterations: 2, + loopType: 'for', + } + const innerLoop: SerializedLoop = { + id: innerLoopId, + nodes: [innerFunctionId], + iterations: 3, + loopType: 'for', + } + + const dag = createMockDAG([ + innerFunctionId, + outerSentinelStart, + outerSentinelEnd, + innerSentinelStart, + innerSentinelEnd, + ]) + dag.loopConfigs.set(outerLoopId, outerLoop) + dag.loopConfigs.set(innerLoopId, innerLoop) + + const workflow = createMockWorkflow( + [createMockBlock(innerFunctionId), createMockBlock(innerLoopId, 'loop')], + [], + { [outerLoopId]: outerLoop, [innerLoopId]: innerLoop } + ) + + edgeConstructor.execute( + workflow, + dag, + new Set(), + new Set([innerLoopId, innerFunctionId]), + new Set([ + innerFunctionId, + innerLoopId, + outerSentinelStart, + outerSentinelEnd, + innerSentinelStart, + innerSentinelEnd, + ]), + new Map() + ) + + const outerStartNode = dag.nodes.get(outerSentinelStart)! + const outerStartTargets = Array.from(outerStartNode.outgoingEdges.values()).map( + (e) => e.target + ) + expect(outerStartTargets).toContain(innerSentinelStart) + + const innerEndNode = dag.nodes.get(innerSentinelEnd)! + const exitEdge = Array.from(innerEndNode.outgoingEdges.values()).find( + (e) => e.target === outerSentinelEnd + ) + expect(exitEdge).toBeDefined() + expect(exitEdge!.sourceHandle).toBe('loop_exit') + }) + + it('should not drop intra-loop edges when target is a nested loop block', () => { + const outerLoopId = 'outer-loop' + const innerLoopId = 'inner-loop' + const functionId = 'func-1' + const innerFunctionId = 'func-2' + + const outerSentinelStart = `loop-${outerLoopId}-sentinel-start` + const outerSentinelEnd = `loop-${outerLoopId}-sentinel-end` + const innerSentinelStart = `loop-${innerLoopId}-sentinel-start` + const innerSentinelEnd = `loop-${innerLoopId}-sentinel-end` + + const outerLoop: SerializedLoop = { + id: outerLoopId, + nodes: [functionId, innerLoopId], + iterations: 5, + loopType: 'for', + } + const innerLoop: SerializedLoop = { + id: innerLoopId, + nodes: [innerFunctionId], + iterations: 3, + loopType: 'for', + } + + const dag = createMockDAG([ + functionId, + innerFunctionId, + outerSentinelStart, + outerSentinelEnd, + innerSentinelStart, + innerSentinelEnd, + ]) + dag.loopConfigs.set(outerLoopId, outerLoop) + dag.loopConfigs.set(innerLoopId, innerLoop) + + const workflow = createMockWorkflow( + [ + createMockBlock(functionId), + createMockBlock(innerFunctionId), + createMockBlock(innerLoopId, 'loop'), + ], + [{ source: functionId, target: innerLoopId }], + { [outerLoopId]: outerLoop, [innerLoopId]: innerLoop } + ) + + edgeConstructor.execute( + workflow, + dag, + new Set(), + new Set([functionId, innerLoopId, innerFunctionId]), + new Set([ + functionId, + innerFunctionId, + innerLoopId, + outerSentinelStart, + outerSentinelEnd, + innerSentinelStart, + innerSentinelEnd, + ]), + new Map() + ) + + const funcNode = dag.nodes.get(functionId)! + const edgeToInnerStart = Array.from(funcNode.outgoingEdges.values()).find( + (e) => e.target === innerSentinelStart + ) + expect(edgeToInnerStart).toBeDefined() + + const innerStartNode = dag.nodes.get(innerSentinelStart)! + expect(innerStartNode.incomingEdges.has(functionId)).toBe(true) + }) + }) }) diff --git a/apps/sim/executor/dag/construction/edges.ts b/apps/sim/executor/dag/construction/edges.ts index ef6c238de6..f2ff9a6185 100644 --- a/apps/sim/executor/dag/construction/edges.ts +++ b/apps/sim/executor/dag/construction/edges.ts @@ -5,7 +5,7 @@ import { isRouterBlockType, isRouterV2BlockType, } from '@/executor/constants' -import type { DAG } from '@/executor/dag/builder' +import type { DAG, DAGNode } from '@/executor/dag/builder' import { buildBranchNodeId, buildParallelSentinelEndId, @@ -258,11 +258,12 @@ export class EdgeConstructor { target = sentinelStartId } - if (this.edgeCrossesLoopBoundary(source, target, blocksInLoops, dag)) { + if (this.edgeCrossesLoopBoundary(originalSource, originalTarget, blocksInLoops, dag)) { continue } - if (loopSentinelStartId && !blocksInLoops.has(originalTarget)) { + const sourceLoopNodes = dag.loopConfigs.get(originalSource)?.nodes + if (loopSentinelStartId && !sourceLoopNodes?.includes(originalTarget)) { this.addEdge(dag, loopSentinelStartId, target, EDGE.LOOP_EXIT, targetHandle) } @@ -304,11 +305,17 @@ export class EdgeConstructor { const { startNodes, terminalNodes } = this.findLoopBoundaryNodes(nodes, dag, reachableBlocks) for (const startNodeId of startNodes) { - this.addEdge(dag, sentinelStartId, startNodeId) + const resolvedId = this.resolveLoopBlockToSentinelStart(startNodeId, dag) + this.addEdge(dag, sentinelStartId, resolvedId) } for (const terminalNodeId of terminalNodes) { - this.addEdge(dag, terminalNodeId, sentinelEndId) + const resolvedId = this.resolveLoopBlockToSentinelEnd(terminalNodeId, dag) + if (resolvedId !== terminalNodeId) { + this.addEdge(dag, resolvedId, sentinelEndId, EDGE.LOOP_EXIT) + } else { + this.addEdge(dag, resolvedId, sentinelEndId) + } } this.addEdge(dag, sentinelEndId, sentinelStartId, EDGE.LOOP_CONTINUE, undefined, true) @@ -406,24 +413,75 @@ export class EdgeConstructor { this.addEdge(dag, sourceNodeId, targetNodeId, sourceHandle, targetHandle) } + /** + * Resolves the DAG node to inspect for a given loop child. + * If the child is a nested loop block, returns its sentinel start node; + * otherwise returns the regular DAG node. + * + * NOTE: This currently handles loop-in-loop nesting only. + * Parallel-in-loop and parallel-in-parallel nesting is not yet supported. + */ + private resolveLoopChildNode( + nodeId: string, + dag: DAG, + sentinel: 'start' | 'end' + ): { resolvedId: string; node: DAGNode | undefined } { + if (dag.loopConfigs.has(nodeId)) { + const resolvedId = + sentinel === 'start' ? buildSentinelStartId(nodeId) : buildSentinelEndId(nodeId) + return { resolvedId, node: dag.nodes.get(resolvedId) } + } + return { resolvedId: nodeId, node: dag.nodes.get(nodeId) } + } + + private resolveLoopBlockToSentinelStart(nodeId: string, dag: DAG): string { + if (dag.loopConfigs.has(nodeId)) { + return buildSentinelStartId(nodeId) + } + return nodeId + } + + private resolveLoopBlockToSentinelEnd(nodeId: string, dag: DAG): string { + if (dag.loopConfigs.has(nodeId)) { + return buildSentinelEndId(nodeId) + } + return nodeId + } + + /** + * Builds the set of effective DAG node IDs for a loop's children, + * mapping nested loop block IDs to their sentinel IDs. + */ + private buildEffectiveNodeSet(nodes: string[], dag: DAG): Set { + const effective = new Set() + for (const nodeId of nodes) { + if (dag.loopConfigs.has(nodeId)) { + effective.add(buildSentinelStartId(nodeId)) + effective.add(buildSentinelEndId(nodeId)) + } else { + effective.add(nodeId) + } + } + return effective + } + private findLoopBoundaryNodes( nodes: string[], dag: DAG, - reachableBlocks: Set + _reachableBlocks: Set ): { startNodes: string[]; terminalNodes: string[] } { - const nodesSet = new Set(nodes) + const effectiveNodeSet = this.buildEffectiveNodeSet(nodes, dag) const startNodesSet = new Set() const terminalNodesSet = new Set() for (const nodeId of nodes) { - const node = dag.nodes.get(nodeId) + const { node } = this.resolveLoopChildNode(nodeId, dag, 'start') if (!node) continue let hasIncomingFromLoop = false - for (const incomingNodeId of node.incomingEdges) { - if (nodesSet.has(incomingNodeId)) { + if (effectiveNodeSet.has(incomingNodeId)) { hasIncomingFromLoop = true break } @@ -435,14 +493,17 @@ export class EdgeConstructor { } for (const nodeId of nodes) { - const node = dag.nodes.get(nodeId) + const { node } = this.resolveLoopChildNode(nodeId, dag, 'end') if (!node) continue let hasOutgoingToLoop = false + for (const [, edge] of node.outgoingEdges) { + const isBackEdge = + edge.sourceHandle === EDGE.LOOP_CONTINUE || edge.sourceHandle === EDGE.LOOP_CONTINUE_ALT + if (isBackEdge) continue - for (const [_, edge] of node.outgoingEdges) { - if (nodesSet.has(edge.target)) { + if (effectiveNodeSet.has(edge.target)) { hasOutgoingToLoop = true break } diff --git a/apps/sim/executor/orchestrators/loop.ts b/apps/sim/executor/orchestrators/loop.ts index 456838d1ee..2b75970e8e 100644 --- a/apps/sim/executor/orchestrators/loop.ts +++ b/apps/sim/executor/orchestrators/loop.ts @@ -327,22 +327,60 @@ export class LoopOrchestrator { return result } - clearLoopExecutionState(loopId: string): void { + clearLoopExecutionState(loopId: string, ctx?: ExecutionContext): void { + const allNodeIds = this.collectAllLoopNodeIds(loopId) + + for (const nodeId of allNodeIds) { + this.state.unmarkExecuted(nodeId) + } + + if (ctx) { + this.resetNestedLoopScopes(loopId, ctx) + } + } + + /** + * Deletes loop scopes for any nested loops so they re-initialize + * on the next outer iteration. + */ + private resetNestedLoopScopes(loopId: string, ctx: ExecutionContext): void { const loopConfig = this.dag.loopConfigs.get(loopId) as LoopConfigWithNodes | undefined - if (!loopConfig) { - logger.warn('Loop config not found for state clearing', { loopId }) - return + if (!loopConfig) return + + for (const nodeId of loopConfig.nodes) { + if (this.dag.loopConfigs.has(nodeId)) { + ctx.loopExecutions?.delete(nodeId) + this.resetNestedLoopScopes(nodeId, ctx) + } } + } + + /** + * Collects all effective DAG node IDs for a loop, recursively including + * sentinel IDs for any nested loop blocks. + * + * NOTE: This currently handles loop-in-loop nesting only. + * Parallel-in-loop and parallel-in-parallel nesting is not yet supported. + */ + private collectAllLoopNodeIds(loopId: string): Set { + const loopConfig = this.dag.loopConfigs.get(loopId) as LoopConfigWithNodes | undefined + if (!loopConfig) return new Set() const sentinelStartId = buildSentinelStartId(loopId) const sentinelEndId = buildSentinelEndId(loopId) - const loopNodes = loopConfig.nodes + const result = new Set([sentinelStartId, sentinelEndId]) - this.state.unmarkExecuted(sentinelStartId) - this.state.unmarkExecuted(sentinelEndId) - for (const loopNodeId of loopNodes) { - this.state.unmarkExecuted(loopNodeId) + for (const nodeId of loopConfig.nodes) { + if (this.dag.loopConfigs.has(nodeId)) { + for (const id of this.collectAllLoopNodeIds(nodeId)) { + result.add(id) + } + } else { + result.add(nodeId) + } } + + return result } restoreLoopEdges(loopId: string): void { @@ -352,10 +390,7 @@ export class LoopOrchestrator { return } - const sentinelStartId = buildSentinelStartId(loopId) - const sentinelEndId = buildSentinelEndId(loopId) - const loopNodes = loopConfig.nodes - const allLoopNodeIds = new Set([sentinelStartId, sentinelEndId, ...loopNodes]) + const allLoopNodeIds = this.collectAllLoopNodeIds(loopId) if (this.edgeManager) { this.edgeManager.clearDeactivatedEdgesForNodes(allLoopNodeIds) diff --git a/apps/sim/executor/orchestrators/node.ts b/apps/sim/executor/orchestrators/node.ts index 535693a82f..d1e56daf52 100644 --- a/apps/sim/executor/orchestrators/node.ts +++ b/apps/sim/executor/orchestrators/node.ts @@ -276,7 +276,7 @@ export class NodeExecutionOrchestrator { ) { const loopId = node.metadata.loopId if (loopId) { - this.loopOrchestrator.clearLoopExecutionState(loopId) + this.loopOrchestrator.clearLoopExecutionState(loopId, ctx) this.loopOrchestrator.restoreLoopEdges(loopId) } } From 7ecbe5d290d874e9061527dac96e8cf73875f818 Mon Sep 17 00:00:00 2001 From: Vasyl Abramovych Date: Sat, 28 Feb 2026 19:09:34 -0800 Subject: [PATCH 2/4] feat(executor): add nested loop iteration context and named loop variable resolution Introduce ParentIteration to track ancestor loop state, build a loopParentMap during DAG construction, and propagate parent iterations through block execution and child workflow contexts. Extend LoopResolver to support named loop references (e.g. ) and add output property resolution (). Named references use the block's display name normalized to a tag-safe identifier, enabling blocks inside nested loops to reference any ancestor loop's iteration state. NOTE: loop-in-loop nesting only; parallel nesting is not yet supported. Made-with: Cursor --- apps/sim/executor/execution/block-executor.ts | 64 ++++--- apps/sim/executor/execution/executor.ts | 21 +++ apps/sim/executor/execution/types.ts | 13 ++ .../handlers/workflow/workflow-handler.ts | 24 +++ apps/sim/executor/types.ts | 6 + .../executor/variables/resolvers/loop.test.ts | 164 +++++++++++++++++- apps/sim/executor/variables/resolvers/loop.ts | 144 ++++++++++++--- 7 files changed, 382 insertions(+), 54 deletions(-) diff --git a/apps/sim/executor/execution/block-executor.ts b/apps/sim/executor/execution/block-executor.ts index 9325aa2861..8774478ce7 100644 --- a/apps/sim/executor/execution/block-executor.ts +++ b/apps/sim/executor/execution/block-executor.ts @@ -40,7 +40,6 @@ import { isJSONString } from '@/executor/utils/json' import { filterOutputForLog } from '@/executor/utils/output-filter' import type { VariableResolver } from '@/executor/variables/resolver' import type { SerializedBlock } from '@/serializer/types' -import type { SubflowType } from '@/stores/workflows/workflow/types' import { SYSTEM_SUBBLOCK_IDS } from '@/triggers/constants' const logger = createLogger('BlockExecutor') @@ -499,47 +498,58 @@ export class BlockExecutor { } } - private createIterationContext( - iterationCurrent: number, - iterationType: SubflowType, - iterationContainerId?: string, - iterationTotal?: number - ): IterationContext { - return { - iterationCurrent, - iterationTotal, - iterationType, - iterationContainerId, - } - } - private getIterationContext(ctx: ExecutionContext, node: DAGNode): IterationContext | undefined { if (!node?.metadata) return undefined if (node.metadata.branchIndex !== undefined && node.metadata.branchTotal !== undefined) { - return this.createIterationContext( - node.metadata.branchIndex, - 'parallel', - node.metadata.parallelId, - node.metadata.branchTotal - ) + return { + iterationCurrent: node.metadata.branchIndex, + iterationTotal: node.metadata.branchTotal, + iterationType: 'parallel', + iterationContainerId: node.metadata.parallelId, + } } if (node.metadata.isLoopNode && node.metadata.loopId) { const loopScope = ctx.loopExecutions?.get(node.metadata.loopId) if (loopScope && loopScope.iteration !== undefined) { - return this.createIterationContext( - loopScope.iteration, - 'loop', - node.metadata.loopId, - loopScope.maxIterations - ) + const parentIterations = this.buildParentIterations(ctx, node.metadata.loopId) + const result: IterationContext = { + iterationCurrent: loopScope.iteration, + iterationTotal: loopScope.maxIterations, + iterationType: 'loop', + iterationContainerId: node.metadata.loopId, + ...(parentIterations.length > 0 && { parentIterations }), + } + return result } } return undefined } + private buildParentIterations( + ctx: ExecutionContext, + loopId: string + ): IterationContext['parentIterations'] & object { + const parents: NonNullable = [] + let currentLoopId = loopId + while (ctx.loopParentMap?.has(currentLoopId)) { + const parentLoopId = ctx.loopParentMap.get(currentLoopId)! + const parentScope = ctx.loopExecutions?.get(parentLoopId) + if (parentScope && parentScope.iteration !== undefined) { + parents.unshift({ + iterationCurrent: parentScope.iteration, + iterationTotal: parentScope.maxIterations, + iterationType: 'loop', + iterationContainerId: parentLoopId, + }) + } + currentLoopId = parentLoopId + } + return parents + } + private preparePauseResumeSelfReference( ctx: ExecutionContext, node: DAGNode, diff --git a/apps/sim/executor/execution/executor.ts b/apps/sim/executor/execution/executor.ts index a888409347..aebe34b2c3 100644 --- a/apps/sim/executor/execution/executor.ts +++ b/apps/sim/executor/execution/executor.ts @@ -1,5 +1,6 @@ import { createLogger } from '@sim/logger' import { StartBlockPath } from '@/lib/workflows/triggers/triggers' +import type { DAG } from '@/executor/dag/builder' import { DAGBuilder } from '@/executor/dag/builder' import { BlockExecutor } from '@/executor/execution/block-executor' import { EdgeManager } from '@/executor/execution/edge-manager' @@ -67,6 +68,7 @@ export class DAGExecutor { savedIncomingEdges, }) const { context, state } = this.createExecutionContext(workflowId, triggerBlockId) + context.loopParentMap = this.buildLoopParentMap(dag) const resolver = new VariableResolver(this.workflow, this.workflowVariables, state) const loopOrchestrator = new LoopOrchestrator(dag, state, resolver) @@ -208,6 +210,7 @@ export class DAGExecutor { snapshotState: filteredSnapshot, runFromBlockContext, }) + context.loopParentMap = this.buildLoopParentMap(dag) const resolver = new VariableResolver(this.workflow, this.workflowVariables, state) const loopOrchestrator = new LoopOrchestrator(dag, state, resolver) @@ -371,6 +374,24 @@ export class DAGExecutor { return { context, state } } + /** + * Builds a child-loop -> parent-loop mapping for nested loop iteration tracking. + * + * NOTE: This currently handles loop-in-loop nesting only. + * Parallel-in-loop and parallel-in-parallel nesting is not yet supported. + */ + private buildLoopParentMap(dag: DAG): Map { + const parentMap = new Map() + for (const [loopId, config] of dag.loopConfigs) { + for (const nodeId of config.nodes) { + if (dag.loopConfigs.has(nodeId)) { + parentMap.set(nodeId, loopId) + } + } + } + return parentMap + } + private initializeStarterBlock( context: ExecutionContext, state: ExecutionState, diff --git a/apps/sim/executor/execution/types.ts b/apps/sim/executor/execution/types.ts index bea082fe8a..6257dc683d 100644 --- a/apps/sim/executor/execution/types.ts +++ b/apps/sim/executor/execution/types.ts @@ -49,11 +49,24 @@ export interface SerializableExecutionState { completedPauseContexts?: string[] } +/** + * Represents the iteration state of an ancestor loop in a nested loop chain. + * Used to propagate parent iteration context through SSE events. + * Currently only covers loop-in-loop nesting; parallel nesting is not yet supported. + */ +export interface ParentIteration { + iterationCurrent: number + iterationTotal?: number + iterationType: SubflowType + iterationContainerId: string +} + export interface IterationContext { iterationCurrent: number iterationTotal?: number iterationType: SubflowType iterationContainerId?: string + parentIterations?: ParentIteration[] } export interface ChildWorkflowContext { diff --git a/apps/sim/executor/handlers/workflow/workflow-handler.ts b/apps/sim/executor/handlers/workflow/workflow-handler.ts index b0123d7844..3b739de0f6 100644 --- a/apps/sim/executor/handlers/workflow/workflow-handler.ts +++ b/apps/sim/executor/handlers/workflow/workflow-handler.ts @@ -290,11 +290,13 @@ export class WorkflowBlockHandler implements BlockHandler { if (nodeMetadata.isLoopNode && nodeMetadata.loopId) { const loopScope = ctx.loopExecutions?.get(nodeMetadata.loopId) if (loopScope && loopScope.iteration !== undefined) { + const parentIterations = this.buildParentIterations(ctx, nodeMetadata.loopId) return { iterationCurrent: loopScope.iteration, iterationTotal: loopScope.maxIterations, iterationType: 'loop', iterationContainerId: nodeMetadata.loopId, + ...(parentIterations.length > 0 && { parentIterations }), } } } @@ -302,6 +304,28 @@ export class WorkflowBlockHandler implements BlockHandler { return undefined } + private buildParentIterations( + ctx: ExecutionContext, + loopId: string + ): NonNullable { + const parents: NonNullable = [] + let currentLoopId = loopId + while (ctx.loopParentMap?.has(currentLoopId)) { + const parentLoopId = ctx.loopParentMap.get(currentLoopId)! + const parentScope = ctx.loopExecutions?.get(parentLoopId) + if (parentScope && parentScope.iteration !== undefined) { + parents.unshift({ + iterationCurrent: parentScope.iteration, + iterationTotal: parentScope.maxIterations, + iterationType: 'loop', + iterationContainerId: parentLoopId, + }) + } + currentLoopId = parentLoopId + } + return parents + } + /** * Builds a cleaner error message for nested workflow errors. * Parses nested error messages to extract workflow chain and root error. diff --git a/apps/sim/executor/types.ts b/apps/sim/executor/types.ts index bf672dfd94..fb679a80c0 100644 --- a/apps/sim/executor/types.ts +++ b/apps/sim/executor/types.ts @@ -192,6 +192,12 @@ export interface ExecutionContext { completedLoops: Set + /** + * Maps child loop IDs to their parent loop IDs for nested loop iteration tracking. + * Only covers loop-in-loop nesting; parallel nesting is not yet supported. + */ + loopParentMap?: Map + loopExecutions?: Map< string, { diff --git a/apps/sim/executor/variables/resolvers/loop.test.ts b/apps/sim/executor/variables/resolvers/loop.test.ts index faabb48ca2..6a87fae707 100644 --- a/apps/sim/executor/variables/resolvers/loop.test.ts +++ b/apps/sim/executor/variables/resolvers/loop.test.ts @@ -7,11 +7,24 @@ import type { ResolutionContext } from './reference' vi.mock('@sim/logger', () => loggerMock) +interface LoopDef { + nodes: string[] + id?: string + iterations?: number + loopType?: 'for' | 'forEach' +} + +interface BlockDef { + id: string + name: string +} + /** * Creates a minimal workflow for testing. */ function createTestWorkflow( - loops: Record = {} + loops: Record = {}, + blockDefs: BlockDef[] = [] ) { // Ensure each loop has required fields const normalizedLoops: Record = {} @@ -20,11 +33,21 @@ function createTestWorkflow( id: loop.id ?? key, nodes: loop.nodes, iterations: loop.iterations ?? 1, + ...(loop.loopType && { loopType: loop.loopType }), } } + const blocks = blockDefs.map((b) => ({ + id: b.id, + position: { x: 0, y: 0 }, + config: { tool: 'test', params: {} }, + inputs: {}, + outputs: {}, + metadata: { id: 'function', name: b.name }, + enabled: true, + })) return { version: '1.0', - blocks: [], + blocks, connections: [], loops: normalizedLoops, parallels: {}, @@ -49,13 +72,16 @@ function createLoopScope(overrides: Partial = {}): LoopScope { function createTestContext( currentNodeId: string, loopScope?: LoopScope, - loopExecutions?: Map + loopExecutions?: Map, + blockOutputs?: Record ): ResolutionContext { return { executionContext: { loopExecutions: loopExecutions ?? new Map(), }, - executionState: {}, + executionState: { + getBlockOutput: (id: string) => blockOutputs?.[id], + }, currentNodeId, loopScope, } as ResolutionContext @@ -304,4 +330,134 @@ describe('LoopResolver', () => { expect(resolver.resolve('', ctx)).toBe(2) }) }) + + describe('named loop references', () => { + it.concurrent('should resolve named loop by block name', () => { + const workflow = createTestWorkflow( + { 'loop-1': { nodes: ['block-1'] } }, + [{ id: 'loop-1', name: 'Loop 1' }] + ) + const resolver = new LoopResolver(workflow) + expect(resolver.canResolve('')).toBe(true) + }) + + it.concurrent('should resolve index via named reference for block inside the loop', () => { + const workflow = createTestWorkflow( + { 'loop-1': { nodes: ['block-1'] } }, + [{ id: 'loop-1', name: 'Loop 1' }] + ) + const resolver = new LoopResolver(workflow) + const loopScope = createLoopScope({ iteration: 3 }) + const loopExecutions = new Map([['loop-1', loopScope]]) + const ctx = createTestContext('block-1', undefined, loopExecutions) + + expect(resolver.resolve('', ctx)).toBe(3) + }) + + it.concurrent('should resolve index for block in a nested descendant loop', () => { + const workflow = createTestWorkflow( + { + 'loop-outer': { nodes: ['loop-inner', 'block-a'] }, + 'loop-inner': { nodes: ['block-b'] }, + }, + [ + { id: 'loop-outer', name: 'Loop 1' }, + { id: 'loop-inner', name: 'Loop 2' }, + ] + ) + const resolver = new LoopResolver(workflow) + const outerScope = createLoopScope({ iteration: 2 }) + const innerScope = createLoopScope({ iteration: 4 }) + const loopExecutions = new Map([ + ['loop-outer', outerScope], + ['loop-inner', innerScope], + ]) + const ctx = createTestContext('block-b', undefined, loopExecutions) + + expect(resolver.resolve('', ctx)).toBe(2) + expect(resolver.resolve('', ctx)).toBe(4) + expect(resolver.resolve('', ctx)).toBe(4) + }) + + it.concurrent('should return undefined for index when block is outside the loop', () => { + const workflow = createTestWorkflow( + { 'loop-1': { nodes: ['block-1'] } }, + [{ id: 'loop-1', name: 'Loop 1' }] + ) + const resolver = new LoopResolver(workflow) + const loopScope = createLoopScope({ iteration: 3 }) + const loopExecutions = new Map([['loop-1', loopScope]]) + const ctx = createTestContext('block-outside', undefined, loopExecutions) + + expect(resolver.resolve('', ctx)).toBeUndefined() + }) + + it.concurrent('should resolve result from anywhere after loop completes', () => { + const workflow = createTestWorkflow( + { 'loop-1': { nodes: ['block-1'] } }, + [{ id: 'loop-1', name: 'Loop 1' }] + ) + const resolver = new LoopResolver(workflow) + const results = [[{ response: 'a' }], [{ response: 'b' }]] + const ctx = createTestContext('block-outside', undefined, new Map(), { + 'loop-1': { results }, + }) + + expect(resolver.resolve('', ctx)).toEqual(results) + expect(resolver.resolve('', ctx)).toEqual(results) + }) + + it.concurrent('should resolve result with nested path', () => { + const workflow = createTestWorkflow( + { 'loop-1': { nodes: ['block-1'] } }, + [{ id: 'loop-1', name: 'Loop 1' }] + ) + const resolver = new LoopResolver(workflow) + const results = [[{ response: 'a' }], [{ response: 'b' }]] + const ctx = createTestContext('block-outside', undefined, new Map(), { + 'loop-1': { results }, + }) + + expect(resolver.resolve('', ctx)).toEqual([{ response: 'a' }]) + expect(resolver.resolve('', ctx)).toBe('b') + }) + + it.concurrent('should resolve forEach properties via named reference', () => { + const workflow = createTestWorkflow( + { 'loop-1': { nodes: ['block-1'], loopType: 'forEach' } }, + [{ id: 'loop-1', name: 'Loop 1' }] + ) + const resolver = new LoopResolver(workflow) + const items = ['x', 'y', 'z'] + const loopScope = createLoopScope({ iteration: 1, item: 'y', items }) + const loopExecutions = new Map([['loop-1', loopScope]]) + const ctx = createTestContext('block-1', undefined, loopExecutions) + + expect(resolver.resolve('', ctx)).toBe(1) + expect(resolver.resolve('', ctx)).toBe('y') + expect(resolver.resolve('', ctx)).toEqual(items) + }) + + it.concurrent('should throw InvalidFieldError for unknown property on named ref', () => { + const workflow = createTestWorkflow( + { 'loop-1': { nodes: ['block-1'] } }, + [{ id: 'loop-1', name: 'Loop 1' }] + ) + const resolver = new LoopResolver(workflow) + const loopScope = createLoopScope({ iteration: 0 }) + const loopExecutions = new Map([['loop-1', loopScope]]) + const ctx = createTestContext('block-1', undefined, loopExecutions) + + expect(() => resolver.resolve('', ctx)).toThrow(InvalidFieldError) + }) + + it.concurrent('should not resolve named ref when no matching block exists', () => { + const workflow = createTestWorkflow( + { 'loop-1': { nodes: ['block-1'] } }, + [{ id: 'loop-1', name: 'Loop 1' }] + ) + const resolver = new LoopResolver(workflow) + expect(resolver.canResolve('')).toBe(false) + }) + }) }) diff --git a/apps/sim/executor/variables/resolvers/loop.ts b/apps/sim/executor/variables/resolvers/loop.ts index 3abaec58c3..51d24c862f 100644 --- a/apps/sim/executor/variables/resolvers/loop.ts +++ b/apps/sim/executor/variables/resolvers/loop.ts @@ -1,5 +1,5 @@ import { createLogger } from '@sim/logger' -import { isReference, parseReferencePath, REFERENCE } from '@/executor/constants' +import { isReference, normalizeName, parseReferencePath, REFERENCE } from '@/executor/constants' import { InvalidFieldError } from '@/executor/utils/block-reference' import { extractBaseBlockId } from '@/executor/utils/subflow-utils' import { @@ -12,9 +12,23 @@ import type { SerializedWorkflow } from '@/serializer/types' const logger = createLogger('LoopResolver') export class LoopResolver implements Resolver { - constructor(private workflow: SerializedWorkflow) {} + private loopNameToId: Map - private static KNOWN_PROPERTIES = ['iteration', 'index', 'item', 'currentItem', 'items'] + constructor(private workflow: SerializedWorkflow) { + this.loopNameToId = new Map() + for (const block of workflow.blocks) { + if (workflow.loops[block.id] && block.metadata?.name) { + this.loopNameToId.set(normalizeName(block.metadata.name), block.id) + } + } + } + + private static RUNTIME_PROPERTIES = ['iteration', 'index', 'item', 'currentItem', 'items'] + private static OUTPUT_PROPERTIES = ['result', 'results'] + private static KNOWN_PROPERTIES = [ + ...LoopResolver.RUNTIME_PROPERTIES, + ...LoopResolver.OUTPUT_PROPERTIES, + ] canResolve(reference: string): boolean { if (!isReference(reference)) { @@ -25,7 +39,7 @@ export class LoopResolver implements Resolver { return false } const [type] = parts - return type === REFERENCE.PREFIX.LOOP + return type === REFERENCE.PREFIX.LOOP || this.loopNameToId.has(type) } resolve(reference: string, context: ResolutionContext): any { @@ -35,14 +49,55 @@ export class LoopResolver implements Resolver { return undefined } - const loopId = this.findLoopForBlock(context.currentNodeId) - let loopScope = context.loopScope + const [firstPart, ...rest] = parts + const isGenericRef = firstPart === REFERENCE.PREFIX.LOOP - if (!loopScope) { - if (!loopId) { + let targetLoopId: string | undefined + + if (isGenericRef) { + targetLoopId = this.findInnermostLoopForBlock(context.currentNodeId) + if (!targetLoopId && !context.loopScope) { return undefined } - loopScope = context.executionContext.loopExecutions?.get(loopId) + } else { + targetLoopId = this.loopNameToId.get(firstPart) + if (!targetLoopId) { + return undefined + } + } + + if (rest.length > 0) { + const property = rest[0] + + if (LoopResolver.OUTPUT_PROPERTIES.includes(property)) { + return this.resolveOutput(targetLoopId!, rest.slice(1), context) + } + + if (!LoopResolver.KNOWN_PROPERTIES.includes(property)) { + const isForEach = targetLoopId + ? this.isForEachLoop(targetLoopId) + : context.loopScope?.items !== undefined + const availableFields = isForEach + ? ['index', 'currentItem', 'items', 'result'] + : ['index', 'result'] + throw new InvalidFieldError(firstPart, property, availableFields) + } + + if (!isGenericRef && targetLoopId) { + if (!this.isBlockInLoopOrDescendant(context.currentNodeId, targetLoopId)) { + logger.warn('Block is not inside the referenced loop', { + reference, + blockId: context.currentNodeId, + loopId: targetLoopId, + }) + return undefined + } + } + } + + let loopScope = isGenericRef ? context.loopScope : undefined + if (!loopScope && targetLoopId) { + loopScope = context.executionContext.loopExecutions?.get(targetLoopId) } if (!loopScope) { @@ -50,26 +105,20 @@ export class LoopResolver implements Resolver { return undefined } - const isForEach = loopId ? this.isForEachLoop(loopId) : loopScope.items !== undefined - - if (parts.length === 1) { - const result: Record = { + if (rest.length === 0) { + const obj: Record = { index: loopScope.iteration, } if (loopScope.item !== undefined) { - result.currentItem = loopScope.item + obj.currentItem = loopScope.item } if (loopScope.items !== undefined) { - result.items = loopScope.items + obj.items = loopScope.items } - return result + return obj } - const [_, property, ...pathParts] = parts - if (!LoopResolver.KNOWN_PROPERTIES.includes(property)) { - const availableFields = isForEach ? ['index', 'currentItem', 'items'] : ['index'] - throw new InvalidFieldError('loop', property, availableFields) - } + const [property, ...pathParts] = rest let value: any switch (property) { @@ -93,7 +142,23 @@ export class LoopResolver implements Resolver { return value } - private findLoopForBlock(blockId: string): string | undefined { + private resolveOutput( + loopId: string, + pathParts: string[], + context: ResolutionContext + ): any { + const output = context.executionState.getBlockOutput(loopId) + if (!output) { + return undefined + } + const value = (output as Record).results + if (pathParts.length > 0) { + return navigatePath(value, pathParts) + } + return value + } + + private findInnermostLoopForBlock(blockId: string): string | undefined { const baseId = extractBaseBlockId(blockId) for (const loopId of Object.keys(this.workflow.loops || {})) { const loopConfig = this.workflow.loops[loopId] @@ -101,10 +166,43 @@ export class LoopResolver implements Resolver { return loopId } } - return undefined } + private isBlockInLoopOrDescendant(blockId: string, targetLoopId: string): boolean { + const baseId = extractBaseBlockId(blockId) + const targetLoop = this.workflow.loops?.[targetLoopId] + if (!targetLoop) { + return false + } + if (targetLoop.nodes.includes(baseId)) { + return true + } + const directLoopId = this.findInnermostLoopForBlock(blockId) + if (!directLoopId || directLoopId === targetLoopId) { + return false + } + return this.isLoopNestedInside(directLoopId, targetLoopId) + } + + private isLoopNestedInside(childLoopId: string, ancestorLoopId: string): boolean { + const ancestorLoop = this.workflow.loops?.[ancestorLoopId] + if (!ancestorLoop) { + return false + } + if (ancestorLoop.nodes.includes(childLoopId)) { + return true + } + for (const nodeId of ancestorLoop.nodes) { + if (this.workflow.loops[nodeId]) { + if (this.isLoopNestedInside(childLoopId, nodeId)) { + return true + } + } + } + return false + } + private isForEachLoop(loopId: string): boolean { const loopConfig = this.workflow.loops?.[loopId] return loopConfig?.loopType === 'forEach' From acfd6d6ed95ba13ff5dc01daa074899172c2d6c0 Mon Sep 17 00:00:00 2001 From: Vasyl Abramovych Date: Sat, 28 Feb 2026 19:10:05 -0800 Subject: [PATCH 3/4] feat(terminal): propagate parent iteration context through SSE events and terminal display Thread parentIterations through SSE block-started, block-completed, and block-error events so the terminal can reconstruct nested loop hierarchies. Update the entry tree builder to recursively nest inner loop subflow nodes inside their parent iteration rows, using parentIterations depth-stripping to support arbitrary nesting depth. Display the block's store name for subflow container rows instead of the generic "Loop" / "Parallel" label. Made-with: Cursor --- .../app/api/workflows/[id]/execute/route.ts | 9 ++ .../components/terminal/terminal.tsx | 12 +-- .../[workflowId]/components/terminal/utils.ts | 98 ++++++++++--------- .../hooks/use-workflow-execution.ts | 5 + .../workflows/executor/execution-events.ts | 15 ++- apps/sim/stores/terminal/console/store.ts | 4 + apps/sim/stores/terminal/console/types.ts | 3 + 7 files changed, 95 insertions(+), 51 deletions(-) diff --git a/apps/sim/app/api/workflows/[id]/execute/route.ts b/apps/sim/app/api/workflows/[id]/execute/route.ts index b393ae492a..0de58b355d 100644 --- a/apps/sim/app/api/workflows/[id]/execute/route.ts +++ b/apps/sim/app/api/workflows/[id]/execute/route.ts @@ -825,6 +825,9 @@ export async function POST(req: NextRequest, { params }: { params: Promise<{ id: iterationTotal: iterationContext.iterationTotal, iterationType: iterationContext.iterationType, iterationContainerId: iterationContext.iterationContainerId, + ...(iterationContext.parentIterations?.length && { + parentIterations: iterationContext.parentIterations, + }), }), ...(childWorkflowContext && { childWorkflowBlockId: childWorkflowContext.parentBlockId, @@ -881,6 +884,9 @@ export async function POST(req: NextRequest, { params }: { params: Promise<{ id: iterationTotal: iterationContext.iterationTotal, iterationType: iterationContext.iterationType, iterationContainerId: iterationContext.iterationContainerId, + ...(iterationContext.parentIterations?.length && { + parentIterations: iterationContext.parentIterations, + }), }), ...childWorkflowData, ...instanceData, @@ -912,6 +918,9 @@ export async function POST(req: NextRequest, { params }: { params: Promise<{ id: iterationTotal: iterationContext.iterationTotal, iterationType: iterationContext.iterationType, iterationContainerId: iterationContext.iterationContainerId, + ...(iterationContext.parentIterations?.length && { + parentIterations: iterationContext.parentIterations, + }), }), ...childWorkflowData, ...instanceData, diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/terminal/terminal.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/terminal/terminal.tsx index 204ca166c8..5aa1e6aa23 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/terminal/terminal.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/terminal/terminal.tsx @@ -60,6 +60,7 @@ import { openCopilotWithMessage } from '@/stores/notifications/utils' import type { ConsoleEntry } from '@/stores/terminal' import { useTerminalConsoleStore, useTerminalStore } from '@/stores/terminal' import { useWorkflowRegistry } from '@/stores/workflows/registry/store' +import { useWorkflowStore } from '@/stores/workflows/workflow/store' /** * Terminal height configuration constants @@ -279,12 +280,11 @@ const SubflowNodeRow = memo(function SubflowNodeRow({ children.some((c) => c.entry.isCanceled || c.children.some((gc) => gc.entry.isCanceled)) && !hasRunningDescendant - const displayName = - entry.blockType === 'loop' - ? 'Loop' - : entry.blockType === 'parallel' - ? 'Parallel' - : entry.blockName + const containerId = entry.iterationContainerId + const storeBlockName = useWorkflowStore( + (state) => (containerId ? state.blocks[containerId]?.name : undefined) + ) + const displayName = storeBlockName || entry.blockName return (
diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/terminal/utils.ts b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/terminal/utils.ts index a31bf2cc1d..e59c12ab13 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/terminal/utils.ts +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/terminal/utils.ts @@ -221,27 +221,26 @@ function collectWorkflowDescendants( * that executed within each iteration. * Sorts by start time to ensure chronological order. */ -function buildEntryTree(entries: ConsoleEntry[]): EntryNode[] { - // Separate entries into three buckets: - // 1. Iteration entries (loop/parallel children) - // 2. Workflow child entries (blocks inside a child workflow) - // 3. Regular blocks +function buildEntryTree(entries: ConsoleEntry[], idPrefix = ''): EntryNode[] { const regularBlocks: ConsoleEntry[] = [] - const iterationEntries: ConsoleEntry[] = [] + const topLevelIterationEntries: ConsoleEntry[] = [] + const nestedIterationEntries: ConsoleEntry[] = [] const workflowChildEntries: ConsoleEntry[] = [] for (const entry of entries) { if (entry.childWorkflowBlockId) { - // Child workflow entries take priority over iteration classification workflowChildEntries.push(entry) } else if (entry.iterationType && entry.iterationCurrent !== undefined) { - iterationEntries.push(entry) + if (entry.parentIterations && entry.parentIterations.length > 0) { + nestedIterationEntries.push(entry) + } else { + topLevelIterationEntries.push(entry) + } } else { regularBlocks.push(entry) } } - // Group workflow child entries by the parent workflow block ID const workflowChildGroups = new Map() for (const entry of workflowChildEntries) { const parentId = entry.childWorkflowBlockId! @@ -253,9 +252,8 @@ function buildEntryTree(entries: ConsoleEntry[]): EntryNode[] { } } - // Group iteration entries by (iterationType, iterationContainerId, iterationCurrent) const iterationGroupsMap = new Map() - for (const entry of iterationEntries) { + for (const entry of topLevelIterationEntries) { const iterationContainerId = entry.iterationContainerId || 'unknown' const key = `${entry.iterationType}-${iterationContainerId}-${entry.iterationCurrent}` let group = iterationGroupsMap.get(key) @@ -272,11 +270,9 @@ function buildEntryTree(entries: ConsoleEntry[]): EntryNode[] { } iterationGroupsMap.set(key, group) } else { - // Update start time to earliest if (entryStartMs < group.startTimeMs) { group.startTimeMs = entryStartMs } - // Update total if available if (entry.iterationTotal !== undefined) { group.iterationTotal = entry.iterationTotal } @@ -284,12 +280,10 @@ function buildEntryTree(entries: ConsoleEntry[]): EntryNode[] { group.blocks.push(entry) } - // Sort blocks within each iteration by executionOrder ascending (oldest first, top-down) for (const group of iterationGroupsMap.values()) { group.blocks.sort((a, b) => a.executionOrder - b.executionOrder) } - // Group iterations by (iterationType, iterationContainerId) to create subflow parents const subflowGroups = new Map< string, { iterationType: string; iterationContainerId: string; groups: IterationGroup[] } @@ -308,34 +302,37 @@ function buildEntryTree(entries: ConsoleEntry[]): EntryNode[] { subflowGroup.groups.push(group) } - // Sort iterations within each subflow by iteration number for (const subflowGroup of subflowGroups.values()) { subflowGroup.groups.sort((a, b) => a.iterationCurrent - b.iterationCurrent) } - // Build subflow nodes with iteration children const subflowNodes: EntryNode[] = [] for (const subflowGroup of subflowGroups.values()) { const { iterationType, iterationContainerId, groups: iterationGroups } = subflowGroup - // Calculate subflow timing from all its iterations - const firstIteration = iterationGroups[0] - const allBlocks = iterationGroups.flatMap((g) => g.blocks) + + const nestedForThisSubflow = nestedIterationEntries.filter((e) => { + const parent = e.parentIterations?.[0] + return parent && parent.iterationContainerId === iterationContainerId + }) + + const allDirectBlocks = iterationGroups.flatMap((g) => g.blocks) + const allRelevantBlocks = [...allDirectBlocks, ...nestedForThisSubflow] const subflowStartMs = Math.min( - ...allBlocks.map((b) => new Date(b.startedAt || b.timestamp).getTime()) + ...allRelevantBlocks.map((b) => new Date(b.startedAt || b.timestamp).getTime()) ) const subflowEndMs = Math.max( - ...allBlocks.map((b) => new Date(b.endedAt || b.timestamp).getTime()) + ...allRelevantBlocks.map((b) => new Date(b.endedAt || b.timestamp).getTime()) ) - const totalDuration = allBlocks.reduce((sum, b) => sum + (b.durationMs || 0), 0) - // Parallel branches run concurrently — use wall-clock time. Loop iterations run serially — use sum. + const totalDuration = allRelevantBlocks.reduce((sum, b) => sum + (b.durationMs || 0), 0) const subflowDuration = iterationType === 'parallel' ? subflowEndMs - subflowStartMs : totalDuration - // Create synthetic subflow parent entry - // Use the minimum executionOrder from all child blocks for proper ordering - const subflowExecutionOrder = Math.min(...allBlocks.map((b) => b.executionOrder)) + const subflowExecutionOrder = Math.min( + ...allRelevantBlocks.map((b) => b.executionOrder) + ) + const firstIteration = iterationGroups[0] const syntheticSubflow: ConsoleEntry = { - id: `subflow-${iterationType}-${iterationContainerId}-${firstIteration.blocks[0]?.executionId || 'unknown'}`, + id: `${idPrefix}subflow-${iterationType}-${iterationContainerId}-${firstIteration.blocks[0]?.executionId || 'unknown'}`, timestamp: new Date(subflowStartMs).toISOString(), workflowId: firstIteration.blocks[0]?.workflowId || '', blockId: `${iterationType}-container-${iterationContainerId}`, @@ -346,28 +343,37 @@ function buildEntryTree(entries: ConsoleEntry[]): EntryNode[] { executionOrder: subflowExecutionOrder, endedAt: new Date(subflowEndMs).toISOString(), durationMs: subflowDuration, - success: !allBlocks.some((b) => b.error), + success: !allRelevantBlocks.some((b) => b.error), + iterationContainerId, } - // Build iteration child nodes const iterationNodes: EntryNode[] = iterationGroups.map((iterGroup) => { - // Create synthetic iteration entry + const matchingNestedEntries = nestedForThisSubflow.filter((e) => { + const parent = e.parentIterations![0] + return parent.iterationCurrent === iterGroup.iterationCurrent + }) + + const strippedNestedEntries: ConsoleEntry[] = matchingNestedEntries.map((e) => ({ + ...e, + parentIterations: + e.parentIterations!.length > 1 ? e.parentIterations!.slice(1) : undefined, + })) + const iterBlocks = iterGroup.blocks + const allIterEntries = [...iterBlocks, ...strippedNestedEntries] const iterStartMs = Math.min( - ...iterBlocks.map((b) => new Date(b.startedAt || b.timestamp).getTime()) + ...allIterEntries.map((b) => new Date(b.startedAt || b.timestamp).getTime()) ) const iterEndMs = Math.max( - ...iterBlocks.map((b) => new Date(b.endedAt || b.timestamp).getTime()) + ...allIterEntries.map((b) => new Date(b.endedAt || b.timestamp).getTime()) ) - const iterDuration = iterBlocks.reduce((sum, b) => sum + (b.durationMs || 0), 0) - // Parallel branches run concurrently — use wall-clock time. Loop iterations run serially — use sum. + const iterDuration = allIterEntries.reduce((sum, b) => sum + (b.durationMs || 0), 0) const iterDisplayDuration = iterationType === 'parallel' ? iterEndMs - iterStartMs : iterDuration - // Use the minimum executionOrder from blocks in this iteration - const iterExecutionOrder = Math.min(...iterBlocks.map((b) => b.executionOrder)) + const iterExecutionOrder = Math.min(...allIterEntries.map((b) => b.executionOrder)) const syntheticIteration: ConsoleEntry = { - id: `iteration-${iterationType}-${iterGroup.iterationContainerId}-${iterGroup.iterationCurrent}-${iterBlocks[0]?.executionId || 'unknown'}`, + id: `${idPrefix}iteration-${iterationType}-${iterGroup.iterationContainerId}-${iterGroup.iterationCurrent}-${iterBlocks[0]?.executionId || 'unknown'}`, timestamp: new Date(iterStartMs).toISOString(), workflowId: iterBlocks[0]?.workflowId || '', blockId: `iteration-${iterGroup.iterationContainerId}-${iterGroup.iterationCurrent}`, @@ -378,14 +384,13 @@ function buildEntryTree(entries: ConsoleEntry[]): EntryNode[] { executionOrder: iterExecutionOrder, endedAt: new Date(iterEndMs).toISOString(), durationMs: iterDisplayDuration, - success: !iterBlocks.some((b) => b.error), + success: !allIterEntries.some((b) => b.error), iterationCurrent: iterGroup.iterationCurrent, iterationTotal: iterGroup.iterationTotal, iterationType: iterationType as 'loop' | 'parallel', iterationContainerId: iterGroup.iterationContainerId, } - // Block nodes within this iteration — workflow blocks get their full subtree const blockNodes: EntryNode[] = iterBlocks.map((block) => { if (isWorkflowBlockType(block.blockType)) { const instanceKey = block.childWorkflowInstanceId ?? block.blockId @@ -404,9 +409,17 @@ function buildEntryTree(entries: ConsoleEntry[]): EntryNode[] { return { entry: block, children: [], nodeType: 'block' as const } }) + const childPrefix = `${idPrefix}${iterationContainerId}-${iterGroup.iterationCurrent}-` + const nestedSubflowNodes = strippedNestedEntries.length > 0 + ? buildEntryTree(strippedNestedEntries, childPrefix) + : [] + + const allChildren = [...blockNodes, ...nestedSubflowNodes] + allChildren.sort((a, b) => a.entry.executionOrder - b.entry.executionOrder) + return { entry: syntheticIteration, - children: blockNodes, + children: allChildren, nodeType: 'iteration' as const, iterationInfo: { current: iterGroup.iterationCurrent, @@ -422,7 +435,6 @@ function buildEntryTree(entries: ConsoleEntry[]): EntryNode[] { }) } - // Build workflow nodes for regular blocks that are workflow block types const workflowNodes: EntryNode[] = [] const remainingRegularBlocks: ConsoleEntry[] = [] @@ -442,14 +454,12 @@ function buildEntryTree(entries: ConsoleEntry[]): EntryNode[] { } } - // Build nodes for remaining regular blocks const regularNodes: EntryNode[] = remainingRegularBlocks.map((entry) => ({ entry, children: [], nodeType: 'block' as const, })) - // Combine all nodes and sort by executionOrder ascending (oldest first, top-down) const allNodes = [...subflowNodes, ...workflowNodes, ...regularNodes] allNodes.sort((a, b) => a.entry.executionOrder - b.entry.executionOrder) return allNodes diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-workflow-execution.ts b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-workflow-execution.ts index 1af79967c8..73a1ce2a74 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-workflow-execution.ts +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-workflow-execution.ts @@ -383,6 +383,7 @@ export function useWorkflowExecution() { iterationTotal: data.iterationTotal, iterationType: data.iterationType, iterationContainerId: data.iterationContainerId, + parentIterations: data.parentIterations, childWorkflowBlockId: data.childWorkflowBlockId, childWorkflowName: data.childWorkflowName, childWorkflowInstanceId: data.childWorkflowInstanceId, @@ -409,6 +410,7 @@ export function useWorkflowExecution() { iterationTotal: data.iterationTotal, iterationType: data.iterationType, iterationContainerId: data.iterationContainerId, + parentIterations: data.parentIterations, childWorkflowBlockId: data.childWorkflowBlockId, childWorkflowName: data.childWorkflowName, childWorkflowInstanceId: data.childWorkflowInstanceId, @@ -431,6 +433,7 @@ export function useWorkflowExecution() { iterationTotal: data.iterationTotal, iterationType: data.iterationType, iterationContainerId: data.iterationContainerId, + parentIterations: data.parentIterations, childWorkflowBlockId: data.childWorkflowBlockId, childWorkflowName: data.childWorkflowName, childWorkflowInstanceId: data.childWorkflowInstanceId, @@ -456,6 +459,7 @@ export function useWorkflowExecution() { iterationTotal: data.iterationTotal, iterationType: data.iterationType, iterationContainerId: data.iterationContainerId, + parentIterations: data.parentIterations, childWorkflowBlockId: data.childWorkflowBlockId, childWorkflowName: data.childWorkflowName, childWorkflowInstanceId: data.childWorkflowInstanceId, @@ -490,6 +494,7 @@ export function useWorkflowExecution() { iterationTotal: data.iterationTotal, iterationType: data.iterationType, iterationContainerId: data.iterationContainerId, + parentIterations: data.parentIterations, childWorkflowBlockId: data.childWorkflowBlockId, childWorkflowName: data.childWorkflowName, }) diff --git a/apps/sim/lib/workflows/executor/execution-events.ts b/apps/sim/lib/workflows/executor/execution-events.ts index a9324d1087..2a2c06d401 100644 --- a/apps/sim/lib/workflows/executor/execution-events.ts +++ b/apps/sim/lib/workflows/executor/execution-events.ts @@ -1,4 +1,8 @@ -import type { ChildWorkflowContext, IterationContext } from '@/executor/execution/types' +import type { + ChildWorkflowContext, + IterationContext, + ParentIteration, +} from '@/executor/execution/types' import type { SubflowType } from '@/stores/workflows/workflow/types' export type ExecutionEventType = @@ -83,6 +87,7 @@ export interface BlockStartedEvent extends BaseExecutionEvent { iterationTotal?: number iterationType?: SubflowType iterationContainerId?: string + parentIterations?: ParentIteration[] childWorkflowBlockId?: string childWorkflowName?: string } @@ -108,6 +113,7 @@ export interface BlockCompletedEvent extends BaseExecutionEvent { iterationTotal?: number iterationType?: SubflowType iterationContainerId?: string + parentIterations?: ParentIteration[] childWorkflowBlockId?: string childWorkflowName?: string /** Per-invocation unique ID for correlating child block events with this workflow block. */ @@ -135,6 +141,7 @@ export interface BlockErrorEvent extends BaseExecutionEvent { iterationTotal?: number iterationType?: SubflowType iterationContainerId?: string + parentIterations?: ParentIteration[] childWorkflowBlockId?: string childWorkflowName?: string /** Per-invocation unique ID for correlating child block events with this workflow block. */ @@ -271,6 +278,9 @@ export function createSSECallbacks(options: SSECallbackOptions) { iterationTotal: iterationContext.iterationTotal, iterationType: iterationContext.iterationType, iterationContainerId: iterationContext.iterationContainerId, + ...(iterationContext.parentIterations?.length && { + parentIterations: iterationContext.parentIterations, + }), }), ...(childWorkflowContext && { childWorkflowBlockId: childWorkflowContext.parentBlockId, @@ -303,6 +313,9 @@ export function createSSECallbacks(options: SSECallbackOptions) { iterationTotal: iterationContext.iterationTotal, iterationType: iterationContext.iterationType, iterationContainerId: iterationContext.iterationContainerId, + ...(iterationContext.parentIterations?.length && { + parentIterations: iterationContext.parentIterations, + }), } : {} const childWorkflowData = childWorkflowContext diff --git a/apps/sim/stores/terminal/console/store.ts b/apps/sim/stores/terminal/console/store.ts index 3468e4d212..7479ca0d6c 100644 --- a/apps/sim/stores/terminal/console/store.ts +++ b/apps/sim/stores/terminal/console/store.ts @@ -427,6 +427,10 @@ export const useTerminalConsoleStore = create()( updatedEntry.iterationContainerId = update.iterationContainerId } + if (update.parentIterations !== undefined) { + updatedEntry.parentIterations = update.parentIterations + } + if (update.childWorkflowBlockId !== undefined) { updatedEntry.childWorkflowBlockId = update.childWorkflowBlockId } diff --git a/apps/sim/stores/terminal/console/types.ts b/apps/sim/stores/terminal/console/types.ts index 3fcfd6b1dc..80610cc8d8 100644 --- a/apps/sim/stores/terminal/console/types.ts +++ b/apps/sim/stores/terminal/console/types.ts @@ -1,3 +1,4 @@ +import type { ParentIteration } from '@/executor/execution/types' import type { NormalizedBlockOutput } from '@/executor/types' import type { SubflowType } from '@/stores/workflows/workflow/types' @@ -22,6 +23,7 @@ export interface ConsoleEntry { iterationTotal?: number iterationType?: SubflowType iterationContainerId?: string + parentIterations?: ParentIteration[] isRunning?: boolean isCanceled?: boolean /** ID of the workflow block in the parent execution that spawned this child block */ @@ -50,6 +52,7 @@ export interface ConsoleUpdate { iterationTotal?: number iterationType?: SubflowType iterationContainerId?: string + parentIterations?: ParentIteration[] childWorkflowBlockId?: string childWorkflowName?: string childWorkflowInstanceId?: string From c140523241e5d8cfa182790ec0fa871df3037c50 Mon Sep 17 00:00:00 2001 From: Vasyl Abramovych Date: Sat, 28 Feb 2026 19:10:25 -0800 Subject: [PATCH 4/4] feat(canvas): allow nesting subflow containers and prevent cycles Remove the restriction that prevented subflow nodes from being dragged into other subflow containers, enabling loop-in-loop nesting on the canvas. Add cycle detection (isDescendantOf) to prevent a container from being placed inside one of its own descendants. Resize all ancestor containers when a nested child moves, collect descendant blocks when removing from a subflow so boundary edges are attributed correctly, and surface all ancestor loop tags in the tag dropdown for blocks inside nested loops. Made-with: Cursor --- .../components/tag-dropdown/tag-dropdown.tsx | 98 ++++---- .../panel/components/toolbar/toolbar.tsx | 9 +- .../[workflowId]/hooks/use-node-utilities.ts | 28 +++ .../utils/workflow-canvas-helpers.ts | 33 +++ .../[workspaceId]/w/[workflowId]/workflow.tsx | 231 ++++++++++++------ 5 files changed, 265 insertions(+), 134 deletions(-) diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/tag-dropdown/tag-dropdown.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/tag-dropdown/tag-dropdown.tsx index 0c1dbc951f..00425fa7b2 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/tag-dropdown/tag-dropdown.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/tag-dropdown/tag-dropdown.tsx @@ -1167,21 +1167,44 @@ export const TagDropdown: React.FC = ({ {} as Record ) - let loopBlockGroup: BlockTagGroup | null = null + const loopBlockGroups: BlockTagGroup[] = [] + const ancestorLoopIds = new Set() + + const findAncestorLoops = (targetId: string) => { + for (const [loopId, loop] of Object.entries(loops)) { + if (loop.nodes.includes(targetId) && !ancestorLoopIds.has(loopId)) { + ancestorLoopIds.add(loopId) + const loopBlock = blocks[loopId] + if (loopBlock) { + const loopType = loop.loopType || 'for' + const loopBlockName = loopBlock.name || loopBlock.type + const normalizedLoopName = normalizeName(loopBlockName) + const contextualTags: string[] = [`${normalizedLoopName}.index`] + if (loopType === 'forEach') { + contextualTags.push(`${normalizedLoopName}.currentItem`) + contextualTags.push(`${normalizedLoopName}.items`) + } + loopBlockGroups.push({ + blockName: loopBlockName, + blockId: loopId, + blockType: 'loop', + tags: contextualTags, + distance: 0, + isContextual: true, + }) + } + findAncestorLoops(loopId) + } + } + } const isLoopBlock = blocks[blockId]?.type === 'loop' - const currentLoop = isLoopBlock ? loops[blockId] : null - - const containingLoop = Object.entries(loops).find(([_, loop]) => loop.nodes.includes(blockId)) - - let containingLoopBlockId: string | null = null - - if (currentLoop && isLoopBlock) { - containingLoopBlockId = blockId - const loopType = currentLoop.loopType || 'for' - + if (isLoopBlock && loops[blockId]) { + const loop = loops[blockId] + ancestorLoopIds.add(blockId) const loopBlock = blocks[blockId] if (loopBlock) { + const loopType = loop.loopType || 'for' const loopBlockName = loopBlock.name || loopBlock.type const normalizedLoopName = normalizeName(loopBlockName) const contextualTags: string[] = [`${normalizedLoopName}.index`] @@ -1189,40 +1212,18 @@ export const TagDropdown: React.FC = ({ contextualTags.push(`${normalizedLoopName}.currentItem`) contextualTags.push(`${normalizedLoopName}.items`) } - - loopBlockGroup = { + loopBlockGroups.push({ blockName: loopBlockName, blockId: blockId, blockType: 'loop', tags: contextualTags, distance: 0, isContextual: true, - } - } - } else if (containingLoop) { - const [loopId, loop] = containingLoop - containingLoopBlockId = loopId - const loopType = loop.loopType || 'for' - - const containingLoopBlock = blocks[loopId] - if (containingLoopBlock) { - const loopBlockName = containingLoopBlock.name || containingLoopBlock.type - const normalizedLoopName = normalizeName(loopBlockName) - const contextualTags: string[] = [`${normalizedLoopName}.index`] - if (loopType === 'forEach') { - contextualTags.push(`${normalizedLoopName}.currentItem`) - contextualTags.push(`${normalizedLoopName}.items`) - } - - loopBlockGroup = { - blockName: loopBlockName, - blockId: loopId, - blockType: 'loop', - tags: contextualTags, - distance: 0, - isContextual: true, - } + }) } + findAncestorLoops(blockId) + } else { + findAncestorLoops(blockId) } let parallelBlockGroup: BlockTagGroup | null = null @@ -1275,7 +1276,7 @@ export const TagDropdown: React.FC = ({ if (!blockConfig) { if (accessibleBlock.type === 'loop' || accessibleBlock.type === 'parallel') { if ( - accessibleBlockId === containingLoopBlockId || + ancestorLoopIds.has(accessibleBlockId) || accessibleBlockId === containingParallelBlockId ) { continue @@ -1366,9 +1367,7 @@ export const TagDropdown: React.FC = ({ } const finalBlockTagGroups: BlockTagGroup[] = [] - if (loopBlockGroup) { - finalBlockTagGroups.push(loopBlockGroup) - } + finalBlockTagGroups.push(...loopBlockGroups) if (parallelBlockGroup) { finalBlockTagGroups.push(parallelBlockGroup) } @@ -1570,21 +1569,6 @@ export const TagDropdown: React.FC = ({ if (variableObj) { processedTag = tag } - } else if ( - blockGroup?.isContextual && - (blockGroup.blockType === 'loop' || blockGroup.blockType === 'parallel') - ) { - const tagParts = tag.split('.') - if (tagParts.length === 1) { - processedTag = blockGroup.blockType - } else { - const lastPart = tagParts[tagParts.length - 1] - if (['index', 'currentItem', 'items'].includes(lastPart)) { - processedTag = `${blockGroup.blockType}.${lastPart}` - } else { - processedTag = tag - } - } } let newValue: string diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/toolbar/toolbar.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/toolbar/toolbar.tsx index 365379854a..49ad27ad7f 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/toolbar/toolbar.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/toolbar/toolbar.tsx @@ -67,9 +67,6 @@ const ToolbarItem = memo(function ToolbarItem({ const handleDragStart = useCallback( (e: React.DragEvent) => { - if (!isTrigger && (item.type === 'loop' || item.type === 'parallel')) { - document.body.classList.add('sim-drag-subflow') - } const iconElement = e.currentTarget.querySelector('.toolbar-item-icon') onDragStart(e, item.type, isTriggerCapable, { name: item.name, @@ -80,11 +77,7 @@ const ToolbarItem = memo(function ToolbarItem({ [item.type, item.name, item.bgColor, isTriggerCapable, onDragStart, isTrigger] ) - const handleDragEnd = useCallback(() => { - if (!isTrigger) { - document.body.classList.remove('sim-drag-subflow') - } - }, [isTrigger]) + const handleDragEnd = useCallback(() => {}, []) const handleClick = useCallback(() => { onClick(item.type, isTriggerCapable) diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-node-utilities.ts b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-node-utilities.ts index 06329a6b71..d1a0f7a033 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-node-utilities.ts +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-node-utilities.ts @@ -91,6 +91,33 @@ export function useNodeUtilities(blocks: Record) { [getNodes, blocks] ) + /** + * Returns true if nodeId is in the subtree of ancestorId (i.e. walking from nodeId + * up the parentId chain we reach ancestorId). Used to reject parent assignments that + * would create a cycle (e.g. setting dragged node's parent to a container inside it). + * + * @param ancestorId - Node that might be an ancestor + * @param nodeId - Node to walk from (upward) + * @returns True if ancestorId appears in the parent chain of nodeId + */ + const isDescendantOf = useCallback( + (ancestorId: string, nodeId: string): boolean => { + const visited = new Set() + const maxDepth = 100 + let currentId: string | undefined = nodeId + let depth = 0 + while (currentId && depth < maxDepth) { + if (currentId === ancestorId) return true + if (visited.has(currentId)) return false + visited.add(currentId) + currentId = blocks?.[currentId]?.data?.parentId + depth += 1 + } + return false + }, + [blocks] + ) + /** * Gets the absolute position of a node (accounting for nested parents). * For nodes inside containers, accounts for header and padding offsets. @@ -379,6 +406,7 @@ export function useNodeUtilities(blocks: Record) { return { getNodeDepth, getNodeHierarchy, + isDescendantOf, getNodeAbsolutePosition, calculateRelativePosition, isPointInLoopNode, diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/utils/workflow-canvas-helpers.ts b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/utils/workflow-canvas-helpers.ts index 7f24907c47..3306fac0ff 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/utils/workflow-canvas-helpers.ts +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/utils/workflow-canvas-helpers.ts @@ -4,6 +4,39 @@ import { TriggerUtils } from '@/lib/workflows/triggers/triggers' import { clampPositionToContainer } from '@/app/workspace/[workspaceId]/w/[workflowId]/utils/node-position-utils' import type { BlockState } from '@/stores/workflows/workflow/types' +/** + * Collects all descendant block IDs for container blocks (loop/parallel) in the given set. + * Used to treat a nested subflow as one unit when computing boundary edges (e.g. remove-from-subflow). + * + * @param blockIds - Root block IDs (e.g. the blocks being removed from subflow) + * @param blocks - All workflow blocks + * @returns IDs of blocks that are descendants of any container in blockIds (excluding the roots) + */ +export function getDescendantBlockIds( + blockIds: string[], + blocks: Record +): string[] { + const current = new Set(blockIds) + const added: string[] = [] + const toProcess = [...blockIds] + + while (toProcess.length > 0) { + const id = toProcess.pop()! + const block = blocks[id] + if (block?.type !== 'loop' && block?.type !== 'parallel') continue + + for (const [bid, b] of Object.entries(blocks)) { + if (b?.data?.parentId === id && !current.has(bid)) { + current.add(bid) + added.push(bid) + toProcess.push(bid) + } + } + } + + return added +} + /** * Checks if the currently focused element is an editable input. * Returns true if the user is typing in an input, textarea, or contenteditable element. diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/workflow.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/workflow.tsx index 02c6175c2f..fe841cc018 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/workflow.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/workflow.tsx @@ -57,6 +57,7 @@ import { estimateBlockDimensions, filterProtectedBlocks, getClampedPositionForNode, + getDescendantBlockIds, getWorkflowLockToggleIds, isBlockProtected, isEdgeProtected, @@ -416,6 +417,7 @@ const WorkflowContent = React.memo(() => { const { getNodeDepth, getNodeAbsolutePosition, + isDescendantOf, calculateRelativePosition, isPointInLoopNode, resizeLoopNodes, @@ -432,7 +434,6 @@ const WorkflowContent = React.memo(() => { const canNodeEnterContainer = useCallback( (node: Node): boolean => { if (node.data?.type === 'starter') return false - if (node.type === 'subflowNode') return false const block = blocks[node.id] return !(block && TriggerUtils.isTriggerBlock(block)) }, @@ -681,10 +682,15 @@ const WorkflowContent = React.memo(() => { if (nodesNeedingUpdate.length === 0) return // Filter out nodes that cannot enter containers (when target is a container) - const validNodes = targetParentId + let validNodes = targetParentId ? nodesNeedingUpdate.filter(canNodeEnterContainer) : nodesNeedingUpdate + // Exclude nodes that would create a cycle (moving a container into one of its descendants) + if (targetParentId) { + validNodes = validNodes.filter((n) => !isDescendantOf(n.id, targetParentId)) + } + if (validNodes.length === 0) return // Find boundary edges (edges that cross the container boundary) @@ -744,6 +750,7 @@ const WorkflowContent = React.memo(() => { blocks, edgesForDisplay, canNodeEnterContainer, + isDescendantOf, calculateRelativePosition, getNodeAbsolutePosition, shiftUpdatesToContainerBounds, @@ -1709,24 +1716,66 @@ const WorkflowContent = React.memo(() => { const baseName = data.type === 'loop' ? 'Loop' : 'Parallel' const name = getUniqueBlockName(baseName, blocks) - const autoConnectEdge = tryCreateAutoConnectEdge(position, id, { - targetParentId: null, - }) + if (containerInfo) { + const rawPosition = { + x: position.x - containerInfo.loopPosition.x, + y: position.y - containerInfo.loopPosition.y, + } - addBlock( - id, - data.type, - name, - position, - { - width: CONTAINER_DIMENSIONS.DEFAULT_WIDTH, - height: CONTAINER_DIMENSIONS.DEFAULT_HEIGHT, - type: 'subflowNode', - }, - undefined, - undefined, - autoConnectEdge - ) + const relativePosition = clampPositionToContainer( + rawPosition, + containerInfo.dimensions, + { width: CONTAINER_DIMENSIONS.DEFAULT_WIDTH, height: CONTAINER_DIMENSIONS.DEFAULT_HEIGHT } + ) + + const existingChildBlocks = Object.values(blocks) + .filter((b) => b.data?.parentId === containerInfo.loopId) + .map((b) => ({ id: b.id, type: b.type, position: b.position })) + + const autoConnectEdge = tryCreateAutoConnectEdge(relativePosition, id, { + targetParentId: containerInfo.loopId, + existingChildBlocks, + containerId: containerInfo.loopId, + }) + + addBlock( + id, + data.type, + name, + relativePosition, + { + width: CONTAINER_DIMENSIONS.DEFAULT_WIDTH, + height: CONTAINER_DIMENSIONS.DEFAULT_HEIGHT, + type: 'subflowNode', + parentId: containerInfo.loopId, + extent: 'parent', + }, + containerInfo.loopId, + 'parent', + autoConnectEdge + ) + + resizeLoopNodesWrapper() + } else { + const autoConnectEdge = tryCreateAutoConnectEdge(position, id, { + targetParentId: null, + }) + + addBlock( + id, + data.type, + name, + position, + { + width: CONTAINER_DIMENSIONS.DEFAULT_WIDTH, + height: CONTAINER_DIMENSIONS.DEFAULT_HEIGHT, + type: 'subflowNode', + }, + undefined, + undefined, + autoConnectEdge + ) + } return } @@ -2113,11 +2162,10 @@ const WorkflowContent = React.memo(() => { // Check if hovering over a container node const containerInfo = isPointInLoopNode(position) - // Highlight container if hovering over it and not dragging a subflow - // Subflow drag is marked by body class flag set by toolbar - const isSubflowDrag = document.body.classList.contains('sim-drag-subflow') + // Highlight container if hovering over it + - if (containerInfo && !isSubflowDrag) { + if (containerInfo) { const containerNode = getNodes().find((n) => n.id === containerInfo.loopId) if (containerNode?.type === 'subflowNode') { const kind = (containerNode.data as SubflowNodeData)?.kind @@ -2316,6 +2364,7 @@ const WorkflowContent = React.memo(() => { extent: block.data?.extent || undefined, dragHandle: '.workflow-drag-handle', draggable: !isBlockProtected(block.id, blocks), + className: block.data?.parentId ? 'nested-subflow-node' : undefined, data: { ...block.data, name: block.name, @@ -2476,15 +2525,32 @@ const WorkflowContent = React.memo(() => { }) if (validBlockIds.length === 0) return - const movingNodeIds = new Set(validBlockIds) + const validBlockIdSet = new Set(validBlockIds) + const descendantIds = getDescendantBlockIds(validBlockIds, blocks) + const movingNodeIds = new Set([...validBlockIds, ...descendantIds]) - // Find boundary edges (edges that cross the subflow boundary) + // Find boundary edges (one end inside the subtree, one end outside) const boundaryEdges = edgesForDisplay.filter((e) => { const sourceInSelection = movingNodeIds.has(e.source) const targetInSelection = movingNodeIds.has(e.target) return sourceInSelection !== targetInSelection }) - const boundaryEdgesByNode = mapEdgesByNode(boundaryEdges, movingNodeIds) + + // Attribute each boundary edge to the validBlockId that is the ancestor of the moved endpoint + const boundaryEdgesByNode = new Map() + for (const edge of boundaryEdges) { + const movedEnd = movingNodeIds.has(edge.source) ? edge.source : edge.target + let id: string | undefined = movedEnd + while (id) { + if (validBlockIdSet.has(id)) { + const list = boundaryEdgesByNode.get(id) ?? [] + list.push(edge) + boundaryEdgesByNode.set(id, list) + break + } + id = blocks[id]?.data?.parentId + } + } // Collect absolute positions BEFORE any mutations const absolutePositions = new Map() @@ -2546,42 +2612,56 @@ const WorkflowContent = React.memo(() => { /** * Updates container dimensions in displayNodes during drag or keyboard movement. + * Resizes the moved node's immediate parent and all ancestor containers (for nested loops/parallels). */ const updateContainerDimensionsDuringMove = useCallback( (movedNodeId: string, movedNodePosition: { x: number; y: number }) => { - const parentId = blocks[movedNodeId]?.data?.parentId - if (!parentId) return + const ancestorIds: string[] = [] + let currentId = blocks[movedNodeId]?.data?.parentId + while (currentId) { + ancestorIds.push(currentId) + currentId = blocks[currentId]?.data?.parentId + } + if (ancestorIds.length === 0) return setDisplayNodes((currentNodes) => { - const childNodes = currentNodes.filter((n) => n.parentId === parentId) - if (childNodes.length === 0) return currentNodes - - const childPositions = childNodes.map((node) => { - const nodePosition = node.id === movedNodeId ? movedNodePosition : node.position - const { width, height } = getBlockDimensions(node.id) - return { x: nodePosition.x, y: nodePosition.y, width, height } - }) + const computedDimensions = new Map() + + for (const containerId of ancestorIds) { + const childNodes = currentNodes.filter((n) => n.parentId === containerId) + if (childNodes.length === 0) continue + + const childPositions = childNodes.map((node) => { + const nodePosition = + node.id === movedNodeId ? movedNodePosition : node.position + const dims = computedDimensions.get(node.id) + const width = dims?.width ?? node.data?.width ?? getBlockDimensions(node.id).width + const height = dims?.height ?? node.data?.height ?? getBlockDimensions(node.id).height + return { x: nodePosition.x, y: nodePosition.y, width, height } + }) - const { width: newWidth, height: newHeight } = calculateContainerDimensions(childPositions) + computedDimensions.set( + containerId, + calculateContainerDimensions(childPositions) + ) + } return currentNodes.map((node) => { - if (node.id === parentId) { - const currentWidth = node.data?.width || CONTAINER_DIMENSIONS.DEFAULT_WIDTH - const currentHeight = node.data?.height || CONTAINER_DIMENSIONS.DEFAULT_HEIGHT - - // Only update if dimensions changed - if (newWidth !== currentWidth || newHeight !== currentHeight) { - return { - ...node, - data: { - ...node.data, - width: newWidth, - height: newHeight, - }, - } - } + const newDims = computedDimensions.get(node.id) + if (!newDims) return node + const currentWidth = node.data?.width ?? CONTAINER_DIMENSIONS.DEFAULT_WIDTH + const currentHeight = node.data?.height ?? CONTAINER_DIMENSIONS.DEFAULT_HEIGHT + if (newDims.width === currentWidth && newDims.height === currentHeight) { + return node + } + return { + ...node, + data: { + ...node.data, + width: newDims.width, + height: newDims.height, + }, } - return node }) }) }, @@ -2914,16 +2994,6 @@ const WorkflowContent = React.memo(() => { // Get the node's absolute position to properly calculate intersections const nodeAbsolutePos = getNodeAbsolutePosition(node.id) - // Prevent subflows from being dragged into other subflows - if (node.type === 'subflowNode') { - // Clear any highlighting for subflow nodes - if (potentialParentId) { - clearDragHighlights() - setPotentialParentId(null) - } - return // Exit early - subflows cannot be placed inside other subflows - } - // Find intersections with container nodes using absolute coordinates const intersectingNodes = getNodes() .filter((n) => { @@ -2993,15 +3063,25 @@ const WorkflowContent = React.memo(() => { return a.size - b.size // Smaller container takes precedence }) + // Exclude containers that are inside the dragged node (would create a cycle) + const validContainers = sortedContainers.filter( + ({ container }) => !isDescendantOf(node.id, container.id) + ) + // Use the most appropriate container (deepest or smallest at same depth) - const bestContainerMatch = sortedContainers[0] + const bestContainerMatch = validContainers[0] - setPotentialParentId(bestContainerMatch.container.id) + if (bestContainerMatch) { + setPotentialParentId(bestContainerMatch.container.id) - // Add highlight class and change cursor - const kind = (bestContainerMatch.container.data as SubflowNodeData)?.kind - if (kind === 'loop' || kind === 'parallel') { - highlightContainerNode(bestContainerMatch.container.id, kind) + // Add highlight class and change cursor + const kind = (bestContainerMatch.container.data as SubflowNodeData)?.kind + if (kind === 'loop' || kind === 'parallel') { + highlightContainerNode(bestContainerMatch.container.id, kind) + } + } else { + clearDragHighlights() + setPotentialParentId(null) } } else { // Remove highlighting if no longer over a container @@ -3017,6 +3097,7 @@ const WorkflowContent = React.memo(() => { blocks, getNodeAbsolutePosition, getNodeDepth, + isDescendantOf, updateContainerDimensionsDuringMove, highlightContainerNode, ] @@ -3159,6 +3240,17 @@ const WorkflowContent = React.memo(() => { } } + // Prevent placing a container inside one of its own nested containers (would create cycle) + if (potentialParentId && isDescendantOf(node.id, potentialParentId)) { + addNotification({ + level: 'info', + message: 'Cannot place a container inside one of its own nested containers', + workflowId: activeWorkflowId || undefined, + }) + setPotentialParentId(null) + return + } + // Update the node's parent relationship if (potentialParentId) { // Remove existing edges before moving into container @@ -3275,6 +3367,7 @@ const WorkflowContent = React.memo(() => { getNodes, dragStartParentId, potentialParentId, + isDescendantOf, updateNodeParent, updateBlockPosition, collaborativeBatchAddEdges,