diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/terminal/hooks/use-terminal-filters.ts b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/terminal/hooks/use-terminal-filters.ts index c712864cf..0d788410b 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/terminal/hooks/use-terminal-filters.ts +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/terminal/hooks/use-terminal-filters.ts @@ -88,21 +88,38 @@ export function useTerminalFilters() { let result = entries if (hasActiveFilters) { - result = entries.filter((entry) => { - // Block ID filter - if (filters.blockIds.size > 0 && !filters.blockIds.has(entry.blockId)) { - return false - } + // Determine which top-level entries pass the filters + const visibleBlockIds = new Set() + for (const entry of entries) { + if (entry.parentWorkflowBlockId) continue - // Status filter - if (filters.statuses.size > 0) { + let passes = true + if (filters.blockIds.size > 0 && !filters.blockIds.has(entry.blockId)) { + passes = false + } + if (passes && filters.statuses.size > 0) { const isError = !!entry.error const hasStatus = isError ? filters.statuses.has('error') : filters.statuses.has('info') - if (!hasStatus) return false + if (!hasStatus) passes = false } + if (passes) { + visibleBlockIds.add(entry.blockId) + } + } - return true - }) + // Propagate visibility to child workflow entries (handles arbitrary nesting). + // Keep iterating until no new children are discovered. + let prevSize = 0 + while (visibleBlockIds.size !== prevSize) { + prevSize = visibleBlockIds.size + for (const entry of entries) { + if (entry.parentWorkflowBlockId && visibleBlockIds.has(entry.parentWorkflowBlockId)) { + visibleBlockIds.add(entry.blockId) + } + } + } + + result = entries.filter((entry) => visibleBlockIds.has(entry.blockId)) } // Sort by executionOrder (monotonically increasing integer from server) 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 8b19a3a35..6262d434c 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 @@ -339,7 +339,8 @@ const SubflowNodeRow = memo(function SubflowNodeRow({ }) /** - * Entry node component - dispatches to appropriate component based on node type + * Entry node component - dispatches to appropriate component based on node type. + * Handles recursive rendering for workflow nodes with arbitrarily nested children. */ const EntryNodeRow = memo(function EntryNodeRow({ node, @@ -380,6 +381,98 @@ const EntryNodeRow = memo(function EntryNodeRow({ ) } + if (nodeType === 'workflow') { + const { entry, children } = node + const BlockIcon = getBlockIcon(entry.blockType) + const hasError = Boolean(entry.error) || children.some((c) => c.entry.error) + 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 isRunning = Boolean(entry.isRunning) + const isCanceled = Boolean(entry.isCanceled) + + return ( +
+ {/* Workflow Block Header */} +
{ + e.stopPropagation() + if (hasChildren) { + onToggleNode(nodeId) + } + onSelectEntry(entry) + }} + > +
+
+ {BlockIcon && } +
+ + {entry.blockName} + + {hasChildren && ( + + )} +
+ + + +
+ + {/* Nested Child Workflow Blocks (recursive) */} + {isExpanded && hasChildren && ( +
+ {children.map((child) => ( + + ))} +
+ )} +
+ ) + } + // Regular block return ( { const blocksMap = new Map() allWorkflowEntries.forEach((entry) => { + // Skip child workflow entries — they use synthetic IDs and shouldn't appear in filters + if (entry.parentWorkflowBlockId) return if (!blocksMap.has(entry.blockId)) { blocksMap.set(entry.blockId, { blockId: entry.blockId, @@ -667,19 +762,22 @@ export const Terminal = memo(function Terminal() { const newestExec = executionGroups[0] - // Collect all node IDs that should be expanded (subflows and their iterations) + // Collect all expandable node IDs recursively (subflows, iterations, and workflow nodes) 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 collectExpandableNodes = (nodes: EntryNode[]) => { + for (const node of nodes) { + if (node.children.length === 0) continue + if ( + node.nodeType === 'subflow' || + node.nodeType === 'iteration' || + node.nodeType === 'workflow' + ) { + nodeIdsToExpand.push(node.entry.id) + collectExpandableNodes(node.children) } } } + collectExpandableNodes(newestExec.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 0c285a7b9..1bb97af85 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 @@ -120,10 +120,10 @@ 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 + * Entry node for tree structure - represents a block, subflow, iteration, or workflow */ export interface EntryNode { /** The console entry (for blocks) or synthetic entry (for subflows/iterations) */ @@ -175,12 +175,17 @@ interface IterationGroup { * Sorts by start time to ensure chronological order. */ function buildEntryTree(entries: ConsoleEntry[]): EntryNode[] { - // Separate regular blocks from iteration entries + // Separate regular blocks from iteration entries and child workflow entries const regularBlocks: ConsoleEntry[] = [] const iterationEntries: ConsoleEntry[] = [] + const childWorkflowEntries = new Map() for (const entry of entries) { - if (entry.iterationType && entry.iterationCurrent !== undefined) { + if (entry.parentWorkflowBlockId) { + const existing = childWorkflowEntries.get(entry.parentWorkflowBlockId) || [] + existing.push(entry) + childWorkflowEntries.set(entry.parentWorkflowBlockId, existing) + } else if (entry.iterationType && entry.iterationCurrent !== undefined) { iterationEntries.push(entry) } else { regularBlocks.push(entry) @@ -338,12 +343,53 @@ function buildEntryTree(entries: ConsoleEntry[]): EntryNode[] { }) } - // Build nodes for regular blocks - const regularNodes: EntryNode[] = regularBlocks.map((entry) => ({ - entry, - children: [], - nodeType: 'block' as const, - })) + /** + * Recursively builds child nodes for workflow blocks. + * Handles multi-level nesting where a child workflow block itself has children. + */ + const buildWorkflowChildNodes = (parentBlockId: string): EntryNode[] => { + const childEntries = childWorkflowEntries.get(parentBlockId) + if (!childEntries || childEntries.length === 0) return [] + + childEntries.sort((a, b) => { + const aTime = new Date(a.startedAt || a.timestamp).getTime() + const bTime = new Date(b.startedAt || b.timestamp).getTime() + return aTime - bTime + }) + + return childEntries.map((child) => { + const nestedChildren = buildWorkflowChildNodes(child.blockId) + if (nestedChildren.length > 0) { + return { + entry: child, + children: nestedChildren, + nodeType: 'workflow' as const, + } + } + return { + entry: child, + children: [], + nodeType: 'block' as const, + } + }) + } + + // Build nodes for regular blocks, promoting workflow blocks with children to 'workflow' nodes + const regularNodes: EntryNode[] = regularBlocks.map((entry) => { + const childNodes = buildWorkflowChildNodes(entry.blockId) + if (childNodes.length > 0) { + return { + entry, + children: childNodes, + nodeType: 'workflow' as const, + } + } + return { + entry, + children: [], + nodeType: 'block' as const, + } + }) // Combine all nodes and sort by executionOrder ascending (oldest first, top-down) const allNodes = [...subflowNodes, ...regularNodes] 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 fceade9a1..f79833c84 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 @@ -38,7 +38,11 @@ import { useCurrentWorkflowExecution, useExecutionStore } from '@/stores/executi import { useNotificationStore } from '@/stores/notifications' import { useVariablesStore } from '@/stores/panel' import { useEnvironmentStore } from '@/stores/settings/environment' -import { useTerminalConsoleStore } from '@/stores/terminal' +import { + extractChildWorkflowEntries, + hasChildTraceSpans, + useTerminalConsoleStore, +} from '@/stores/terminal' import { useWorkflowDiffStore } from '@/stores/workflow-diff' import { useWorkflowRegistry } from '@/stores/workflows/registry/store' import { mergeSubblockState } from '@/stores/workflows/utils' @@ -517,6 +521,20 @@ export function useWorkflowExecution() { addConsoleEntry(data, data.output as NormalizedBlockOutput) } + // Extract child workflow trace spans into separate console entries + if (data.blockType === 'workflow' && hasChildTraceSpans(data.output)) { + const childEntries = extractChildWorkflowEntries({ + parentBlockId: data.blockId, + executionId: executionIdRef.current, + executionOrder: data.executionOrder, + workflowId: workflowId!, + childTraceSpans: data.output.childTraceSpans, + }) + for (const entry of childEntries) { + addConsole(entry) + } + } + if (onBlockCompleteCallback) { onBlockCompleteCallback(data.blockId, data.output).catch((error) => { logger.error('Error in onBlockComplete callback:', error) 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 bf5e2e531..dbada8da0 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 @@ -1,7 +1,11 @@ import { v4 as uuidv4 } from 'uuid' import type { ExecutionResult, StreamingExecution } from '@/executor/types' import { useExecutionStore } from '@/stores/execution' -import { useTerminalConsoleStore } from '@/stores/terminal' +import { + extractChildWorkflowEntries, + hasChildTraceSpans, + useTerminalConsoleStore, +} from '@/stores/terminal' import { useWorkflowRegistry } from '@/stores/workflows/registry/store' import { useWorkflowStore } from '@/stores/workflows/workflow/store' @@ -149,6 +153,20 @@ export async function executeWorkflowWithFullLogging( iterationContainerId: event.data.iterationContainerId, }) + // Extract child workflow trace spans into separate console entries + if (event.data.blockType === 'workflow' && hasChildTraceSpans(event.data.output)) { + const childEntries = extractChildWorkflowEntries({ + parentBlockId: event.data.blockId, + executionId, + executionOrder: event.data.executionOrder, + workflowId: activeWorkflowId, + childTraceSpans: event.data.output.childTraceSpans, + }) + for (const entry of childEntries) { + addConsole(entry) + } + } + if (options.onBlockComplete) { options.onBlockComplete(event.data.blockId, event.data.output).catch(() => {}) } diff --git a/apps/sim/stores/terminal/console/index.ts b/apps/sim/stores/terminal/console/index.ts index d2b667954..40bb3a8fc 100644 --- a/apps/sim/stores/terminal/console/index.ts +++ b/apps/sim/stores/terminal/console/index.ts @@ -1,3 +1,4 @@ export { indexedDBStorage } from './storage' export { useTerminalConsoleStore } from './store' export type { ConsoleEntry, ConsoleStore, ConsoleUpdate } from './types' +export { extractChildWorkflowEntries, hasChildTraceSpans } from './utils' diff --git a/apps/sim/stores/terminal/console/store.ts b/apps/sim/stores/terminal/console/store.ts index 9fddbf3ef..caa2bff1f 100644 --- a/apps/sim/stores/terminal/console/store.ts +++ b/apps/sim/stores/terminal/console/store.ts @@ -224,7 +224,11 @@ export const useTerminalConsoleStore = create()( const newEntry = get().entries[0] - if (newEntry?.error && newEntry.blockType !== 'cancelled') { + if ( + newEntry?.error && + newEntry.blockType !== 'cancelled' && + !newEntry.parentWorkflowBlockId + ) { notifyBlockError({ error: newEntry.error, blockName: newEntry.blockName || 'Unknown Block', @@ -249,7 +253,9 @@ export const useTerminalConsoleStore = create()( })), exportConsoleCSV: (workflowId: string) => { - const entries = get().entries.filter((entry) => entry.workflowId === workflowId) + const entries = get().entries.filter( + (entry) => entry.workflowId === workflowId && !entry.parentWorkflowBlockId + ) if (entries.length === 0) { return diff --git a/apps/sim/stores/terminal/console/types.ts b/apps/sim/stores/terminal/console/types.ts index e057854d8..6283538a0 100644 --- a/apps/sim/stores/terminal/console/types.ts +++ b/apps/sim/stores/terminal/console/types.ts @@ -22,6 +22,7 @@ export interface ConsoleEntry { iterationTotal?: number iterationType?: SubflowType iterationContainerId?: string + parentWorkflowBlockId?: string isRunning?: boolean isCanceled?: boolean } @@ -44,6 +45,7 @@ export interface ConsoleUpdate { iterationTotal?: number iterationType?: SubflowType iterationContainerId?: string + parentWorkflowBlockId?: string } export interface ConsoleStore { diff --git a/apps/sim/stores/terminal/console/utils.ts b/apps/sim/stores/terminal/console/utils.ts new file mode 100644 index 000000000..a18b5f13d --- /dev/null +++ b/apps/sim/stores/terminal/console/utils.ts @@ -0,0 +1,78 @@ +import type { TraceSpan } from '@/lib/logs/types' +import type { ConsoleEntry } from '@/stores/terminal/console/types' + +/** + * Parameters for extracting child workflow entries from trace spans + */ +interface ExtractChildWorkflowEntriesParams { + parentBlockId: string + executionId: string + executionOrder: number + workflowId: string + childTraceSpans: TraceSpan[] +} + +/** + * Extracts child workflow trace spans into console entry payloads. + * Handles recursive nesting for multi-level child workflows by flattening + * nested children with a parent block ID chain. + */ +export function extractChildWorkflowEntries( + params: ExtractChildWorkflowEntriesParams +): Omit[] { + const { parentBlockId, executionId, executionOrder, workflowId, childTraceSpans } = params + const entries: Omit[] = [] + + for (const span of childTraceSpans) { + if (!span.blockId) continue + + const childBlockId = `child-${parentBlockId}-${span.blockId}` + + entries.push({ + blockId: childBlockId, + blockName: span.name || 'Unknown Block', + blockType: span.type || 'unknown', + parentWorkflowBlockId: parentBlockId, + input: span.input || {}, + output: (span.output || {}) as ConsoleEntry['output'], + durationMs: span.duration, + startedAt: span.startTime, + endedAt: span.endTime, + success: span.status !== 'error', + error: + span.status === 'error' + ? (span.output?.error as string) || `${span.name || 'Block'} failed` + : undefined, + executionId, + executionOrder, + workflowId, + }) + + // Recursively extract nested child workflow spans + if (span.children && span.children.length > 0 && span.type === 'workflow') { + const nestedEntries = extractChildWorkflowEntries({ + parentBlockId: childBlockId, + executionId, + executionOrder, + workflowId, + childTraceSpans: span.children, + }) + entries.push(...nestedEntries) + } + } + + return entries +} + +/** + * Checks if a block completed event output contains child trace spans + */ +export function hasChildTraceSpans(output: unknown): output is Record & { + childTraceSpans: TraceSpan[] +} { + return ( + output !== null && + typeof output === 'object' && + Array.isArray((output as Record).childTraceSpans) + ) +} diff --git a/apps/sim/stores/terminal/index.ts b/apps/sim/stores/terminal/index.ts index e031ce303..9b65ac1c0 100644 --- a/apps/sim/stores/terminal/index.ts +++ b/apps/sim/stores/terminal/index.ts @@ -1,4 +1,4 @@ export type { ConsoleEntry, ConsoleStore, ConsoleUpdate } from './console' -export { useTerminalConsoleStore } from './console' +export { extractChildWorkflowEntries, hasChildTraceSpans, useTerminalConsoleStore } from './console' export { useTerminalStore } from './store' export type { TerminalState } from './types'