Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -57,40 +57,6 @@ function useSetToggle() {
)
}

/**
* Generates a unique key for a trace span
*/
function getSpanKey(span: TraceSpan): string {
if (span.id) {
return span.id
}
const name = span.name || 'span'
const start = span.startTime || 'unknown-start'
const end = span.endTime || 'unknown-end'
return `${name}|${start}|${end}`
}

/**
* Merges multiple arrays of trace span children, deduplicating by span key
*/
function mergeTraceSpanChildren(...groups: TraceSpan[][]): TraceSpan[] {
const merged: TraceSpan[] = []
const seen = new Set<string>()

groups.forEach((group) => {
group.forEach((child) => {
const key = getSpanKey(child)
if (seen.has(key)) {
return
}
seen.add(key)
merged.push(child)
})
})

return merged
}

/**
* Parses a time value to milliseconds
*/
Expand All @@ -116,34 +82,16 @@ function hasErrorInTree(span: TraceSpan): boolean {

/**
* Normalizes and sorts trace spans recursively.
* Merges children from both span.children and span.output.childTraceSpans,
* deduplicates them, and sorts by start time.
* Deduplicates children and sorts by start time.
*/
function normalizeAndSortSpans(spans: TraceSpan[]): TraceSpan[] {
return spans
.map((span) => {
const enrichedSpan: TraceSpan = { ...span }

// Clean output by removing childTraceSpans after extracting
if (enrichedSpan.output && typeof enrichedSpan.output === 'object') {
enrichedSpan.output = { ...enrichedSpan.output }
if ('childTraceSpans' in enrichedSpan.output) {
const { childTraceSpans, ...cleanOutput } = enrichedSpan.output as {
childTraceSpans?: TraceSpan[]
} & Record<string, unknown>
enrichedSpan.output = cleanOutput
}
}

// Merge and deduplicate children from both sources
const directChildren = Array.isArray(span.children) ? span.children : []
const outputChildren = Array.isArray(span.output?.childTraceSpans)
? (span.output!.childTraceSpans as TraceSpan[])
: []

const mergedChildren = mergeTraceSpanChildren(directChildren, outputChildren)
enrichedSpan.children =
mergedChildren.length > 0 ? normalizeAndSortSpans(mergedChildren) : undefined
// Process and deduplicate children
const children = Array.isArray(span.children) ? span.children : []
enrichedSpan.children = children.length > 0 ? normalizeAndSortSpans(children) : undefined

return enrichedSpan
})
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import {
import { ScrollArea } from '@/components/ui/scroll-area'
import { BASE_EXECUTION_CHARGE } from '@/lib/billing/constants'
import { cn } from '@/lib/core/utils/cn'
import { filterHiddenOutputKeys } from '@/lib/logs/execution/trace-spans/trace-spans'
import {
ExecutionSnapshot,
FileCards,
Expand Down Expand Up @@ -274,16 +275,13 @@ export const LogDetails = memo(function LogDetails({
return isWorkflowExecutionLog && log?.cost
}, [log, isWorkflowExecutionLog])

// Extract and clean the workflow final output (remove childTraceSpans for cleaner display)
// Extract and clean the workflow final output (recursively remove hidden keys for cleaner display)
const workflowOutput = useMemo(() => {
const executionData = log?.executionData as
| { finalOutput?: Record<string, unknown> }
| undefined
if (!executionData?.finalOutput) return null
const { childTraceSpans, ...cleanOutput } = executionData.finalOutput as {
childTraceSpans?: unknown
} & Record<string, unknown>
return cleanOutput
return filterHiddenOutputKeys(executionData.finalOutput) as Record<string, unknown>
}, [log?.executionData])

useEffect(() => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -39,8 +39,8 @@ interface WorkflowStackEntry {

/**
* Extracts child trace spans from a workflow block's execution data.
* Checks both the `children` property (where trace span processing moves them)
* and the legacy `output.childTraceSpans` for compatibility.
* Checks `children` property (where trace-spans processing puts them),
* with fallback to `output.childTraceSpans` for old stored logs.
*/
function extractChildTraceSpans(blockExecution: BlockExecutionData | undefined): TraceSpan[] {
if (!blockExecution) return []
Expand All @@ -49,6 +49,7 @@ function extractChildTraceSpans(blockExecution: BlockExecutionData | undefined):
return blockExecution.children
}

// Backward compat: old stored logs may have childTraceSpans in output
if (blockExecution.output && typeof blockExecution.output === 'object') {
const output = blockExecution.output as Record<string, unknown>
if (Array.isArray(output.childTraceSpans)) {
Expand Down
7 changes: 7 additions & 0 deletions apps/sim/executor/execution/block-executor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -152,6 +152,9 @@ export class BlockExecutor {
blockLog.durationMs = duration
blockLog.success = true
blockLog.output = filterOutputForLog(block.metadata?.id || '', normalizedOutput, { block })
if (normalizedOutput.childTraceSpans && Array.isArray(normalizedOutput.childTraceSpans)) {
blockLog.childTraceSpans = normalizedOutput.childTraceSpans
}
}

this.state.setBlockOutput(node.id, normalizedOutput, duration)
Expand Down Expand Up @@ -245,6 +248,10 @@ export class BlockExecutor {
blockLog.error = errorMessage
blockLog.input = this.sanitizeInputsForLog(input)
blockLog.output = filterOutputForLog(block.metadata?.id || '', errorOutput, { block })

if (errorOutput.childTraceSpans && Array.isArray(errorOutput.childTraceSpans)) {
blockLog.childTraceSpans = errorOutput.childTraceSpans
}
}

logger.error(
Expand Down
8 changes: 4 additions & 4 deletions apps/sim/executor/handlers/workflow/workflow-handler.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -118,7 +118,7 @@ describe('WorkflowBlockHandler', () => {
}

await expect(handler.execute(deepContext, mockBlock, inputs)).rejects.toThrow(
'Error in child workflow "child-workflow-id": Maximum workflow nesting depth of 10 exceeded'
'"child-workflow-id" failed: Maximum workflow nesting depth of 10 exceeded'
)
})

Expand All @@ -132,7 +132,7 @@ describe('WorkflowBlockHandler', () => {
})

await expect(handler.execute(mockContext, mockBlock, inputs)).rejects.toThrow(
'Error in child workflow "non-existent-workflow": Child workflow non-existent-workflow not found'
'"non-existent-workflow" failed: Child workflow non-existent-workflow not found'
)
})

Expand All @@ -142,7 +142,7 @@ describe('WorkflowBlockHandler', () => {
mockFetch.mockRejectedValueOnce(new Error('Network error'))

await expect(handler.execute(mockContext, mockBlock, inputs)).rejects.toThrow(
'Error in child workflow "child-workflow-id": Network error'
'"child-workflow-id" failed: Network error'
)
})
})
Expand Down Expand Up @@ -212,7 +212,7 @@ describe('WorkflowBlockHandler', () => {

expect(() =>
(handler as any).mapChildOutputToParent(childResult, 'child-id', 'Child Workflow', 100)
).toThrow('Error in child workflow "Child Workflow": Child workflow failed')
).toThrow('"Child Workflow" failed: Child workflow failed')

try {
;(handler as any).mapChildOutputToParent(childResult, 'child-id', 'Child Workflow', 100)
Expand Down
88 changes: 78 additions & 10 deletions apps/sim/executor/handlers/workflow/workflow-handler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,11 @@ export class WorkflowBlockHandler implements BlockHandler {
throw new Error('No workflow selected for execution')
}

// Initialize with registry name, will be updated with loaded workflow name
const { workflows } = useWorkflowRegistry.getState()
const workflowMetadata = workflows[workflowId]
let childWorkflowName = workflowMetadata?.name || workflowId

try {
const currentDepth = (ctx.workflowId?.split('_sub_').length || 1) - 1
if (currentDepth >= DEFAULTS.MAX_WORKFLOW_DEPTH) {
Expand All @@ -75,9 +80,8 @@ export class WorkflowBlockHandler implements BlockHandler {
throw new Error(`Child workflow ${workflowId} not found`)
}

const { workflows } = useWorkflowRegistry.getState()
const workflowMetadata = workflows[workflowId]
const childWorkflowName = workflowMetadata?.name || childWorkflow.name || 'Unknown Workflow'
// Update with loaded workflow name (more reliable than registry)
childWorkflowName = workflowMetadata?.name || childWorkflow.name || 'Unknown Workflow'

logger.info(
`Executing child workflow: ${childWorkflowName} (${workflowId}) at depth ${currentDepth}`
Expand Down Expand Up @@ -142,11 +146,6 @@ export class WorkflowBlockHandler implements BlockHandler {
} catch (error: unknown) {
logger.error(`Error executing child workflow ${workflowId}:`, error)

const { workflows } = useWorkflowRegistry.getState()
const workflowMetadata = workflows[workflowId]
const childWorkflowName = workflowMetadata?.name || workflowId

const originalError = error instanceof Error ? error.message : 'Unknown error'
let childTraceSpans: WorkflowTraceSpan[] = []
let executionResult: ExecutionResult | undefined

Expand All @@ -165,8 +164,11 @@ export class WorkflowBlockHandler implements BlockHandler {
childTraceSpans = error.childTraceSpans
}

// Build a cleaner error message for nested workflow errors
const errorMessage = this.buildNestedWorkflowErrorMessage(childWorkflowName, error)

throw new ChildWorkflowError({
message: `Error in child workflow "${childWorkflowName}": ${originalError}`,
message: errorMessage,
childWorkflowName,
childTraceSpans,
executionResult,
Expand All @@ -175,6 +177,72 @@ export class WorkflowBlockHandler implements BlockHandler {
}
}

/**
* Builds a cleaner error message for nested workflow errors.
* Parses nested error messages to extract workflow chain and root error.
*/
private buildNestedWorkflowErrorMessage(childWorkflowName: string, error: unknown): string {
const originalError = error instanceof Error ? error.message : 'Unknown error'

// Extract any nested workflow names from the error message
const { chain, rootError } = this.parseNestedWorkflowError(originalError)

// Add current workflow to the beginning of the chain
chain.unshift(childWorkflowName)

// If we have a chain (nested workflows), format nicely
if (chain.length > 1) {
return `Workflow chain: ${chain.join(' → ')} | ${rootError}`
}

// Single workflow failure
return `"${childWorkflowName}" failed: ${rootError}`
}

/**
* Parses a potentially nested workflow error message to extract:
* - The chain of workflow names
* - The actual root error message (preserving the block prefix for the failing block)
*
* Handles formats like:
* - "workflow-name" failed: error
* - [block_type] Block Name: "workflow-name" failed: error
* - Workflow chain: A → B | error
*/
private parseNestedWorkflowError(message: string): { chain: string[]; rootError: string } {
const chain: string[] = []
const remaining = message

// First, check if it's already in chain format
const chainMatch = remaining.match(/^Workflow chain: (.+?) \| (.+)$/)
if (chainMatch) {
const chainPart = chainMatch[1]
const errorPart = chainMatch[2]
chain.push(...chainPart.split(' → ').map((s) => s.trim()))
return { chain, rootError: errorPart }
}

// Extract workflow names from patterns like:
// - "workflow-name" failed:
// - [block_type] Block Name: "workflow-name" failed:
const workflowPattern = /(?:\[[^\]]+\]\s*[^:]+:\s*)?"([^"]+)"\s*failed:\s*/g
let match: RegExpExecArray | null
let lastIndex = 0

match = workflowPattern.exec(remaining)
while (match !== null) {
chain.push(match[1])
lastIndex = match.index + match[0].length
match = workflowPattern.exec(remaining)
}

// The root error is everything after the last match
// Keep the block prefix (e.g., [function] Function 1:) so we know which block failed
const rootError = lastIndex > 0 ? remaining.slice(lastIndex) : remaining

return { chain, rootError: rootError.trim() || 'Unknown error' }
}

private async loadChildWorkflow(workflowId: string) {
const headers = await buildAuthHeaders()
const url = buildAPIUrl(`/api/workflows/${workflowId}`)
Expand Down Expand Up @@ -444,7 +512,7 @@ export class WorkflowBlockHandler implements BlockHandler {
if (!success) {
logger.warn(`Child workflow ${childWorkflowName} failed`)
throw new ChildWorkflowError({
message: `Error in child workflow "${childWorkflowName}": ${childResult.error || 'Child workflow execution failed'}`,
message: `"${childWorkflowName}" failed: ${childResult.error || 'Child workflow execution failed'}`,
childWorkflowName,
childTraceSpans: childTraceSpans || [],
})
Expand Down
6 changes: 6 additions & 0 deletions apps/sim/executor/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -114,6 +114,12 @@ export interface BlockLog {
loopId?: string
parallelId?: string
iterationIndex?: number
/**
* Child workflow trace spans for nested workflow execution.
* Stored separately from output to keep output clean for display
* while preserving data for trace-spans processing.
*/
childTraceSpans?: TraceSpan[]
}

export interface ExecutionMetadata {
Expand Down
5 changes: 4 additions & 1 deletion apps/sim/executor/utils/output-filter.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { filterHiddenOutputKeys } from '@/lib/logs/execution/trace-spans/trace-spans'
import { getBlock } from '@/blocks'
import { isHiddenFromDisplay } from '@/blocks/types'
import { isTriggerBehavior, isTriggerInternalKey } from '@/executor/constants'
Expand All @@ -7,6 +8,7 @@ import type { SerializedBlock } from '@/serializer/types'
/**
* Filters block output for logging/display purposes.
* Removes internal fields and fields marked with hiddenFromDisplay.
* Also recursively filters globally hidden keys from nested objects.
*
* @param blockType - The block type string (e.g., 'human_in_the_loop', 'workflow')
* @param output - The raw block output to filter
Expand Down Expand Up @@ -44,7 +46,8 @@ export function filterOutputForLog(
continue
}

filtered[key] = value
// Recursively filter globally hidden keys from nested objects
filtered[key] = filterHiddenOutputKeys(value)
}

return filtered
Expand Down
Loading