From 8b8b88cc01c2a7f04a29e75e45660fffba1897a9 Mon Sep 17 00:00:00 2001 From: waleed Date: Sun, 22 Feb 2026 16:11:52 -0800 Subject: [PATCH 1/9] feat(terminal): expandable child workflow blocks in console --- .../app/api/workflows/[id]/execute/route.ts | 19 ++- .../components/terminal/terminal.tsx | 161 ++++++++++++++++-- .../[workflowId]/components/terminal/utils.ts | 119 ++++++++++++- .../hooks/use-workflow-execution.ts | 10 ++ .../utils/workflow-execution-utils.ts | 4 + apps/sim/executor/constants.ts | 1 + apps/sim/executor/execution/block-executor.ts | 6 +- apps/sim/executor/execution/executor.ts | 1 + apps/sim/executor/execution/types.ts | 26 ++- .../handlers/workflow/workflow-handler.ts | 14 ++ apps/sim/executor/types.ts | 17 +- .../lib/workflows/executor/execution-core.ts | 13 +- .../workflows/executor/execution-events.ts | 39 +++-- apps/sim/stores/terminal/console/store.ts | 8 + apps/sim/stores/terminal/console/types.ts | 6 + 15 files changed, 393 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 b6ed6bd8b3..ad4f0ce900 100644 --- a/apps/sim/app/api/workflows/[id]/execute/route.ts +++ b/apps/sim/app/api/workflows/[id]/execute/route.ts @@ -38,6 +38,7 @@ import { executeWorkflowJob, type WorkflowExecutionPayload } from '@/background/ import { normalizeName } from '@/executor/constants' import { ExecutionSnapshot } from '@/executor/execution/snapshot' import type { + ChildWorkflowContext, ExecutionMetadata, IterationContext, SerializableExecutionState, @@ -742,7 +743,8 @@ export async function POST(req: NextRequest, { params }: { params: Promise<{ id: blockName: string, blockType: string, executionOrder: number, - iterationContext?: IterationContext + iterationContext?: IterationContext, + childWorkflowContext?: ChildWorkflowContext ) => { logger.info(`[${requestId}] 🔷 onBlockStart called:`, { blockId, blockName, blockType }) sendEvent({ @@ -761,6 +763,10 @@ export async function POST(req: NextRequest, { params }: { params: Promise<{ id: iterationType: iterationContext.iterationType, iterationContainerId: iterationContext.iterationContainerId, }), + ...(childWorkflowContext && { + childWorkflowBlockId: childWorkflowContext.parentBlockId, + childWorkflowName: childWorkflowContext.workflowName, + }), }, }) } @@ -770,9 +776,16 @@ export async function POST(req: NextRequest, { params }: { params: Promise<{ id: blockName: string, blockType: string, callbackData: any, - iterationContext?: IterationContext + iterationContext?: IterationContext, + childWorkflowContext?: ChildWorkflowContext ) => { const hasError = callbackData.output?.error + const childWorkflowData = childWorkflowContext + ? { + childWorkflowBlockId: childWorkflowContext.parentBlockId, + childWorkflowName: childWorkflowContext.workflowName, + } + : {} if (hasError) { logger.info(`[${requestId}] ✗ onBlockComplete (error) called:`, { @@ -802,6 +815,7 @@ export async function POST(req: NextRequest, { params }: { params: Promise<{ id: iterationType: iterationContext.iterationType, iterationContainerId: iterationContext.iterationContainerId, }), + ...childWorkflowData, }, }) } else { @@ -831,6 +845,7 @@ export async function POST(req: NextRequest, { params }: { params: Promise<{ id: iterationType: iterationContext.iterationType, iterationContainerId: iterationContext.iterationContainerId, }), + ...childWorkflowData, }, }) } 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 8b19a3a35a..1fde67ea41 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 @@ -41,6 +41,7 @@ import { } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/terminal/hooks' import { ROW_STYLES } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/terminal/types' import { + collectExpandableNodeIds, type EntryNode, type ExecutionGroup, flattenBlockEntriesOnly, @@ -67,6 +68,21 @@ const MIN_HEIGHT = TERMINAL_HEIGHT.MIN const DEFAULT_EXPANDED_HEIGHT = TERMINAL_HEIGHT.DEFAULT const MIN_OUTPUT_PANEL_WIDTH_PX = OUTPUT_PANEL_WIDTH.MIN +/** Returns true if any node in the subtree has an error */ +function hasErrorInTree(nodes: EntryNode[]): boolean { + return nodes.some((n) => Boolean(n.entry.error) || hasErrorInTree(n.children)) +} + +/** Returns true if any node in the subtree is currently running */ +function hasRunningInTree(nodes: EntryNode[]): boolean { + return nodes.some((n) => Boolean(n.entry.isRunning) || hasRunningInTree(n.children)) +} + +/** Returns true if any node in the subtree was canceled */ +function hasCanceledInTree(nodes: EntryNode[]): boolean { + return nodes.some((n) => Boolean(n.entry.isCanceled) || hasCanceledInTree(n.children)) +} + /** * Block row component for displaying actual block entries */ @@ -338,6 +354,119 @@ const SubflowNodeRow = memo(function SubflowNodeRow({ ) }) +/** + * Workflow node component - shows workflow block header with nested child blocks + */ +const WorkflowNodeRow = memo(function WorkflowNodeRow({ + node, + selectedEntryId, + onSelectEntry, + expandedNodes, + onToggleNode, +}: { + node: EntryNode + selectedEntryId: string | null + onSelectEntry: (entry: ConsoleEntry) => void + expandedNodes: Set + onToggleNode: (nodeId: string) => void +}) { + const { entry, children } = node + const BlockIcon = getBlockIcon(entry.blockType) + const bgColor = getBlockColor(entry.blockType) + const nodeId = entry.id + const isExpanded = expandedNodes.has(nodeId) + const hasChildren = children.length > 0 + const isSelected = selectedEntryId === entry.id + + const hasError = useMemo( + () => Boolean(entry.error) || hasErrorInTree(children), + [entry.error, children] + ) + const hasRunningDescendant = useMemo(() => hasRunningInTree(children), [children]) + const hasCanceledDescendant = useMemo( + () => hasCanceledInTree(children) && !hasRunningDescendant, + [children, hasRunningDescendant] + ) + + return ( +
+ {/* Workflow Block Header */} +
{ + e.stopPropagation() + if (!isSelected) onSelectEntry(entry) + if (hasChildren) onToggleNode(nodeId) + }} + > +
+
+ {BlockIcon && } +
+ + {entry.blockName} + + {hasChildren && ( + + )} +
+ + + +
+ + {/* Nested Child Blocks — rendered through EntryNodeRow for full loop/parallel support */} + {isExpanded && hasChildren && ( +
+ {children.map((child) => ( + + ))} +
+ )} +
+ ) +}) + /** * Entry node component - dispatches to appropriate component based on node type */ @@ -368,6 +497,18 @@ const EntryNodeRow = memo(function EntryNodeRow({ ) } + if (nodeType === 'workflow') { + return ( + + ) + } + if (nodeType === 'iteration') { return ( { if (executionGroups.length === 0) return - const newestExec = executionGroups[0] - - // Collect all node IDs that should be expanded (subflows and their iterations) - const nodeIdsToExpand: string[] = [] - for (const node of newestExec.entryTree) { - if (node.nodeType === 'subflow' && node.children.length > 0) { - nodeIdsToExpand.push(node.entry.id) - // Also expand all iteration children - for (const iterNode of node.children) { - if (iterNode.nodeType === 'iteration') { - nodeIdsToExpand.push(iterNode.entry.id) - } - } - } - } + const nodeIdsToExpand = collectExpandableNodeIds(executionGroups[0].entryTree) if (nodeIdsToExpand.length > 0) { setExpandedNodes((prev) => { 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 f1bb7b3dc8..bf6414596b 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 @@ -1,5 +1,12 @@ import type React from 'react' -import { AlertTriangleIcon, BanIcon, RepeatIcon, SplitIcon, XCircleIcon } from 'lucide-react' +import { + AlertTriangleIcon, + BanIcon, + NetworkIcon, + RepeatIcon, + SplitIcon, + XCircleIcon, +} from 'lucide-react' import { getBlock } from '@/blocks' import { TERMINAL_BLOCK_COLUMN_WIDTH } from '@/stores/constants' import type { ConsoleEntry } from '@/stores/terminal' @@ -12,6 +19,8 @@ const SUBFLOW_COLORS = { parallel: '#FEE12B', } as const +const WORKFLOW_COLOR = '#8b5cf6' + /** * Special block type colors for errors and system messages */ @@ -41,6 +50,10 @@ export function getBlockIcon( return SplitIcon } + if (blockType === 'workflow') { + return NetworkIcon + } + if (blockType === 'error') { return XCircleIcon } @@ -71,6 +84,9 @@ export function getBlockColor(blockType: string): string { if (blockType === 'parallel') { return SUBFLOW_COLORS.parallel } + if (blockType === 'workflow') { + return WORKFLOW_COLOR + } // Special block types for errors and system messages if (blockType === 'error') { return SPECIAL_BLOCK_COLORS.error @@ -84,6 +100,14 @@ export function getBlockColor(blockType: string): string { return '#6b7280' } +/** + * Checks if a block type is a child workflow node + */ +export function isWorkflowBlockType(blockType: string): boolean { + const t = blockType?.toLowerCase() + return t === 'workflow' || t === 'workflow_input' +} + /** * Determines if a keyboard event originated from a text-editable element */ @@ -120,7 +144,7 @@ export function isSubflowBlockType(blockType: string): boolean { /** * Node type for the tree structure */ -export type EntryNodeType = 'block' | 'subflow' | 'iteration' +export type EntryNodeType = 'block' | 'subflow' | 'iteration' | 'workflow' /** * Entry node for tree structure - represents a block, subflow, or iteration @@ -168,6 +192,25 @@ interface IterationGroup { startTimeMs: number } +/** + * Recursively collects all descendant entries owned by a workflow block. + * This includes direct children and the children of any nested workflow blocks, + * enabling correct tree construction for deeply-nested child workflows. + */ +function collectWorkflowDescendants( + workflowBlockId: string, + workflowChildGroups: Map +): ConsoleEntry[] { + const direct = workflowChildGroups.get(workflowBlockId) ?? [] + const result = [...direct] + for (const entry of direct) { + if (isWorkflowBlockType(entry.blockType)) { + result.push(...collectWorkflowDescendants(entry.blockId, workflowChildGroups)) + } + } + return result +} + /** * Builds a tree structure from flat entries. * Groups iteration entries by (iterationType, iterationContainerId, iterationCurrent), showing all blocks @@ -175,18 +218,37 @@ interface IterationGroup { * Sorts by start time to ensure chronological order. */ function buildEntryTree(entries: ConsoleEntry[]): EntryNode[] { - // Separate regular blocks from iteration entries + // Separate entries into three buckets: + // 1. Iteration entries (loop/parallel children) + // 2. Workflow child entries (blocks inside a child workflow) + // 3. Regular blocks const regularBlocks: ConsoleEntry[] = [] const iterationEntries: ConsoleEntry[] = [] + const workflowChildEntries: ConsoleEntry[] = [] for (const entry of entries) { - if (entry.iterationType && entry.iterationCurrent !== undefined) { + if (entry.childWorkflowBlockId) { + // Child workflow entries take priority over iteration classification + workflowChildEntries.push(entry) + } else if (entry.iterationType && entry.iterationCurrent !== undefined) { iterationEntries.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! + const group = workflowChildGroups.get(parentId) + if (group) { + group.push(entry) + } else { + workflowChildGroups.set(parentId, [entry]) + } + } + // Group iteration entries by (iterationType, iterationContainerId, iterationCurrent) const iterationGroupsMap = new Map() for (const entry of iterationEntries) { @@ -344,19 +406,60 @@ function buildEntryTree(entries: ConsoleEntry[]): EntryNode[] { }) } - // Build nodes for regular blocks - const regularNodes: EntryNode[] = regularBlocks.map((entry) => ({ + // Build workflow nodes for regular blocks that are workflow block types + const workflowNodes: EntryNode[] = [] + const remainingRegularBlocks: ConsoleEntry[] = [] + + for (const block of regularBlocks) { + if (isWorkflowBlockType(block.blockType)) { + const allDescendants = collectWorkflowDescendants(block.blockId, workflowChildGroups) + const rawChildren = allDescendants.map((c) => ({ + ...c, + childWorkflowBlockId: + c.childWorkflowBlockId === block.blockId ? undefined : c.childWorkflowBlockId, + })) + const children = buildEntryTree(rawChildren) + workflowNodes.push({ entry: block, children, nodeType: 'workflow' as const }) + } else { + remainingRegularBlocks.push(block) + } + } + + // 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, ...regularNodes] + const allNodes = [...subflowNodes, ...workflowNodes, ...regularNodes] allNodes.sort((a, b) => a.entry.executionOrder - b.entry.executionOrder) return allNodes } +/** + * Recursively collects IDs of all nodes that should be auto-expanded. + * Includes subflow, iteration, and workflow nodes that have children. + */ +export function collectExpandableNodeIds(nodes: EntryNode[]): string[] { + const ids: string[] = [] + for (const node of nodes) { + if ( + (node.nodeType === 'subflow' || + node.nodeType === 'iteration' || + node.nodeType === 'workflow') && + node.children.length > 0 + ) { + ids.push(node.entry.id) + } + if (node.children.length > 0) { + ids.push(...collectExpandableNodeIds(node.children)) + } + } + return ids +} + /** * Groups console entries by execution ID and builds a tree structure. * Pre-computes timestamps for efficient sorting. @@ -464,7 +567,7 @@ export function flattenBlockEntriesOnly( ): NavigableBlockEntry[] { const result: NavigableBlockEntry[] = [] for (const node of nodes) { - if (node.nodeType === 'block') { + if (node.nodeType === 'block' || node.nodeType === 'workflow') { result.push({ entry: node.entry, executionId, 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 10186d6876..2e2ef890f1 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,8 @@ export function useWorkflowExecution() { iterationTotal: data.iterationTotal, iterationType: data.iterationType, iterationContainerId: data.iterationContainerId, + childWorkflowBlockId: data.childWorkflowBlockId, + childWorkflowName: data.childWorkflowName, }) } @@ -406,6 +408,8 @@ export function useWorkflowExecution() { iterationTotal: data.iterationTotal, iterationType: data.iterationType, iterationContainerId: data.iterationContainerId, + childWorkflowBlockId: data.childWorkflowBlockId, + childWorkflowName: data.childWorkflowName, }) } @@ -425,6 +429,8 @@ export function useWorkflowExecution() { iterationTotal: data.iterationTotal, iterationType: data.iterationType, iterationContainerId: data.iterationContainerId, + childWorkflowBlockId: data.childWorkflowBlockId, + childWorkflowName: data.childWorkflowName, }, executionIdRef.current ) @@ -447,6 +453,8 @@ export function useWorkflowExecution() { iterationTotal: data.iterationTotal, iterationType: data.iterationType, iterationContainerId: data.iterationContainerId, + childWorkflowBlockId: data.childWorkflowBlockId, + childWorkflowName: data.childWorkflowName, }, executionIdRef.current ) @@ -478,6 +486,8 @@ export function useWorkflowExecution() { iterationTotal: data.iterationTotal, iterationType: data.iterationType, iterationContainerId: data.iterationContainerId, + childWorkflowBlockId: data.childWorkflowBlockId, + childWorkflowName: data.childWorkflowName, }) } diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/utils/workflow-execution-utils.ts b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/utils/workflow-execution-utils.ts index ff1baf222a..44aa82300e 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/utils/workflow-execution-utils.ts +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/utils/workflow-execution-utils.ts @@ -173,6 +173,8 @@ export async function executeWorkflowWithFullLogging( iterationTotal: event.data.iterationTotal, iterationType: event.data.iterationType, iterationContainerId: event.data.iterationContainerId, + childWorkflowBlockId: event.data.childWorkflowBlockId, + childWorkflowName: event.data.childWorkflowName, }) if (options.onBlockComplete) { @@ -210,6 +212,8 @@ export async function executeWorkflowWithFullLogging( iterationTotal: event.data.iterationTotal, iterationType: event.data.iterationType, iterationContainerId: event.data.iterationContainerId, + childWorkflowBlockId: event.data.childWorkflowBlockId, + childWorkflowName: event.data.childWorkflowName, }) break } diff --git a/apps/sim/executor/constants.ts b/apps/sim/executor/constants.ts index b5f97dd471..9aee8a192f 100644 --- a/apps/sim/executor/constants.ts +++ b/apps/sim/executor/constants.ts @@ -159,6 +159,7 @@ export const DEFAULTS = { MAX_FOREACH_ITEMS: 1000, MAX_PARALLEL_BRANCHES: 20, MAX_WORKFLOW_DEPTH: 10, + MAX_SSE_CHILD_DEPTH: 3, EXECUTION_TIME: 0, TOKENS: { PROMPT: 0, diff --git a/apps/sim/executor/execution/block-executor.ts b/apps/sim/executor/execution/block-executor.ts index 56b7c6a915..345a94c48a 100644 --- a/apps/sim/executor/execution/block-executor.ts +++ b/apps/sim/executor/execution/block-executor.ts @@ -440,7 +440,8 @@ export class BlockExecutor { blockName, blockType, executionOrder, - iterationContext + iterationContext, + ctx.childWorkflowContext ) } } @@ -475,7 +476,8 @@ export class BlockExecutor { executionOrder, endedAt, }, - iterationContext + iterationContext, + ctx.childWorkflowContext ) } } diff --git a/apps/sim/executor/execution/executor.ts b/apps/sim/executor/execution/executor.ts index a19081ef9e..65ed626d13 100644 --- a/apps/sim/executor/execution/executor.ts +++ b/apps/sim/executor/execution/executor.ts @@ -323,6 +323,7 @@ export class DAGExecutor { onBlockStart: this.contextExtensions.onBlockStart, onBlockComplete: this.contextExtensions.onBlockComplete, abortSignal: this.contextExtensions.abortSignal, + childWorkflowContext: this.contextExtensions.childWorkflowContext, includeFileBase64: this.contextExtensions.includeFileBase64, base64MaxBytes: this.contextExtensions.base64MaxBytes, runFromBlockContext: overrides?.runFromBlockContext, diff --git a/apps/sim/executor/execution/types.ts b/apps/sim/executor/execution/types.ts index 29b79ca037..f1beecd785 100644 --- a/apps/sim/executor/execution/types.ts +++ b/apps/sim/executor/execution/types.ts @@ -54,6 +54,17 @@ export interface IterationContext { iterationContainerId?: string } +export interface ChildWorkflowContext { + /** The workflow block's ID in the parent execution */ + parentBlockId: string + /** Display name of the child workflow */ + workflowName: string + /** Child workflow ID */ + workflowId: string + /** Nesting depth (1 = first level child) */ + depth: number +} + export interface ExecutionCallbacks { onStream?: (streamingExec: any) => Promise onBlockStart?: ( @@ -61,14 +72,16 @@ export interface ExecutionCallbacks { blockName: string, blockType: string, executionOrder: number, - iterationContext?: IterationContext + iterationContext?: IterationContext, + childWorkflowContext?: ChildWorkflowContext ) => Promise onBlockComplete?: ( blockId: string, blockName: string, blockType: string, output: any, - iterationContext?: IterationContext + iterationContext?: IterationContext, + childWorkflowContext?: ChildWorkflowContext ) => Promise } @@ -105,7 +118,8 @@ export interface ContextExtensions { blockName: string, blockType: string, executionOrder: number, - iterationContext?: IterationContext + iterationContext?: IterationContext, + childWorkflowContext?: ChildWorkflowContext ) => Promise onBlockComplete?: ( blockId: string, @@ -119,9 +133,13 @@ export interface ContextExtensions { executionOrder: number endedAt: string }, - iterationContext?: IterationContext + iterationContext?: IterationContext, + childWorkflowContext?: ChildWorkflowContext ) => Promise + /** Context identifying this execution as a child of a workflow block */ + childWorkflowContext?: ChildWorkflowContext + /** * Run-from-block configuration. When provided, executor runs in partial * execution mode starting from the specified block. diff --git a/apps/sim/executor/handlers/workflow/workflow-handler.ts b/apps/sim/executor/handlers/workflow/workflow-handler.ts index d32353f434..ac16469e20 100644 --- a/apps/sim/executor/handlers/workflow/workflow-handler.ts +++ b/apps/sim/executor/handlers/workflow/workflow-handler.ts @@ -115,6 +115,9 @@ export class WorkflowBlockHandler implements BlockHandler { ) childWorkflowSnapshotId = childSnapshotResult.snapshot.id + const childDepth = (ctx.childWorkflowContext?.depth ?? 0) + 1 + const shouldPropagateCallbacks = childDepth <= DEFAULTS.MAX_SSE_CHILD_DEPTH + const subExecutor = new Executor({ workflow: childWorkflow.serializedState, workflowInput: childWorkflowInput, @@ -127,6 +130,17 @@ export class WorkflowBlockHandler implements BlockHandler { userId: ctx.userId, executionId: ctx.executionId, abortSignal: ctx.abortSignal, + ...(shouldPropagateCallbacks && { + onBlockStart: ctx.onBlockStart, + onBlockComplete: ctx.onBlockComplete, + onStream: ctx.onStream as ((streamingExecution: unknown) => Promise) | undefined, + childWorkflowContext: { + parentBlockId: block.id, + workflowName: childWorkflowName, + workflowId, + depth: childDepth, + }, + }), }, }) diff --git a/apps/sim/executor/types.ts b/apps/sim/executor/types.ts index 9298f66678..4fd744270a 100644 --- a/apps/sim/executor/types.ts +++ b/apps/sim/executor/types.ts @@ -1,7 +1,11 @@ import type { TraceSpan } from '@/lib/logs/types' import type { PermissionGroupConfig } from '@/lib/permission-groups/types' import type { BlockOutput } from '@/blocks/types' -import type { SerializableExecutionState } from '@/executor/execution/types' +import type { + ChildWorkflowContext, + IterationContext, + SerializableExecutionState, +} from '@/executor/execution/types' import type { RunFromBlockContext } from '@/executor/utils/run-from-block' import type { SerializedBlock, SerializedWorkflow } from '@/serializer/types' @@ -239,15 +243,22 @@ export interface ExecutionContext { blockId: string, blockName: string, blockType: string, - executionOrder: number + executionOrder: number, + iterationContext?: IterationContext, + childWorkflowContext?: ChildWorkflowContext ) => Promise onBlockComplete?: ( blockId: string, blockName: string, blockType: string, - output: any + output: any, + iterationContext?: IterationContext, + childWorkflowContext?: ChildWorkflowContext ) => Promise + /** Context identifying this execution as a child of a workflow block */ + childWorkflowContext?: ChildWorkflowContext + /** * AbortSignal for cancellation support. * When the signal is aborted, execution should stop gracefully. diff --git a/apps/sim/lib/workflows/executor/execution-core.ts b/apps/sim/lib/workflows/executor/execution-core.ts index 963256af2e..2835c6f410 100644 --- a/apps/sim/lib/workflows/executor/execution-core.ts +++ b/apps/sim/lib/workflows/executor/execution-core.ts @@ -20,6 +20,7 @@ import { updateWorkflowRunCounts } from '@/lib/workflows/utils' import { Executor } from '@/executor' import type { ExecutionSnapshot } from '@/executor/execution/snapshot' import type { + ChildWorkflowContext, ContextExtensions, ExecutionCallbacks, IterationContext, @@ -287,11 +288,19 @@ export async function executeWorkflowCore( startedAt: string endedAt: string }, - iterationContext?: IterationContext + iterationContext?: IterationContext, + childWorkflowContext?: ChildWorkflowContext ) => { await loggingSession.onBlockComplete(blockId, blockName, blockType, output) if (onBlockComplete) { - await onBlockComplete(blockId, blockName, blockType, output, iterationContext) + await onBlockComplete( + blockId, + blockName, + blockType, + output, + iterationContext, + childWorkflowContext + ) } } diff --git a/apps/sim/lib/workflows/executor/execution-events.ts b/apps/sim/lib/workflows/executor/execution-events.ts index b0585dea50..212b73852a 100644 --- a/apps/sim/lib/workflows/executor/execution-events.ts +++ b/apps/sim/lib/workflows/executor/execution-events.ts @@ -1,3 +1,4 @@ +import type { ChildWorkflowContext, IterationContext } from '@/executor/execution/types' import type { SubflowType } from '@/stores/workflows/workflow/types' export type ExecutionEventType = @@ -81,6 +82,8 @@ export interface BlockStartedEvent extends BaseExecutionEvent { iterationTotal?: number iterationType?: SubflowType iterationContainerId?: string + childWorkflowBlockId?: string + childWorkflowName?: string } } @@ -104,6 +107,8 @@ export interface BlockCompletedEvent extends BaseExecutionEvent { iterationTotal?: number iterationType?: SubflowType iterationContainerId?: string + childWorkflowBlockId?: string + childWorkflowName?: string } } @@ -127,6 +132,8 @@ export interface BlockErrorEvent extends BaseExecutionEvent { iterationTotal?: number iterationType?: SubflowType iterationContainerId?: string + childWorkflowBlockId?: string + childWorkflowName?: string } } @@ -222,12 +229,8 @@ export function createSSECallbacks(options: SSECallbackOptions) { blockName: string, blockType: string, executionOrder: number, - iterationContext?: { - iterationCurrent: number - iterationTotal?: number - iterationType: string - iterationContainerId?: string - } + iterationContext?: IterationContext, + childWorkflowContext?: ChildWorkflowContext ) => { sendEvent({ type: 'block:started', @@ -242,9 +245,13 @@ export function createSSECallbacks(options: SSECallbackOptions) { ...(iterationContext && { iterationCurrent: iterationContext.iterationCurrent, iterationTotal: iterationContext.iterationTotal, - iterationType: iterationContext.iterationType as any, + iterationType: iterationContext.iterationType, iterationContainerId: iterationContext.iterationContainerId, }), + ...(childWorkflowContext && { + childWorkflowBlockId: childWorkflowContext.parentBlockId, + childWorkflowName: childWorkflowContext.workflowName, + }), }, }) } @@ -261,22 +268,24 @@ export function createSSECallbacks(options: SSECallbackOptions) { executionOrder: number endedAt: string }, - iterationContext?: { - iterationCurrent: number - iterationTotal?: number - iterationType: string - iterationContainerId?: string - } + iterationContext?: IterationContext, + childWorkflowContext?: ChildWorkflowContext ) => { const hasError = callbackData.output?.error const iterationData = iterationContext ? { iterationCurrent: iterationContext.iterationCurrent, iterationTotal: iterationContext.iterationTotal, - iterationType: iterationContext.iterationType as any, + iterationType: iterationContext.iterationType, iterationContainerId: iterationContext.iterationContainerId, } : {} + const childWorkflowData = childWorkflowContext + ? { + childWorkflowBlockId: childWorkflowContext.parentBlockId, + childWorkflowName: childWorkflowContext.workflowName, + } + : {} if (hasError) { sendEvent({ @@ -295,6 +304,7 @@ export function createSSECallbacks(options: SSECallbackOptions) { executionOrder: callbackData.executionOrder, endedAt: callbackData.endedAt, ...iterationData, + ...childWorkflowData, }, }) } else { @@ -314,6 +324,7 @@ export function createSSECallbacks(options: SSECallbackOptions) { executionOrder: callbackData.executionOrder, endedAt: callbackData.endedAt, ...iterationData, + ...childWorkflowData, }, }) } diff --git a/apps/sim/stores/terminal/console/store.ts b/apps/sim/stores/terminal/console/store.ts index 9fddbf3efd..c2b20b3a73 100644 --- a/apps/sim/stores/terminal/console/store.ts +++ b/apps/sim/stores/terminal/console/store.ts @@ -420,6 +420,14 @@ export const useTerminalConsoleStore = create()( updatedEntry.iterationContainerId = update.iterationContainerId } + if (update.childWorkflowBlockId !== undefined) { + updatedEntry.childWorkflowBlockId = update.childWorkflowBlockId + } + + if (update.childWorkflowName !== undefined) { + updatedEntry.childWorkflowName = update.childWorkflowName + } + return updatedEntry }) diff --git a/apps/sim/stores/terminal/console/types.ts b/apps/sim/stores/terminal/console/types.ts index e057854d8c..53da84cdcc 100644 --- a/apps/sim/stores/terminal/console/types.ts +++ b/apps/sim/stores/terminal/console/types.ts @@ -24,6 +24,10 @@ export interface ConsoleEntry { iterationContainerId?: string isRunning?: boolean isCanceled?: boolean + /** ID of the workflow block in the parent execution that spawned this child block */ + childWorkflowBlockId?: string + /** Display name of the child workflow this block belongs to */ + childWorkflowName?: string } export interface ConsoleUpdate { @@ -44,6 +48,8 @@ export interface ConsoleUpdate { iterationTotal?: number iterationType?: SubflowType iterationContainerId?: string + childWorkflowBlockId?: string + childWorkflowName?: string } export interface ConsoleStore { From 23457564e622df081b831ea3db1336df707793d0 Mon Sep 17 00:00:00 2001 From: waleed Date: Sun, 22 Feb 2026 16:23:50 -0800 Subject: [PATCH 2/9] fix(terminal): cycle guard in collectWorkflowDescendants, workflow node running/canceled state --- .../w/[workflowId]/components/terminal/terminal.tsx | 9 ++++++--- .../w/[workflowId]/components/terminal/utils.ts | 7 +++++-- 2 files changed, 11 insertions(+), 5 deletions(-) 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 1fde67ea41..9d9b206b79 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 @@ -382,10 +382,13 @@ const WorkflowNodeRow = memo(function WorkflowNodeRow({ () => Boolean(entry.error) || hasErrorInTree(children), [entry.error, children] ) - const hasRunningDescendant = useMemo(() => hasRunningInTree(children), [children]) + const hasRunningDescendant = useMemo( + () => Boolean(entry.isRunning) || hasRunningInTree(children), + [entry.isRunning, children] + ) const hasCanceledDescendant = useMemo( - () => hasCanceledInTree(children) && !hasRunningDescendant, - [children, hasRunningDescendant] + () => (Boolean(entry.isCanceled) || hasCanceledInTree(children)) && !hasRunningDescendant, + [entry.isCanceled, children, hasRunningDescendant] ) 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 bf6414596b..ded0f1d53d 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 @@ -199,13 +199,16 @@ interface IterationGroup { */ function collectWorkflowDescendants( workflowBlockId: string, - workflowChildGroups: Map + workflowChildGroups: Map, + visited: Set = new Set() ): ConsoleEntry[] { + if (visited.has(workflowBlockId)) return [] + visited.add(workflowBlockId) const direct = workflowChildGroups.get(workflowBlockId) ?? [] const result = [...direct] for (const entry of direct) { if (isWorkflowBlockType(entry.blockType)) { - result.push(...collectWorkflowDescendants(entry.blockId, workflowChildGroups)) + result.push(...collectWorkflowDescendants(entry.blockId, workflowChildGroups, visited)) } } return result From e7ad3f70f5100650ceda02b8af733446a88f6c79 Mon Sep 17 00:00:00 2001 From: waleed Date: Sun, 22 Feb 2026 16:25:29 -0800 Subject: [PATCH 3/9] fix(terminal): expand workflow blocks nested inside loop/parallel iterations --- .../[workflowId]/components/terminal/utils.ts | 23 ++++++++++++++----- 1 file changed, 17 insertions(+), 6 deletions(-) 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 ded0f1d53d..8c6f741058 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 @@ -384,12 +384,23 @@ function buildEntryTree(entries: ConsoleEntry[]): EntryNode[] { iterationContainerId: iterGroup.iterationContainerId, } - // Block nodes within this iteration - const blockNodes: EntryNode[] = iterBlocks.map((block) => ({ - entry: block, - children: [], - nodeType: 'block' as const, - })) + // Block nodes within this iteration — workflow blocks get their full subtree + const blockNodes: EntryNode[] = iterBlocks.map((block) => { + if (isWorkflowBlockType(block.blockType)) { + const allDescendants = collectWorkflowDescendants(block.blockId, workflowChildGroups) + const rawChildren = allDescendants.map((c) => ({ + ...c, + childWorkflowBlockId: + c.childWorkflowBlockId === block.blockId ? undefined : c.childWorkflowBlockId, + })) + return { + entry: block, + children: buildEntryTree(rawChildren), + nodeType: 'workflow' as const, + } + } + return { entry: block, children: [], nodeType: 'block' as const } + }) return { entry: syntheticIteration, From 17812797585ad6a47dc3fcc251950869745fdb77 Mon Sep 17 00:00:00 2001 From: waleed Date: Sun, 22 Feb 2026 22:43:59 -0800 Subject: [PATCH 4/9] fix(terminal): prevent child block mixing across loop iterations for workflow blocks --- .../app/api/workflows/[id]/execute/route.ts | 11 +++++++- .../[workflowId]/components/terminal/utils.ts | 28 +++++++++++++------ .../hooks/use-workflow-execution.ts | 4 +++ .../utils/workflow-execution-utils.ts | 2 ++ .../executor/errors/child-workflow-error.ts | 4 +++ apps/sim/executor/execution/block-executor.ts | 3 ++ .../handlers/workflow/workflow-handler.ts | 11 +++++++- .../workflows/executor/execution-events.ts | 14 +++++++++- apps/sim/stores/terminal/console/store.ts | 4 +++ apps/sim/stores/terminal/console/types.ts | 3 ++ 10 files changed, 72 insertions(+), 12 deletions(-) diff --git a/apps/sim/app/api/workflows/[id]/execute/route.ts b/apps/sim/app/api/workflows/[id]/execute/route.ts index ad4f0ce900..3a83871624 100644 --- a/apps/sim/app/api/workflows/[id]/execute/route.ts +++ b/apps/sim/app/api/workflows/[id]/execute/route.ts @@ -787,6 +787,11 @@ export async function POST(req: NextRequest, { params }: { params: Promise<{ id: } : {} + // Extract per-invocation instance ID and strip from user-visible output + const childWorkflowInstanceId: string | undefined = + callbackData.output?._childWorkflowInstanceId + const instanceData = childWorkflowInstanceId ? { childWorkflowInstanceId } : {} + if (hasError) { logger.info(`[${requestId}] ✗ onBlockComplete (error) called:`, { blockId, @@ -816,6 +821,7 @@ export async function POST(req: NextRequest, { params }: { params: Promise<{ id: iterationContainerId: iterationContext.iterationContainerId, }), ...childWorkflowData, + ...instanceData, }, }) } else { @@ -824,6 +830,8 @@ export async function POST(req: NextRequest, { params }: { params: Promise<{ id: blockName, blockType, }) + const { _childWorkflowInstanceId: _stripped, ...strippedOutput } = + callbackData.output ?? {} sendEvent({ type: 'block:completed', timestamp: new Date().toISOString(), @@ -834,7 +842,7 @@ export async function POST(req: NextRequest, { params }: { params: Promise<{ id: blockName, blockType, input: callbackData.input, - output: callbackData.output, + output: strippedOutput, durationMs: callbackData.executionTime || 0, startedAt: callbackData.startedAt, executionOrder: callbackData.executionOrder, @@ -846,6 +854,7 @@ export async function POST(req: NextRequest, { params }: { params: Promise<{ id: iterationContainerId: iterationContext.iterationContainerId, }), ...childWorkflowData, + ...instanceData, }, }) } 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 8c6f741058..beaee4b108 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 @@ -198,17 +198,25 @@ interface IterationGroup { * enabling correct tree construction for deeply-nested child workflows. */ function collectWorkflowDescendants( - workflowBlockId: string, + instanceKey: string, workflowChildGroups: Map, visited: Set = new Set() ): ConsoleEntry[] { - if (visited.has(workflowBlockId)) return [] - visited.add(workflowBlockId) - const direct = workflowChildGroups.get(workflowBlockId) ?? [] + if (visited.has(instanceKey)) return [] + visited.add(instanceKey) + const direct = workflowChildGroups.get(instanceKey) ?? [] const result = [...direct] for (const entry of direct) { if (isWorkflowBlockType(entry.blockType)) { - result.push(...collectWorkflowDescendants(entry.blockId, workflowChildGroups, visited)) + // Use childWorkflowInstanceId when available (unique per-invocation) to correctly + // separate children across loop iterations of the same workflow block. + result.push( + ...collectWorkflowDescendants( + entry.childWorkflowInstanceId ?? entry.blockId, + workflowChildGroups, + visited + ) + ) } } return result @@ -387,11 +395,12 @@ function buildEntryTree(entries: ConsoleEntry[]): EntryNode[] { // Block nodes within this iteration — workflow blocks get their full subtree const blockNodes: EntryNode[] = iterBlocks.map((block) => { if (isWorkflowBlockType(block.blockType)) { - const allDescendants = collectWorkflowDescendants(block.blockId, workflowChildGroups) + const instanceKey = block.childWorkflowInstanceId ?? block.blockId + const allDescendants = collectWorkflowDescendants(instanceKey, workflowChildGroups) const rawChildren = allDescendants.map((c) => ({ ...c, childWorkflowBlockId: - c.childWorkflowBlockId === block.blockId ? undefined : c.childWorkflowBlockId, + c.childWorkflowBlockId === instanceKey ? undefined : c.childWorkflowBlockId, })) return { entry: block, @@ -426,11 +435,12 @@ function buildEntryTree(entries: ConsoleEntry[]): EntryNode[] { for (const block of regularBlocks) { if (isWorkflowBlockType(block.blockType)) { - const allDescendants = collectWorkflowDescendants(block.blockId, workflowChildGroups) + const instanceKey = block.childWorkflowInstanceId ?? block.blockId + const allDescendants = collectWorkflowDescendants(instanceKey, workflowChildGroups) const rawChildren = allDescendants.map((c) => ({ ...c, childWorkflowBlockId: - c.childWorkflowBlockId === block.blockId ? undefined : c.childWorkflowBlockId, + c.childWorkflowBlockId === instanceKey ? undefined : c.childWorkflowBlockId, })) const children = buildEntryTree(rawChildren) workflowNodes.push({ entry: block, children, nodeType: 'workflow' as const }) 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 2e2ef890f1..531d52285e 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 @@ -385,6 +385,7 @@ export function useWorkflowExecution() { iterationContainerId: data.iterationContainerId, childWorkflowBlockId: data.childWorkflowBlockId, childWorkflowName: data.childWorkflowName, + childWorkflowInstanceId: data.childWorkflowInstanceId, }) } @@ -410,6 +411,7 @@ export function useWorkflowExecution() { iterationContainerId: data.iterationContainerId, childWorkflowBlockId: data.childWorkflowBlockId, childWorkflowName: data.childWorkflowName, + childWorkflowInstanceId: data.childWorkflowInstanceId, }) } @@ -431,6 +433,7 @@ export function useWorkflowExecution() { iterationContainerId: data.iterationContainerId, childWorkflowBlockId: data.childWorkflowBlockId, childWorkflowName: data.childWorkflowName, + childWorkflowInstanceId: data.childWorkflowInstanceId, }, executionIdRef.current ) @@ -455,6 +458,7 @@ export function useWorkflowExecution() { iterationContainerId: data.iterationContainerId, childWorkflowBlockId: data.childWorkflowBlockId, childWorkflowName: data.childWorkflowName, + childWorkflowInstanceId: data.childWorkflowInstanceId, }, executionIdRef.current ) diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/utils/workflow-execution-utils.ts b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/utils/workflow-execution-utils.ts index 44aa82300e..16130169ad 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/utils/workflow-execution-utils.ts +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/utils/workflow-execution-utils.ts @@ -175,6 +175,7 @@ export async function executeWorkflowWithFullLogging( iterationContainerId: event.data.iterationContainerId, childWorkflowBlockId: event.data.childWorkflowBlockId, childWorkflowName: event.data.childWorkflowName, + childWorkflowInstanceId: event.data.childWorkflowInstanceId, }) if (options.onBlockComplete) { @@ -214,6 +215,7 @@ export async function executeWorkflowWithFullLogging( iterationContainerId: event.data.iterationContainerId, childWorkflowBlockId: event.data.childWorkflowBlockId, childWorkflowName: event.data.childWorkflowName, + childWorkflowInstanceId: event.data.childWorkflowInstanceId, }) break } diff --git a/apps/sim/executor/errors/child-workflow-error.ts b/apps/sim/executor/errors/child-workflow-error.ts index 0fc1c92340..056c2ceebe 100644 --- a/apps/sim/executor/errors/child-workflow-error.ts +++ b/apps/sim/executor/errors/child-workflow-error.ts @@ -7,6 +7,7 @@ interface ChildWorkflowErrorOptions { childTraceSpans?: TraceSpan[] executionResult?: ExecutionResult childWorkflowSnapshotId?: string + childWorkflowInstanceId?: string cause?: Error } @@ -18,6 +19,8 @@ export class ChildWorkflowError extends Error { readonly childWorkflowName: string readonly executionResult?: ExecutionResult readonly childWorkflowSnapshotId?: string + /** Per-invocation unique ID used to correlate child block events with this workflow block. */ + readonly childWorkflowInstanceId?: string constructor(options: ChildWorkflowErrorOptions) { super(options.message, { cause: options.cause }) @@ -26,6 +29,7 @@ export class ChildWorkflowError extends Error { this.childTraceSpans = options.childTraceSpans ?? [] this.executionResult = options.executionResult this.childWorkflowSnapshotId = options.childWorkflowSnapshotId + this.childWorkflowInstanceId = options.childWorkflowInstanceId } static isChildWorkflowError(error: unknown): error is ChildWorkflowError { diff --git a/apps/sim/executor/execution/block-executor.ts b/apps/sim/executor/execution/block-executor.ts index 345a94c48a..b737af5e8d 100644 --- a/apps/sim/executor/execution/block-executor.ts +++ b/apps/sim/executor/execution/block-executor.ts @@ -249,6 +249,9 @@ export class BlockExecutor { if (error.childWorkflowSnapshotId) { errorOutput.childWorkflowSnapshotId = error.childWorkflowSnapshotId } + if (error.childWorkflowInstanceId) { + errorOutput._childWorkflowInstanceId = error.childWorkflowInstanceId + } } this.state.setBlockOutput(node.id, errorOutput, duration) diff --git a/apps/sim/executor/handlers/workflow/workflow-handler.ts b/apps/sim/executor/handlers/workflow/workflow-handler.ts index ac16469e20..7817e9463e 100644 --- a/apps/sim/executor/handlers/workflow/workflow-handler.ts +++ b/apps/sim/executor/handlers/workflow/workflow-handler.ts @@ -58,6 +58,10 @@ export class WorkflowBlockHandler implements BlockHandler { const workflowMetadata = workflows[workflowId] let childWorkflowName = workflowMetadata?.name || workflowId + // Unique ID per invocation — used to correlate child block events with this specific + // workflow block execution, preventing cross-iteration child mixing in loop contexts. + const instanceId = crypto.randomUUID() + let childWorkflowSnapshotId: string | undefined try { const currentDepth = (ctx.workflowId?.split('_sub_').length || 1) - 1 @@ -135,7 +139,7 @@ export class WorkflowBlockHandler implements BlockHandler { onBlockComplete: ctx.onBlockComplete, onStream: ctx.onStream as ((streamingExecution: unknown) => Promise) | undefined, childWorkflowContext: { - parentBlockId: block.id, + parentBlockId: instanceId, workflowName: childWorkflowName, workflowId, depth: childDepth, @@ -162,6 +166,7 @@ export class WorkflowBlockHandler implements BlockHandler { workflowId, childWorkflowName, duration, + instanceId, childTraceSpans, childWorkflowSnapshotId ) @@ -197,6 +202,7 @@ export class WorkflowBlockHandler implements BlockHandler { childTraceSpans, executionResult, childWorkflowSnapshotId, + childWorkflowInstanceId: instanceId, cause: error instanceof Error ? error : undefined, }) } @@ -539,6 +545,7 @@ export class WorkflowBlockHandler implements BlockHandler { childWorkflowId: string, childWorkflowName: string, duration: number, + instanceId: string, childTraceSpans?: WorkflowTraceSpan[], childWorkflowSnapshotId?: string ): BlockOutput { @@ -552,6 +559,7 @@ export class WorkflowBlockHandler implements BlockHandler { childWorkflowName, childTraceSpans: childTraceSpans || [], childWorkflowSnapshotId, + childWorkflowInstanceId: instanceId, }) } @@ -562,6 +570,7 @@ export class WorkflowBlockHandler implements BlockHandler { ...(childWorkflowSnapshotId ? { childWorkflowSnapshotId } : {}), result, childTraceSpans: childTraceSpans || [], + _childWorkflowInstanceId: instanceId, } as Record } } diff --git a/apps/sim/lib/workflows/executor/execution-events.ts b/apps/sim/lib/workflows/executor/execution-events.ts index 212b73852a..83b21f09d8 100644 --- a/apps/sim/lib/workflows/executor/execution-events.ts +++ b/apps/sim/lib/workflows/executor/execution-events.ts @@ -109,6 +109,8 @@ export interface BlockCompletedEvent extends BaseExecutionEvent { iterationContainerId?: string childWorkflowBlockId?: string childWorkflowName?: string + /** Per-invocation unique ID for correlating child block events with this workflow block. */ + childWorkflowInstanceId?: string } } @@ -134,6 +136,8 @@ export interface BlockErrorEvent extends BaseExecutionEvent { iterationContainerId?: string childWorkflowBlockId?: string childWorkflowName?: string + /** Per-invocation unique ID for correlating child block events with this workflow block. */ + childWorkflowInstanceId?: string } } @@ -287,6 +291,11 @@ export function createSSECallbacks(options: SSECallbackOptions) { } : {} + // Extract per-invocation instance ID and strip from user-visible output + const childWorkflowInstanceId: string | undefined = + callbackData.output?._childWorkflowInstanceId + const instanceData = childWorkflowInstanceId ? { childWorkflowInstanceId } : {} + if (hasError) { sendEvent({ type: 'block:error', @@ -305,9 +314,11 @@ export function createSSECallbacks(options: SSECallbackOptions) { endedAt: callbackData.endedAt, ...iterationData, ...childWorkflowData, + ...instanceData, }, }) } else { + const { _childWorkflowInstanceId: _stripped, ...strippedOutput } = callbackData.output ?? {} sendEvent({ type: 'block:completed', timestamp: new Date().toISOString(), @@ -318,13 +329,14 @@ export function createSSECallbacks(options: SSECallbackOptions) { blockName, blockType, input: callbackData.input, - output: callbackData.output, + output: strippedOutput, durationMs: callbackData.executionTime || 0, startedAt: callbackData.startedAt, executionOrder: callbackData.executionOrder, endedAt: callbackData.endedAt, ...iterationData, ...childWorkflowData, + ...instanceData, }, }) } diff --git a/apps/sim/stores/terminal/console/store.ts b/apps/sim/stores/terminal/console/store.ts index c2b20b3a73..e3b7f3ea10 100644 --- a/apps/sim/stores/terminal/console/store.ts +++ b/apps/sim/stores/terminal/console/store.ts @@ -428,6 +428,10 @@ export const useTerminalConsoleStore = create()( updatedEntry.childWorkflowName = update.childWorkflowName } + if (update.childWorkflowInstanceId !== undefined) { + updatedEntry.childWorkflowInstanceId = update.childWorkflowInstanceId + } + return updatedEntry }) diff --git a/apps/sim/stores/terminal/console/types.ts b/apps/sim/stores/terminal/console/types.ts index 53da84cdcc..3fcfd6b1dc 100644 --- a/apps/sim/stores/terminal/console/types.ts +++ b/apps/sim/stores/terminal/console/types.ts @@ -28,6 +28,8 @@ export interface ConsoleEntry { childWorkflowBlockId?: string /** Display name of the child workflow this block belongs to */ childWorkflowName?: string + /** Per-invocation unique ID linking this workflow block to its child block events */ + childWorkflowInstanceId?: string } export interface ConsoleUpdate { @@ -50,6 +52,7 @@ export interface ConsoleUpdate { iterationContainerId?: string childWorkflowBlockId?: string childWorkflowName?: string + childWorkflowInstanceId?: string } export interface ConsoleStore { From b6fb7f5e74ffb1f67d6e26737b7a4c509dc9f3b3 Mon Sep 17 00:00:00 2001 From: waleed Date: Sun, 22 Feb 2026 23:19:01 -0800 Subject: [PATCH 5/9] ack PR comments, remove extranoeus logs --- .../app/api/workflows/[id]/execute/route.ts | 11 ++++------- .../[workflowId]/components/terminal/utils.ts | 4 +++- apps/sim/executor/execution/block-executor.ts | 19 +++++++++++++------ apps/sim/executor/execution/types.ts | 2 ++ apps/sim/lib/execution/isolated-vm.ts | 2 -- .../workflows/executor/execution-events.ts | 11 +++++------ 6 files changed, 27 insertions(+), 22 deletions(-) diff --git a/apps/sim/app/api/workflows/[id]/execute/route.ts b/apps/sim/app/api/workflows/[id]/execute/route.ts index 3a83871624..0e12e08e55 100644 --- a/apps/sim/app/api/workflows/[id]/execute/route.ts +++ b/apps/sim/app/api/workflows/[id]/execute/route.ts @@ -787,10 +787,9 @@ export async function POST(req: NextRequest, { params }: { params: Promise<{ id: } : {} - // Extract per-invocation instance ID and strip from user-visible output - const childWorkflowInstanceId: string | undefined = - callbackData.output?._childWorkflowInstanceId - const instanceData = childWorkflowInstanceId ? { childWorkflowInstanceId } : {} + const instanceData = callbackData.childWorkflowInstanceId + ? { childWorkflowInstanceId: callbackData.childWorkflowInstanceId } + : {} if (hasError) { logger.info(`[${requestId}] ✗ onBlockComplete (error) called:`, { @@ -830,8 +829,6 @@ export async function POST(req: NextRequest, { params }: { params: Promise<{ id: blockName, blockType, }) - const { _childWorkflowInstanceId: _stripped, ...strippedOutput } = - callbackData.output ?? {} sendEvent({ type: 'block:completed', timestamp: new Date().toISOString(), @@ -842,7 +839,7 @@ export async function POST(req: NextRequest, { params }: { params: Promise<{ id: blockName, blockType, input: callbackData.input, - output: strippedOutput, + output: callbackData.output, durationMs: callbackData.executionTime || 0, startedAt: callbackData.startedAt, executionOrder: callbackData.executionOrder, 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 beaee4b108..96998e5e0f 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 @@ -101,7 +101,9 @@ export function getBlockColor(blockType: string): string { } /** - * Checks if a block type is a child workflow node + * Checks if a block type is a workflow-calling block (calls a child workflow). + * Covers both `workflow` (new) and `workflow_input` (legacy) block variants — + * both are handled by WorkflowBlockHandler and emit child workflow events. */ export function isWorkflowBlockType(blockType: string): boolean { const t = blockType?.toLowerCase() diff --git a/apps/sim/executor/execution/block-executor.ts b/apps/sim/executor/execution/block-executor.ts index b737af5e8d..86e815c847 100644 --- a/apps/sim/executor/execution/block-executor.ts +++ b/apps/sim/executor/execution/block-executor.ts @@ -166,6 +166,9 @@ export class BlockExecutor { this.state.setBlockOutput(node.id, normalizedOutput, duration) if (!isSentinel && blockLog) { + const childWorkflowInstanceId = normalizedOutput._childWorkflowInstanceId as + | string + | undefined const displayOutput = filterOutputForLog(block.metadata?.id || '', normalizedOutput, { block, }) @@ -178,7 +181,8 @@ export class BlockExecutor { duration, blockLog.startedAt, blockLog.executionOrder, - blockLog.endedAt + blockLog.endedAt, + childWorkflowInstanceId ) } @@ -249,9 +253,6 @@ export class BlockExecutor { if (error.childWorkflowSnapshotId) { errorOutput.childWorkflowSnapshotId = error.childWorkflowSnapshotId } - if (error.childWorkflowInstanceId) { - errorOutput._childWorkflowInstanceId = error.childWorkflowInstanceId - } } this.state.setBlockOutput(node.id, errorOutput, duration) @@ -279,6 +280,9 @@ export class BlockExecutor { ) if (!isSentinel && blockLog) { + const childWorkflowInstanceId = ChildWorkflowError.isChildWorkflowError(error) + ? error.childWorkflowInstanceId + : undefined const displayOutput = filterOutputForLog(block.metadata?.id || '', errorOutput, { block }) this.callOnBlockComplete( ctx, @@ -289,7 +293,8 @@ export class BlockExecutor { duration, blockLog.startedAt, blockLog.executionOrder, - blockLog.endedAt + blockLog.endedAt, + childWorkflowInstanceId ) } @@ -458,7 +463,8 @@ export class BlockExecutor { duration: number, startedAt: string, executionOrder: number, - endedAt: string + endedAt: string, + childWorkflowInstanceId?: string ): void { const blockId = node.metadata?.originalBlockId ?? node.id const blockName = block.metadata?.name ?? blockId @@ -478,6 +484,7 @@ export class BlockExecutor { startedAt, executionOrder, endedAt, + childWorkflowInstanceId, }, iterationContext, ctx.childWorkflowContext diff --git a/apps/sim/executor/execution/types.ts b/apps/sim/executor/execution/types.ts index f1beecd785..5ae6036358 100644 --- a/apps/sim/executor/execution/types.ts +++ b/apps/sim/executor/execution/types.ts @@ -132,6 +132,8 @@ export interface ContextExtensions { startedAt: string executionOrder: number endedAt: string + /** Per-invocation unique ID linking this workflow block execution to its child block events. */ + childWorkflowInstanceId?: string }, iterationContext?: IterationContext, childWorkflowContext?: ChildWorkflowContext diff --git a/apps/sim/lib/execution/isolated-vm.ts b/apps/sim/lib/execution/isolated-vm.ts index 0efeee09b4..7a733e0b42 100644 --- a/apps/sim/lib/execution/isolated-vm.ts +++ b/apps/sim/lib/execution/isolated-vm.ts @@ -619,7 +619,6 @@ function cleanupWorker(workerId: number) { workerInfo.activeExecutions = 0 workers.delete(workerId) - logger.info('Worker removed from pool', { workerId, poolSize: workers.size }) } function resetWorkerIdleTimeout(workerId: number) { @@ -635,7 +634,6 @@ function resetWorkerIdleTimeout(workerId: number) { workerInfo.idleTimeout = setTimeout(() => { const w = workers.get(workerId) if (w && w.activeExecutions === 0) { - logger.info('Cleaning up idle worker', { workerId }) cleanupWorker(workerId) } }, WORKER_IDLE_TIMEOUT_MS) diff --git a/apps/sim/lib/workflows/executor/execution-events.ts b/apps/sim/lib/workflows/executor/execution-events.ts index 83b21f09d8..ff539bf54d 100644 --- a/apps/sim/lib/workflows/executor/execution-events.ts +++ b/apps/sim/lib/workflows/executor/execution-events.ts @@ -271,6 +271,7 @@ export function createSSECallbacks(options: SSECallbackOptions) { startedAt: string executionOrder: number endedAt: string + childWorkflowInstanceId?: string }, iterationContext?: IterationContext, childWorkflowContext?: ChildWorkflowContext @@ -291,10 +292,9 @@ export function createSSECallbacks(options: SSECallbackOptions) { } : {} - // Extract per-invocation instance ID and strip from user-visible output - const childWorkflowInstanceId: string | undefined = - callbackData.output?._childWorkflowInstanceId - const instanceData = childWorkflowInstanceId ? { childWorkflowInstanceId } : {} + const instanceData = callbackData.childWorkflowInstanceId + ? { childWorkflowInstanceId: callbackData.childWorkflowInstanceId } + : {} if (hasError) { sendEvent({ @@ -318,7 +318,6 @@ export function createSSECallbacks(options: SSECallbackOptions) { }, }) } else { - const { _childWorkflowInstanceId: _stripped, ...strippedOutput } = callbackData.output ?? {} sendEvent({ type: 'block:completed', timestamp: new Date().toISOString(), @@ -329,7 +328,7 @@ export function createSSECallbacks(options: SSECallbackOptions) { blockName, blockType, input: callbackData.input, - output: strippedOutput, + output: callbackData.output, durationMs: callbackData.executionTime || 0, startedAt: callbackData.startedAt, executionOrder: callbackData.executionOrder, From f4f293aaa0fc3591f78698c48cbb634529bbfee0 Mon Sep 17 00:00:00 2001 From: waleed Date: Sun, 22 Feb 2026 23:40:51 -0800 Subject: [PATCH 6/9] feat(terminal): real-time child workflow block propagation in console --- .../app/api/workflows/[id]/execute/route.ts | 22 ++++++ .../hooks/use-workflow-execution.ts | 28 ++++++- .../utils/workflow-execution-utils.ts | 18 +++++ apps/sim/executor/execution/block-executor.ts | 4 + apps/sim/executor/execution/executor.ts | 1 + apps/sim/executor/execution/types.ts | 13 ++++ .../handlers/workflow/workflow-handler.ts | 76 +++++++++++++++++++ apps/sim/executor/types.ts | 9 +++ apps/sim/hooks/use-execution-stream.ts | 5 ++ .../lib/workflows/executor/execution-core.ts | 3 +- .../workflows/executor/execution-events.ts | 42 +++++++++- 11 files changed, 218 insertions(+), 3 deletions(-) diff --git a/apps/sim/app/api/workflows/[id]/execute/route.ts b/apps/sim/app/api/workflows/[id]/execute/route.ts index 0e12e08e55..521e6cae5f 100644 --- a/apps/sim/app/api/workflows/[id]/execute/route.ts +++ b/apps/sim/app/api/workflows/[id]/execute/route.ts @@ -919,12 +919,34 @@ export async function POST(req: NextRequest, { params }: { params: Promise<{ id: selectedOutputs ) + const onChildWorkflowInstanceReady = ( + blockId: string, + childWorkflowInstanceId: string, + iterationContext?: IterationContext + ) => { + sendEvent({ + type: 'block:childWorkflowStarted', + timestamp: new Date().toISOString(), + executionId, + workflowId, + data: { + blockId, + childWorkflowInstanceId, + ...(iterationContext && { + iterationCurrent: iterationContext.iterationCurrent, + iterationContainerId: iterationContext.iterationContainerId, + }), + }, + }) + } + const result = await executeWorkflowCore({ snapshot, callbacks: { onBlockStart, onBlockComplete, onStream, + onChildWorkflowInstanceReady, }, loggingSession, abortSignal: timeoutController.signal, 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 531d52285e..f1bcca15b7 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 @@ -549,7 +549,27 @@ export function useWorkflowExecution() { } } - return { onBlockStarted, onBlockCompleted, onBlockError } + const onBlockChildWorkflowStarted = (data: { + blockId: string + childWorkflowInstanceId: string + iterationCurrent?: number + iterationContainerId?: string + }) => { + if (isStaleExecution()) return + updateConsole( + data.blockId, + { + childWorkflowInstanceId: data.childWorkflowInstanceId, + ...(data.iterationCurrent !== undefined && { iterationCurrent: data.iterationCurrent }), + ...(data.iterationContainerId !== undefined && { + iterationContainerId: data.iterationContainerId, + }), + }, + executionIdRef.current + ) + } + + return { onBlockStarted, onBlockCompleted, onBlockError, onBlockChildWorkflowStarted } }, [addConsole, setActiveBlocks, setBlockRunStatus, setEdgeRunStatus, updateConsole] ) @@ -1349,6 +1369,7 @@ export function useWorkflowExecution() { onBlockStarted: blockHandlers.onBlockStarted, onBlockCompleted: blockHandlers.onBlockCompleted, onBlockError: blockHandlers.onBlockError, + onBlockChildWorkflowStarted: blockHandlers.onBlockChildWorkflowStarted, onStreamChunk: (data) => { const existing = streamedContent.get(data.blockId) || '' @@ -1946,6 +1967,7 @@ export function useWorkflowExecution() { onBlockStarted: blockHandlers.onBlockStarted, onBlockCompleted: blockHandlers.onBlockCompleted, onBlockError: blockHandlers.onBlockError, + onBlockChildWorkflowStarted: blockHandlers.onBlockChildWorkflowStarted, onExecutionCompleted: (data) => { if (data.success) { @@ -2174,6 +2196,10 @@ export function useWorkflowExecution() { clearOnce() handlers.onBlockError(data) }, + onBlockChildWorkflowStarted: (data) => { + clearOnce() + handlers.onBlockChildWorkflowStarted(data) + }, onExecutionCompleted: () => { const currentId = useExecutionStore .getState() diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/utils/workflow-execution-utils.ts b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/utils/workflow-execution-utils.ts index 16130169ad..117f78aa22 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/utils/workflow-execution-utils.ts +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/utils/workflow-execution-utils.ts @@ -220,6 +220,24 @@ export async function executeWorkflowWithFullLogging( break } + case 'block:childWorkflowStarted': { + const { updateConsole } = useTerminalConsoleStore.getState() + updateConsole( + event.data.blockId, + { + childWorkflowInstanceId: event.data.childWorkflowInstanceId, + ...(event.data.iterationCurrent !== undefined && { + iterationCurrent: event.data.iterationCurrent, + }), + ...(event.data.iterationContainerId !== undefined && { + iterationContainerId: event.data.iterationContainerId, + }), + }, + executionId + ) + break + } + case 'execution:completed': executionResult = { success: event.data.success, diff --git a/apps/sim/executor/execution/block-executor.ts b/apps/sim/executor/execution/block-executor.ts index 86e815c847..6d7f746d4e 100644 --- a/apps/sim/executor/execution/block-executor.ts +++ b/apps/sim/executor/execution/block-executor.ts @@ -208,6 +208,8 @@ export class BlockExecutor { parallelId?: string branchIndex?: number branchTotal?: number + originalBlockId?: string + isLoopNode?: boolean } { const metadata = node?.metadata ?? {} return { @@ -216,6 +218,8 @@ export class BlockExecutor { parallelId: metadata.parallelId, branchIndex: metadata.branchIndex, branchTotal: metadata.branchTotal, + originalBlockId: metadata.originalBlockId, + isLoopNode: metadata.isLoopNode, } } diff --git a/apps/sim/executor/execution/executor.ts b/apps/sim/executor/execution/executor.ts index 65ed626d13..57b7b905fd 100644 --- a/apps/sim/executor/execution/executor.ts +++ b/apps/sim/executor/execution/executor.ts @@ -322,6 +322,7 @@ export class DAGExecutor { onStream: this.contextExtensions.onStream, onBlockStart: this.contextExtensions.onBlockStart, onBlockComplete: this.contextExtensions.onBlockComplete, + onChildWorkflowInstanceReady: this.contextExtensions.onChildWorkflowInstanceReady, abortSignal: this.contextExtensions.abortSignal, childWorkflowContext: this.contextExtensions.childWorkflowContext, includeFileBase64: this.contextExtensions.includeFileBase64, diff --git a/apps/sim/executor/execution/types.ts b/apps/sim/executor/execution/types.ts index 5ae6036358..8edb688123 100644 --- a/apps/sim/executor/execution/types.ts +++ b/apps/sim/executor/execution/types.ts @@ -83,6 +83,12 @@ export interface ExecutionCallbacks { iterationContext?: IterationContext, childWorkflowContext?: ChildWorkflowContext ) => Promise + /** Fires immediately after instanceId is generated, before child execution begins. */ + onChildWorkflowInstanceReady?: ( + blockId: string, + childWorkflowInstanceId: string, + iterationContext?: IterationContext + ) => void } export interface ContextExtensions { @@ -142,6 +148,13 @@ export interface ContextExtensions { /** Context identifying this execution as a child of a workflow block */ childWorkflowContext?: ChildWorkflowContext + /** Fires immediately after instanceId is generated, before child execution begins. */ + onChildWorkflowInstanceReady?: ( + blockId: string, + childWorkflowInstanceId: string, + iterationContext?: IterationContext + ) => void + /** * Run-from-block configuration. When provided, executor runs in partial * execution mode starting from the specified block. diff --git a/apps/sim/executor/handlers/workflow/workflow-handler.ts b/apps/sim/executor/handlers/workflow/workflow-handler.ts index 7817e9463e..46bf82aac2 100644 --- a/apps/sim/executor/handlers/workflow/workflow-handler.ts +++ b/apps/sim/executor/handlers/workflow/workflow-handler.ts @@ -6,6 +6,7 @@ import type { BlockOutput } from '@/blocks/types' import { Executor } from '@/executor' import { BlockType, DEFAULTS, HTTP } from '@/executor/constants' import { ChildWorkflowError } from '@/executor/errors/child-workflow-error' +import type { IterationContext } from '@/executor/execution/types' import type { BlockHandler, ExecutionContext, @@ -44,6 +45,40 @@ export class WorkflowBlockHandler implements BlockHandler { ctx: ExecutionContext, block: SerializedBlock, inputs: Record + ): Promise { + return this._executeCore(ctx, block, inputs) + } + + async executeWithNode( + ctx: ExecutionContext, + block: SerializedBlock, + inputs: Record, + nodeMetadata: { + nodeId: string + loopId?: string + parallelId?: string + branchIndex?: number + branchTotal?: number + originalBlockId?: string + isLoopNode?: boolean + } + ): Promise { + return this._executeCore(ctx, block, inputs, nodeMetadata) + } + + private async _executeCore( + ctx: ExecutionContext, + block: SerializedBlock, + inputs: Record, + nodeMetadata?: { + nodeId: string + loopId?: string + parallelId?: string + branchIndex?: number + branchTotal?: number + originalBlockId?: string + isLoopNode?: boolean + } ): Promise { logger.info(`Executing workflow block: ${block.id}`) @@ -122,6 +157,12 @@ export class WorkflowBlockHandler implements BlockHandler { const childDepth = (ctx.childWorkflowContext?.depth ?? 0) + 1 const shouldPropagateCallbacks = childDepth <= DEFAULTS.MAX_SSE_CHILD_DEPTH + if (nodeMetadata && shouldPropagateCallbacks) { + const effectiveBlockId = nodeMetadata.originalBlockId ?? nodeMetadata.nodeId + const iterationContext = this.getIterationContext(ctx, nodeMetadata) + ctx.onChildWorkflowInstanceReady?.(effectiveBlockId, instanceId, iterationContext) + } + const subExecutor = new Executor({ workflow: childWorkflow.serializedState, workflowInput: childWorkflowInput, @@ -138,6 +179,7 @@ export class WorkflowBlockHandler implements BlockHandler { onBlockStart: ctx.onBlockStart, onBlockComplete: ctx.onBlockComplete, onStream: ctx.onStream as ((streamingExecution: unknown) => Promise) | undefined, + onChildWorkflowInstanceReady: ctx.onChildWorkflowInstanceReady, childWorkflowContext: { parentBlockId: instanceId, workflowName: childWorkflowName, @@ -208,6 +250,40 @@ export class WorkflowBlockHandler implements BlockHandler { } } + private getIterationContext( + ctx: ExecutionContext, + nodeMetadata: { + loopId?: string + parallelId?: string + branchIndex?: number + branchTotal?: number + isLoopNode?: boolean + } + ): IterationContext | undefined { + if (nodeMetadata.branchIndex !== undefined && nodeMetadata.parallelId !== undefined) { + return { + iterationCurrent: nodeMetadata.branchIndex, + iterationTotal: nodeMetadata.branchTotal, + iterationType: 'parallel', + iterationContainerId: nodeMetadata.parallelId, + } + } + + if (nodeMetadata.isLoopNode && nodeMetadata.loopId) { + const loopScope = ctx.loopExecutions?.get(nodeMetadata.loopId) + if (loopScope && loopScope.iteration !== undefined) { + return { + iterationCurrent: loopScope.iteration, + iterationTotal: loopScope.maxIterations, + iterationType: 'loop', + iterationContainerId: nodeMetadata.loopId, + } + } + } + + return undefined + } + /** * 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 4fd744270a..e930709a70 100644 --- a/apps/sim/executor/types.ts +++ b/apps/sim/executor/types.ts @@ -259,6 +259,13 @@ export interface ExecutionContext { /** Context identifying this execution as a child of a workflow block */ childWorkflowContext?: ChildWorkflowContext + /** Fires immediately after instanceId is generated, before child execution begins. */ + onChildWorkflowInstanceReady?: ( + blockId: string, + childWorkflowInstanceId: string, + iterationContext?: IterationContext + ) => void + /** * AbortSignal for cancellation support. * When the signal is aborted, execution should stop gracefully. @@ -361,6 +368,8 @@ export interface BlockHandler { parallelId?: string branchIndex?: number branchTotal?: number + originalBlockId?: string + isLoopNode?: boolean } ) => Promise } diff --git a/apps/sim/hooks/use-execution-stream.ts b/apps/sim/hooks/use-execution-stream.ts index 2ab98059fb..12a7dc8cab 100644 --- a/apps/sim/hooks/use-execution-stream.ts +++ b/apps/sim/hooks/use-execution-stream.ts @@ -1,6 +1,7 @@ import { useCallback } from 'react' import { createLogger } from '@sim/logger' import type { + BlockChildWorkflowStartedData, BlockCompletedData, BlockErrorData, BlockStartedData, @@ -83,6 +84,9 @@ async function processSSEStream( case 'block:error': callbacks.onBlockError?.(event.data) break + case 'block:childWorkflowStarted': + callbacks.onBlockChildWorkflowStarted?.(event.data) + break case 'stream:chunk': callbacks.onStreamChunk?.(event.data) break @@ -110,6 +114,7 @@ export interface ExecutionStreamCallbacks { onBlockStarted?: (data: BlockStartedData) => void onBlockCompleted?: (data: BlockCompletedData) => void onBlockError?: (data: BlockErrorData) => void + onBlockChildWorkflowStarted?: (data: BlockChildWorkflowStartedData) => void onStreamChunk?: (data: StreamChunkData) => void onStreamDone?: (data: StreamDoneData) => void } diff --git a/apps/sim/lib/workflows/executor/execution-core.ts b/apps/sim/lib/workflows/executor/execution-core.ts index 2835c6f410..f657de32b9 100644 --- a/apps/sim/lib/workflows/executor/execution-core.ts +++ b/apps/sim/lib/workflows/executor/execution-core.ts @@ -129,7 +129,7 @@ export async function executeWorkflowCore( const { metadata, workflow, input, workflowVariables, selectedOutputs } = snapshot const { requestId, workflowId, userId, triggerType, executionId, triggerBlockId, useDraftState } = metadata - const { onBlockStart, onBlockComplete, onStream } = callbacks + const { onBlockStart, onBlockComplete, onStream, onChildWorkflowInstanceReady } = callbacks const providedWorkspaceId = metadata.workspaceId if (!providedWorkspaceId) { @@ -329,6 +329,7 @@ export async function executeWorkflowCore( includeFileBase64, base64MaxBytes, stopAfterBlockId: resolvedStopAfterBlockId, + onChildWorkflowInstanceReady, } const executorInstance = new Executor({ diff --git a/apps/sim/lib/workflows/executor/execution-events.ts b/apps/sim/lib/workflows/executor/execution-events.ts index ff539bf54d..09044acdc5 100644 --- a/apps/sim/lib/workflows/executor/execution-events.ts +++ b/apps/sim/lib/workflows/executor/execution-events.ts @@ -9,6 +9,7 @@ export type ExecutionEventType = | 'block:started' | 'block:completed' | 'block:error' + | 'block:childWorkflowStarted' | 'stream:chunk' | 'stream:done' @@ -141,6 +142,22 @@ export interface BlockErrorEvent extends BaseExecutionEvent { } } +/** + * Block child workflow started event — fires when a workflow block generates its instanceId, + * before child execution begins. Allows clients to pre-associate the running entry with + * the instanceId so child block events can be correlated in real-time. + */ +export interface BlockChildWorkflowStartedEvent extends BaseExecutionEvent { + type: 'block:childWorkflowStarted' + workflowId: string + data: { + blockId: string + childWorkflowInstanceId: string + iterationCurrent?: number + iterationContainerId?: string + } +} + /** * Stream chunk event (for agent blocks) */ @@ -175,6 +192,7 @@ export type ExecutionEvent = | BlockStartedEvent | BlockCompletedEvent | BlockErrorEvent + | BlockChildWorkflowStartedEvent | StreamChunkEvent | StreamDoneEvent @@ -185,6 +203,7 @@ export type ExecutionCancelledData = ExecutionCancelledEvent['data'] export type BlockStartedData = BlockStartedEvent['data'] export type BlockCompletedData = BlockCompletedEvent['data'] export type BlockErrorData = BlockErrorEvent['data'] +export type BlockChildWorkflowStartedData = BlockChildWorkflowStartedEvent['data'] export type StreamChunkData = StreamChunkEvent['data'] export type StreamDoneData = StreamDoneEvent['data'] @@ -374,5 +393,26 @@ export function createSSECallbacks(options: SSECallbackOptions) { } } - return { sendEvent, onBlockStart, onBlockComplete, onStream } + const onChildWorkflowInstanceReady = ( + blockId: string, + childWorkflowInstanceId: string, + iterationContext?: IterationContext + ) => { + sendEvent({ + type: 'block:childWorkflowStarted', + timestamp: new Date().toISOString(), + executionId, + workflowId, + data: { + blockId, + childWorkflowInstanceId, + ...(iterationContext && { + iterationCurrent: iterationContext.iterationCurrent, + iterationContainerId: iterationContext.iterationContainerId, + }), + }, + }) + } + + return { sendEvent, onBlockStart, onBlockComplete, onStream, onChildWorkflowInstanceReady } } From 830e1435ef121bbdc3b48c6745e9be788638e3b9 Mon Sep 17 00:00:00 2001 From: waleed Date: Sun, 22 Feb 2026 23:59:06 -0800 Subject: [PATCH 7/9] fix(terminal): align parallel guard in WorkflowBlockHandler.getIterationContext with BlockExecutor --- apps/sim/executor/handlers/workflow/workflow-handler.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/sim/executor/handlers/workflow/workflow-handler.ts b/apps/sim/executor/handlers/workflow/workflow-handler.ts index 46bf82aac2..387aece557 100644 --- a/apps/sim/executor/handlers/workflow/workflow-handler.ts +++ b/apps/sim/executor/handlers/workflow/workflow-handler.ts @@ -260,7 +260,7 @@ export class WorkflowBlockHandler implements BlockHandler { isLoopNode?: boolean } ): IterationContext | undefined { - if (nodeMetadata.branchIndex !== undefined && nodeMetadata.parallelId !== undefined) { + if (nodeMetadata.branchIndex !== undefined && nodeMetadata.branchTotal !== undefined) { return { iterationCurrent: nodeMetadata.branchIndex, iterationTotal: nodeMetadata.branchTotal, From cd999f38e5bbf5180ec3fa96617dafc2fbfcf88a Mon Sep 17 00:00:00 2001 From: waleed Date: Mon, 23 Feb 2026 00:03:47 -0800 Subject: [PATCH 8/9] fix(terminal): fire onChildWorkflowInstanceReady regardless of nodeMetadata presence --- .../sim/executor/handlers/workflow/workflow-handler.ts | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/apps/sim/executor/handlers/workflow/workflow-handler.ts b/apps/sim/executor/handlers/workflow/workflow-handler.ts index 387aece557..8bd374947e 100644 --- a/apps/sim/executor/handlers/workflow/workflow-handler.ts +++ b/apps/sim/executor/handlers/workflow/workflow-handler.ts @@ -157,9 +157,13 @@ export class WorkflowBlockHandler implements BlockHandler { const childDepth = (ctx.childWorkflowContext?.depth ?? 0) + 1 const shouldPropagateCallbacks = childDepth <= DEFAULTS.MAX_SSE_CHILD_DEPTH - if (nodeMetadata && shouldPropagateCallbacks) { - const effectiveBlockId = nodeMetadata.originalBlockId ?? nodeMetadata.nodeId - const iterationContext = this.getIterationContext(ctx, nodeMetadata) + if (shouldPropagateCallbacks) { + const effectiveBlockId = nodeMetadata + ? (nodeMetadata.originalBlockId ?? nodeMetadata.nodeId) + : block.id + const iterationContext = nodeMetadata + ? this.getIterationContext(ctx, nodeMetadata) + : undefined ctx.onChildWorkflowInstanceReady?.(effectiveBlockId, instanceId, iterationContext) } From d5537f58d8a33d47e85e99b0bc4107b9e4a989b1 Mon Sep 17 00:00:00 2001 From: waleed Date: Mon, 23 Feb 2026 00:17:32 -0800 Subject: [PATCH 9/9] fix(terminal): use shared isWorkflowBlockType from executor/constants --- .../w/[workflowId]/components/terminal/utils.ts | 11 +---------- 1 file changed, 1 insertion(+), 10 deletions(-) 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 96998e5e0f..a31bf2cc1d 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 @@ -8,6 +8,7 @@ import { XCircleIcon, } from 'lucide-react' import { getBlock } from '@/blocks' +import { isWorkflowBlockType } from '@/executor/constants' import { TERMINAL_BLOCK_COLUMN_WIDTH } from '@/stores/constants' import type { ConsoleEntry } from '@/stores/terminal' @@ -100,16 +101,6 @@ export function getBlockColor(blockType: string): string { return '#6b7280' } -/** - * Checks if a block type is a workflow-calling block (calls a child workflow). - * Covers both `workflow` (new) and `workflow_input` (legacy) block variants — - * both are handled by WorkflowBlockHandler and emit child workflow events. - */ -export function isWorkflowBlockType(blockType: string): boolean { - const t = blockType?.toLowerCase() - return t === 'workflow' || t === 'workflow_input' -} - /** * Determines if a keyboard event originated from a text-editable element */