mirror of
https://github.com/simstudioai/sim.git
synced 2026-01-24 06:18:04 -05:00
Compare commits
2 Commits
fix/nested
...
staging
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
211a7ac3a4 | ||
|
|
0f9b6ad1d2 |
@@ -30,6 +30,7 @@ import { normalizeName } from '@/executor/constants'
|
||||
import { ExecutionSnapshot } from '@/executor/execution/snapshot'
|
||||
import type { ExecutionMetadata, IterationContext } from '@/executor/execution/types'
|
||||
import type { NormalizedBlockOutput, StreamingExecution } from '@/executor/types'
|
||||
import { hasExecutionResult } from '@/executor/utils/errors'
|
||||
import { Serializer } from '@/serializer'
|
||||
import { CORE_TRIGGER_TYPES, type CoreTriggerType } from '@/stores/logs/filters/types'
|
||||
|
||||
@@ -467,17 +468,17 @@ export async function POST(req: NextRequest, { params }: { params: Promise<{ id:
|
||||
}
|
||||
|
||||
return NextResponse.json(filteredResult)
|
||||
} catch (error: any) {
|
||||
const errorMessage = error.message || 'Unknown error'
|
||||
} catch (error: unknown) {
|
||||
const errorMessage = error instanceof Error ? error.message : 'Unknown error'
|
||||
logger.error(`[${requestId}] Non-SSE execution failed: ${errorMessage}`)
|
||||
|
||||
const executionResult = error.executionResult
|
||||
const executionResult = hasExecutionResult(error) ? error.executionResult : undefined
|
||||
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
output: executionResult?.output,
|
||||
error: executionResult?.error || error.message || 'Execution failed',
|
||||
error: executionResult?.error || errorMessage || 'Execution failed',
|
||||
metadata: executionResult?.metadata
|
||||
? {
|
||||
duration: executionResult.metadata.duration,
|
||||
@@ -788,11 +789,11 @@ export async function POST(req: NextRequest, { params }: { params: Promise<{ id:
|
||||
|
||||
// Cleanup base64 cache for this execution
|
||||
await cleanupExecutionBase64Cache(executionId)
|
||||
} catch (error: any) {
|
||||
const errorMessage = error.message || 'Unknown error'
|
||||
} catch (error: unknown) {
|
||||
const errorMessage = error instanceof Error ? error.message : 'Unknown error'
|
||||
logger.error(`[${requestId}] SSE execution failed: ${errorMessage}`)
|
||||
|
||||
const executionResult = error.executionResult
|
||||
const executionResult = hasExecutionResult(error) ? error.executionResult : undefined
|
||||
|
||||
sendEvent({
|
||||
type: 'execution:error',
|
||||
|
||||
@@ -16,6 +16,7 @@ import {
|
||||
} from '@/lib/workflows/triggers/triggers'
|
||||
import { useCurrentWorkflow } from '@/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-current-workflow'
|
||||
import type { BlockLog, ExecutionResult, StreamingExecution } from '@/executor/types'
|
||||
import { hasExecutionResult } from '@/executor/utils/errors'
|
||||
import { coerceValue } from '@/executor/utils/start-block'
|
||||
import { subscriptionKeys } from '@/hooks/queries/subscription'
|
||||
import { useExecutionStream } from '@/hooks/use-execution-stream'
|
||||
@@ -76,17 +77,6 @@ function normalizeErrorMessage(error: unknown): string {
|
||||
return WORKFLOW_EXECUTION_FAILURE_MESSAGE
|
||||
}
|
||||
|
||||
function isExecutionResult(value: unknown): value is ExecutionResult {
|
||||
if (!isRecord(value)) return false
|
||||
return typeof value.success === 'boolean' && isRecord(value.output)
|
||||
}
|
||||
|
||||
function extractExecutionResult(error: unknown): ExecutionResult | null {
|
||||
if (!isRecord(error)) return null
|
||||
const candidate = error.executionResult
|
||||
return isExecutionResult(candidate) ? candidate : null
|
||||
}
|
||||
|
||||
export function useWorkflowExecution() {
|
||||
const queryClient = useQueryClient()
|
||||
const currentWorkflow = useCurrentWorkflow()
|
||||
@@ -1138,11 +1128,11 @@ export function useWorkflowExecution() {
|
||||
|
||||
const handleExecutionError = (error: unknown, options?: { executionId?: string }) => {
|
||||
const normalizedMessage = normalizeErrorMessage(error)
|
||||
const executionResultFromError = extractExecutionResult(error)
|
||||
|
||||
let errorResult: ExecutionResult
|
||||
|
||||
if (executionResultFromError) {
|
||||
if (hasExecutionResult(error)) {
|
||||
const executionResultFromError = error.executionResult
|
||||
const logs = Array.isArray(executionResultFromError.logs) ? executionResultFromError.logs : []
|
||||
|
||||
errorResult = {
|
||||
|
||||
@@ -3,11 +3,26 @@
|
||||
import { memo, useMemo } from 'react'
|
||||
import { Handle, type NodeProps, Position } from 'reactflow'
|
||||
import { HANDLE_POSITIONS } from '@/lib/workflows/blocks/block-dimensions'
|
||||
import {
|
||||
buildCanonicalIndex,
|
||||
evaluateSubBlockCondition,
|
||||
isSubBlockFeatureEnabled,
|
||||
isSubBlockVisibleForMode,
|
||||
} from '@/lib/workflows/subblocks/visibility'
|
||||
import { getDisplayValue } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/workflow-block'
|
||||
import { getBlock } from '@/blocks'
|
||||
import { SELECTOR_TYPES_HYDRATION_REQUIRED, type SubBlockConfig } from '@/blocks/types'
|
||||
import { useVariablesStore } from '@/stores/panel/variables/store'
|
||||
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
|
||||
|
||||
/** Execution status for blocks in preview mode */
|
||||
type ExecutionStatus = 'success' | 'error' | 'not-executed'
|
||||
|
||||
/** Subblock value structure matching workflow state */
|
||||
interface SubBlockValueEntry {
|
||||
value: unknown
|
||||
}
|
||||
|
||||
interface WorkflowPreviewBlockData {
|
||||
type: string
|
||||
name: string
|
||||
@@ -18,12 +33,220 @@ interface WorkflowPreviewBlockData {
|
||||
isPreviewSelected?: boolean
|
||||
/** Execution status for highlighting error/success states */
|
||||
executionStatus?: ExecutionStatus
|
||||
/** Subblock values from the workflow state */
|
||||
subBlockValues?: Record<string, SubBlockValueEntry | unknown>
|
||||
}
|
||||
|
||||
/**
|
||||
* Extracts the raw value from a subblock value entry.
|
||||
* Handles both wrapped ({ value: ... }) and unwrapped formats.
|
||||
*/
|
||||
function extractValue(entry: SubBlockValueEntry | unknown): unknown {
|
||||
if (entry && typeof entry === 'object' && 'value' in entry) {
|
||||
return (entry as SubBlockValueEntry).value
|
||||
}
|
||||
return entry
|
||||
}
|
||||
|
||||
interface SubBlockRowProps {
|
||||
title: string
|
||||
value?: string
|
||||
subBlock?: SubBlockConfig
|
||||
rawValue?: unknown
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves dropdown/combobox value to its display label.
|
||||
* Returns null if not a dropdown/combobox or no matching option found.
|
||||
*/
|
||||
function resolveDropdownLabel(
|
||||
subBlock: SubBlockConfig | undefined,
|
||||
rawValue: unknown
|
||||
): string | null {
|
||||
if (!subBlock || (subBlock.type !== 'dropdown' && subBlock.type !== 'combobox')) return null
|
||||
if (!rawValue || typeof rawValue !== 'string') return null
|
||||
|
||||
const options = typeof subBlock.options === 'function' ? subBlock.options() : subBlock.options
|
||||
if (!options) return null
|
||||
|
||||
const option = options.find((opt) =>
|
||||
typeof opt === 'string' ? opt === rawValue : opt.id === rawValue
|
||||
)
|
||||
|
||||
if (!option) return null
|
||||
return typeof option === 'string' ? option : option.label
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves workflow ID to workflow name using the workflow registry.
|
||||
* Uses synchronous store access to avoid hook dependencies.
|
||||
*/
|
||||
function resolveWorkflowName(
|
||||
subBlock: SubBlockConfig | undefined,
|
||||
rawValue: unknown
|
||||
): string | null {
|
||||
if (subBlock?.type !== 'workflow-selector') return null
|
||||
if (!rawValue || typeof rawValue !== 'string') return null
|
||||
|
||||
const workflowMap = useWorkflowRegistry.getState().workflows
|
||||
return workflowMap[rawValue]?.name ?? null
|
||||
}
|
||||
|
||||
/**
|
||||
* Type guard for variable assignments array
|
||||
*/
|
||||
function isVariableAssignmentsArray(
|
||||
value: unknown
|
||||
): value is Array<{ id?: string; variableId?: string; variableName?: string; value: unknown }> {
|
||||
return (
|
||||
Array.isArray(value) &&
|
||||
value.length > 0 &&
|
||||
value.every(
|
||||
(item) =>
|
||||
typeof item === 'object' &&
|
||||
item !== null &&
|
||||
('variableName' in item || 'variableId' in item)
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves variables-input to display names.
|
||||
* Uses synchronous store access to avoid hook dependencies.
|
||||
*/
|
||||
function resolveVariablesDisplay(
|
||||
subBlock: SubBlockConfig | undefined,
|
||||
rawValue: unknown
|
||||
): string | null {
|
||||
if (subBlock?.type !== 'variables-input') return null
|
||||
if (!isVariableAssignmentsArray(rawValue)) return null
|
||||
|
||||
const variables = useVariablesStore.getState().variables
|
||||
const variablesArray = Object.values(variables)
|
||||
|
||||
const names = rawValue
|
||||
.map((a) => {
|
||||
if (a.variableId) {
|
||||
const variable = variablesArray.find((v) => v.id === a.variableId)
|
||||
return variable?.name
|
||||
}
|
||||
if (a.variableName) return a.variableName
|
||||
return null
|
||||
})
|
||||
.filter((name): name is string => !!name)
|
||||
|
||||
if (names.length === 0) return null
|
||||
if (names.length === 1) return names[0]
|
||||
if (names.length === 2) return `${names[0]}, ${names[1]}`
|
||||
return `${names[0]}, ${names[1]} +${names.length - 2}`
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves tool-input to display names.
|
||||
* Resolves built-in tools from block registry (no API needed).
|
||||
*/
|
||||
function resolveToolsDisplay(
|
||||
subBlock: SubBlockConfig | undefined,
|
||||
rawValue: unknown
|
||||
): string | null {
|
||||
if (subBlock?.type !== 'tool-input') return null
|
||||
if (!Array.isArray(rawValue) || rawValue.length === 0) return null
|
||||
|
||||
const toolNames = rawValue
|
||||
.map((tool: unknown) => {
|
||||
if (!tool || typeof tool !== 'object') return null
|
||||
const t = tool as Record<string, unknown>
|
||||
|
||||
// Priority 1: Use tool.title if already populated
|
||||
if (t.title && typeof t.title === 'string') return t.title
|
||||
|
||||
// Priority 2: Extract from inline schema (legacy format)
|
||||
const schema = t.schema as Record<string, unknown> | undefined
|
||||
if (schema?.function && typeof schema.function === 'object') {
|
||||
const fn = schema.function as Record<string, unknown>
|
||||
if (fn.name && typeof fn.name === 'string') return fn.name
|
||||
}
|
||||
|
||||
// Priority 3: Extract from OpenAI function format
|
||||
const fn = t.function as Record<string, unknown> | undefined
|
||||
if (fn?.name && typeof fn.name === 'string') return fn.name
|
||||
|
||||
// Priority 4: Resolve built-in tool blocks from registry
|
||||
if (
|
||||
typeof t.type === 'string' &&
|
||||
t.type !== 'custom-tool' &&
|
||||
t.type !== 'mcp' &&
|
||||
t.type !== 'workflow' &&
|
||||
t.type !== 'workflow_input'
|
||||
) {
|
||||
const blockConfig = getBlock(t.type)
|
||||
if (blockConfig?.name) return blockConfig.name
|
||||
}
|
||||
|
||||
return null
|
||||
})
|
||||
.filter((name): name is string => !!name)
|
||||
|
||||
if (toolNames.length === 0) return null
|
||||
if (toolNames.length === 1) return toolNames[0]
|
||||
if (toolNames.length === 2) return `${toolNames[0]}, ${toolNames[1]}`
|
||||
return `${toolNames[0]}, ${toolNames[1]} +${toolNames.length - 2}`
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders a single subblock row with title and optional value.
|
||||
* Matches the SubBlockRow component in WorkflowBlock.
|
||||
* - Masks password fields with bullets
|
||||
* - Resolves dropdown/combobox labels
|
||||
* - Resolves workflow names from registry
|
||||
* - Resolves variable names from store
|
||||
* - Resolves tool names from block registry
|
||||
* - Shows '-' for other selector types that need hydration
|
||||
*/
|
||||
function SubBlockRow({ title, value, subBlock, rawValue }: SubBlockRowProps) {
|
||||
// Mask password fields
|
||||
const isPasswordField = subBlock?.password === true
|
||||
const maskedValue = isPasswordField && value && value !== '-' ? '•••' : null
|
||||
|
||||
// Resolve various display names (synchronous access, matching WorkflowBlock priority)
|
||||
const dropdownLabel = resolveDropdownLabel(subBlock, rawValue)
|
||||
const variablesDisplay = resolveVariablesDisplay(subBlock, rawValue)
|
||||
const toolsDisplay = resolveToolsDisplay(subBlock, rawValue)
|
||||
const workflowName = resolveWorkflowName(subBlock, rawValue)
|
||||
|
||||
// Check if this is a selector type that needs hydration (show '-' for raw IDs)
|
||||
const isSelectorType = subBlock?.type && SELECTOR_TYPES_HYDRATION_REQUIRED.includes(subBlock.type)
|
||||
|
||||
// Compute final display value matching WorkflowBlock logic
|
||||
// Priority order matches WorkflowBlock: masked > hydrated names > selector fallback > raw value
|
||||
const hydratedName = dropdownLabel || variablesDisplay || toolsDisplay || workflowName
|
||||
const displayValue = maskedValue || hydratedName || (isSelectorType && value ? '-' : value)
|
||||
|
||||
return (
|
||||
<div className='flex items-center gap-[8px]'>
|
||||
<span
|
||||
className='min-w-0 truncate text-[14px] text-[var(--text-tertiary)] capitalize'
|
||||
title={title}
|
||||
>
|
||||
{title}
|
||||
</span>
|
||||
{displayValue !== undefined && (
|
||||
<span
|
||||
className='flex-1 truncate text-right text-[14px] text-[var(--text-primary)]'
|
||||
title={displayValue}
|
||||
>
|
||||
{displayValue}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Preview block component for workflow visualization.
|
||||
* Renders block header, subblocks skeleton, and handles without
|
||||
* Renders block header, subblock values, and handles without
|
||||
* hooks, store subscriptions, or interactive features.
|
||||
* Matches the visual structure of WorkflowBlock exactly.
|
||||
*/
|
||||
function WorkflowPreviewBlockInner({ data }: NodeProps<WorkflowPreviewBlockData>) {
|
||||
const {
|
||||
@@ -34,21 +257,111 @@ function WorkflowPreviewBlockInner({ data }: NodeProps<WorkflowPreviewBlockData>
|
||||
enabled = true,
|
||||
isPreviewSelected = false,
|
||||
executionStatus,
|
||||
subBlockValues,
|
||||
} = data
|
||||
|
||||
const blockConfig = getBlock(type)
|
||||
|
||||
const canonicalIndex = useMemo(
|
||||
() => buildCanonicalIndex(blockConfig?.subBlocks || []),
|
||||
[blockConfig?.subBlocks]
|
||||
)
|
||||
|
||||
const rawValues = useMemo(() => {
|
||||
if (!subBlockValues) return {}
|
||||
return Object.entries(subBlockValues).reduce<Record<string, unknown>>((acc, [key, entry]) => {
|
||||
acc[key] = extractValue(entry)
|
||||
return acc
|
||||
}, {})
|
||||
}, [subBlockValues])
|
||||
|
||||
const visibleSubBlocks = useMemo(() => {
|
||||
if (!blockConfig?.subBlocks) return []
|
||||
|
||||
const isStarterOrTrigger =
|
||||
blockConfig.category === 'triggers' || type === 'starter' || isTrigger
|
||||
|
||||
return blockConfig.subBlocks.filter((subBlock) => {
|
||||
if (subBlock.hidden) return false
|
||||
if (subBlock.hideFromPreview) return false
|
||||
if (subBlock.mode === 'trigger' && blockConfig.category !== 'triggers') return false
|
||||
if (subBlock.mode === 'advanced') return false
|
||||
return true
|
||||
if (!isSubBlockFeatureEnabled(subBlock)) return false
|
||||
|
||||
// Handle trigger mode visibility
|
||||
if (subBlock.mode === 'trigger' && !isStarterOrTrigger) return false
|
||||
|
||||
// Check advanced mode visibility
|
||||
if (!isSubBlockVisibleForMode(subBlock, false, canonicalIndex, rawValues, undefined)) {
|
||||
return false
|
||||
}
|
||||
|
||||
// Check condition visibility
|
||||
if (!subBlock.condition) return true
|
||||
return evaluateSubBlockCondition(subBlock.condition, rawValues)
|
||||
})
|
||||
}, [blockConfig?.subBlocks])
|
||||
}, [blockConfig?.subBlocks, blockConfig?.category, type, isTrigger, canonicalIndex, rawValues])
|
||||
|
||||
/**
|
||||
* Compute condition rows for condition blocks
|
||||
*/
|
||||
const conditionRows = useMemo(() => {
|
||||
if (type !== 'condition') return []
|
||||
|
||||
const conditionsValue = rawValues.conditions
|
||||
const raw = typeof conditionsValue === 'string' ? conditionsValue : undefined
|
||||
|
||||
try {
|
||||
if (raw) {
|
||||
const parsed = JSON.parse(raw) as unknown
|
||||
if (Array.isArray(parsed)) {
|
||||
return parsed.map((item: unknown, index: number) => {
|
||||
const conditionItem = item as { id?: string; value?: unknown }
|
||||
const title = index === 0 ? 'if' : index === parsed.length - 1 ? 'else' : 'else if'
|
||||
return {
|
||||
id: conditionItem?.id ?? `cond-${index}`,
|
||||
title,
|
||||
value: typeof conditionItem?.value === 'string' ? conditionItem.value : '',
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Failed to parse, use fallback
|
||||
}
|
||||
|
||||
return [
|
||||
{ id: 'if', title: 'if', value: '' },
|
||||
{ id: 'else', title: 'else', value: '' },
|
||||
]
|
||||
}, [type, rawValues])
|
||||
|
||||
/**
|
||||
* Compute router rows for router_v2 blocks
|
||||
*/
|
||||
const routerRows = useMemo(() => {
|
||||
if (type !== 'router_v2') return []
|
||||
|
||||
const routesValue = rawValues.routes
|
||||
const raw = typeof routesValue === 'string' ? routesValue : undefined
|
||||
|
||||
try {
|
||||
if (raw) {
|
||||
const parsed = JSON.parse(raw) as unknown
|
||||
if (Array.isArray(parsed)) {
|
||||
return parsed.map((item: unknown, index: number) => {
|
||||
const routeItem = item as { id?: string; value?: string }
|
||||
return {
|
||||
id: routeItem?.id ?? `route${index + 1}`,
|
||||
value: routeItem?.value ?? '',
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Failed to parse, use fallback
|
||||
}
|
||||
|
||||
return [{ id: 'route1', value: '' }]
|
||||
}, [type, rawValues])
|
||||
|
||||
if (!blockConfig) {
|
||||
return null
|
||||
@@ -57,8 +370,14 @@ function WorkflowPreviewBlockInner({ data }: NodeProps<WorkflowPreviewBlockData>
|
||||
const IconComponent = blockConfig.icon
|
||||
const isStarterOrTrigger = blockConfig.category === 'triggers' || type === 'starter' || isTrigger
|
||||
|
||||
const shouldShowDefaultHandles = !isStarterOrTrigger
|
||||
const hasSubBlocks = visibleSubBlocks.length > 0
|
||||
const showErrorRow = !isStarterOrTrigger
|
||||
const hasContentBelowHeader =
|
||||
type === 'condition'
|
||||
? conditionRows.length > 0 || shouldShowDefaultHandles
|
||||
: type === 'router_v2'
|
||||
? routerRows.length > 0 || shouldShowDefaultHandles
|
||||
: hasSubBlocks || shouldShowDefaultHandles
|
||||
|
||||
const horizontalHandleClass = '!border-none !bg-[var(--surface-7)] !h-5 !w-[7px] !rounded-[2px]'
|
||||
const verticalHandleClass = '!border-none !bg-[var(--surface-7)] !h-[7px] !w-5 !rounded-[2px]'
|
||||
@@ -67,7 +386,7 @@ function WorkflowPreviewBlockInner({ data }: NodeProps<WorkflowPreviewBlockData>
|
||||
const hasSuccess = executionStatus === 'success'
|
||||
|
||||
return (
|
||||
<div className='relative w-[250px] select-none rounded-[8px] border border-[var(--border)] bg-[var(--surface-2)]'>
|
||||
<div className='relative w-[250px] select-none rounded-[8px] border border-[var(--border-1)] bg-[var(--surface-2)]'>
|
||||
{/* Selection ring overlay (takes priority over execution rings) */}
|
||||
{isPreviewSelected && (
|
||||
<div className='pointer-events-none absolute inset-0 z-40 rounded-[8px] ring-[1.75px] ring-[var(--brand-secondary)]' />
|
||||
@@ -82,7 +401,7 @@ function WorkflowPreviewBlockInner({ data }: NodeProps<WorkflowPreviewBlockData>
|
||||
)}
|
||||
|
||||
{/* Target handle - not shown for triggers/starters */}
|
||||
{!isStarterOrTrigger && (
|
||||
{shouldShowDefaultHandles && (
|
||||
<Handle
|
||||
type='target'
|
||||
position={horizontalHandles ? Position.Left : Position.Top}
|
||||
@@ -96,49 +415,67 @@ function WorkflowPreviewBlockInner({ data }: NodeProps<WorkflowPreviewBlockData>
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Header */}
|
||||
{/* Header - matches WorkflowBlock structure */}
|
||||
<div
|
||||
className={`flex items-center gap-[10px] p-[8px] ${hasSubBlocks || showErrorRow ? 'border-[var(--divider)] border-b' : ''}`}
|
||||
className={`flex items-center justify-between p-[8px] ${hasContentBelowHeader ? 'border-[var(--border-1)] border-b' : ''}`}
|
||||
>
|
||||
<div
|
||||
className='flex h-[24px] w-[24px] flex-shrink-0 items-center justify-center rounded-[6px]'
|
||||
style={{ background: enabled ? blockConfig.bgColor : 'gray' }}
|
||||
>
|
||||
<IconComponent className='h-[16px] w-[16px] text-white' />
|
||||
<div className='relative z-10 flex min-w-0 flex-1 items-center gap-[10px]'>
|
||||
<div
|
||||
className='flex h-[24px] w-[24px] flex-shrink-0 items-center justify-center rounded-[6px]'
|
||||
style={{ background: enabled ? blockConfig.bgColor : 'gray' }}
|
||||
>
|
||||
<IconComponent className='h-[16px] w-[16px] text-white' />
|
||||
</div>
|
||||
<span
|
||||
className={`truncate font-medium text-[16px] ${!enabled ? 'text-[var(--text-muted)]' : ''}`}
|
||||
title={name}
|
||||
>
|
||||
{name}
|
||||
</span>
|
||||
</div>
|
||||
<span
|
||||
className={`truncate font-medium text-[16px] ${!enabled ? 'text-[#808080]' : ''}`}
|
||||
title={name}
|
||||
>
|
||||
{name}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Subblocks skeleton */}
|
||||
{(hasSubBlocks || showErrorRow) && (
|
||||
{/* Content area with subblocks */}
|
||||
{hasContentBelowHeader && (
|
||||
<div className='flex flex-col gap-[8px] p-[8px]'>
|
||||
{visibleSubBlocks.slice(0, 4).map((subBlock) => (
|
||||
<div key={subBlock.id} className='flex items-center gap-[8px]'>
|
||||
<span className='min-w-0 truncate text-[14px] text-[var(--text-tertiary)] capitalize'>
|
||||
{subBlock.title ?? subBlock.id}
|
||||
</span>
|
||||
<span className='flex-1 truncate text-right text-[14px] text-[var(--white)]'>-</span>
|
||||
</div>
|
||||
))}
|
||||
{visibleSubBlocks.length > 4 && (
|
||||
<div className='flex items-center gap-[8px]'>
|
||||
<span className='text-[14px] text-[var(--text-tertiary)]'>
|
||||
+{visibleSubBlocks.length - 4} more
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{showErrorRow && (
|
||||
<div className='flex items-center gap-[8px]'>
|
||||
<span className='min-w-0 truncate text-[14px] text-[var(--text-tertiary)] capitalize'>
|
||||
error
|
||||
</span>
|
||||
</div>
|
||||
{type === 'condition' ? (
|
||||
// Condition block: render condition rows
|
||||
conditionRows.map((cond) => (
|
||||
<SubBlockRow key={cond.id} title={cond.title} value={getDisplayValue(cond.value)} />
|
||||
))
|
||||
) : type === 'router_v2' ? (
|
||||
// Router block: render context + route rows
|
||||
<>
|
||||
<SubBlockRow
|
||||
key='context'
|
||||
title='Context'
|
||||
value={getDisplayValue(rawValues.context)}
|
||||
/>
|
||||
{routerRows.map((route, index) => (
|
||||
<SubBlockRow
|
||||
key={route.id}
|
||||
title={`Route ${index + 1}`}
|
||||
value={getDisplayValue(route.value)}
|
||||
/>
|
||||
))}
|
||||
</>
|
||||
) : (
|
||||
// Standard blocks: render visible subblocks
|
||||
visibleSubBlocks.map((subBlock) => {
|
||||
const rawValue = rawValues[subBlock.id]
|
||||
return (
|
||||
<SubBlockRow
|
||||
key={subBlock.id}
|
||||
title={subBlock.title ?? subBlock.id}
|
||||
value={getDisplayValue(rawValue)}
|
||||
subBlock={subBlock}
|
||||
rawValue={rawValue}
|
||||
/>
|
||||
)
|
||||
})
|
||||
)}
|
||||
{/* Error row for non-trigger blocks */}
|
||||
{shouldShowDefaultHandles && <SubBlockRow title='error' />}
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -162,16 +499,47 @@ function shouldSkipPreviewBlockRender(
|
||||
prevProps: NodeProps<WorkflowPreviewBlockData>,
|
||||
nextProps: NodeProps<WorkflowPreviewBlockData>
|
||||
): boolean {
|
||||
return (
|
||||
prevProps.id === nextProps.id &&
|
||||
prevProps.data.type === nextProps.data.type &&
|
||||
prevProps.data.name === nextProps.data.name &&
|
||||
prevProps.data.isTrigger === nextProps.data.isTrigger &&
|
||||
prevProps.data.horizontalHandles === nextProps.data.horizontalHandles &&
|
||||
prevProps.data.enabled === nextProps.data.enabled &&
|
||||
prevProps.data.isPreviewSelected === nextProps.data.isPreviewSelected &&
|
||||
prevProps.data.executionStatus === nextProps.data.executionStatus
|
||||
)
|
||||
// Check primitive props first (fast path)
|
||||
if (
|
||||
prevProps.id !== nextProps.id ||
|
||||
prevProps.data.type !== nextProps.data.type ||
|
||||
prevProps.data.name !== nextProps.data.name ||
|
||||
prevProps.data.isTrigger !== nextProps.data.isTrigger ||
|
||||
prevProps.data.horizontalHandles !== nextProps.data.horizontalHandles ||
|
||||
prevProps.data.enabled !== nextProps.data.enabled ||
|
||||
prevProps.data.isPreviewSelected !== nextProps.data.isPreviewSelected ||
|
||||
prevProps.data.executionStatus !== nextProps.data.executionStatus
|
||||
) {
|
||||
return false
|
||||
}
|
||||
|
||||
// Compare subBlockValues by reference first
|
||||
const prevValues = prevProps.data.subBlockValues
|
||||
const nextValues = nextProps.data.subBlockValues
|
||||
|
||||
if (prevValues === nextValues) {
|
||||
return true
|
||||
}
|
||||
|
||||
if (!prevValues || !nextValues) {
|
||||
return false
|
||||
}
|
||||
|
||||
// Shallow compare keys and values
|
||||
const prevKeys = Object.keys(prevValues)
|
||||
const nextKeys = Object.keys(nextValues)
|
||||
|
||||
if (prevKeys.length !== nextKeys.length) {
|
||||
return false
|
||||
}
|
||||
|
||||
for (const key of prevKeys) {
|
||||
if (prevValues[key] !== nextValues[key]) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
export const WorkflowPreviewBlock = memo(WorkflowPreviewBlockInner, shouldSkipPreviewBlockRender)
|
||||
|
||||
@@ -347,6 +347,7 @@ export function WorkflowPreview({
|
||||
enabled: block.enabled ?? true,
|
||||
isPreviewSelected: isSelected,
|
||||
executionStatus,
|
||||
subBlockValues: block.subBlocks,
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
@@ -21,7 +21,7 @@ import {
|
||||
} from '@/lib/workflows/schedules/utils'
|
||||
import { ExecutionSnapshot } from '@/executor/execution/snapshot'
|
||||
import type { ExecutionMetadata } from '@/executor/execution/types'
|
||||
import type { ExecutionResult } from '@/executor/types'
|
||||
import { hasExecutionResult } from '@/executor/utils/errors'
|
||||
import { MAX_CONSECUTIVE_FAILURES } from '@/triggers/constants'
|
||||
|
||||
const logger = createLogger('TriggerScheduleExecution')
|
||||
@@ -231,8 +231,7 @@ async function runWorkflowExecution({
|
||||
} catch (error: unknown) {
|
||||
logger.error(`[${requestId}] Early failure in scheduled workflow ${payload.workflowId}`, error)
|
||||
|
||||
const errorWithResult = error as { executionResult?: ExecutionResult }
|
||||
const executionResult = errorWithResult?.executionResult
|
||||
const executionResult = hasExecutionResult(error) ? error.executionResult : undefined
|
||||
const { traceSpans } = executionResult ? buildTraceSpans(executionResult) : { traceSpans: [] }
|
||||
|
||||
await loggingSession.safeCompleteWithError({
|
||||
|
||||
@@ -16,7 +16,7 @@ import { loadDeployedWorkflowState } from '@/lib/workflows/persistence/utils'
|
||||
import { getWorkflowById } from '@/lib/workflows/utils'
|
||||
import { ExecutionSnapshot } from '@/executor/execution/snapshot'
|
||||
import type { ExecutionMetadata } from '@/executor/execution/types'
|
||||
import type { ExecutionResult } from '@/executor/types'
|
||||
import { hasExecutionResult } from '@/executor/utils/errors'
|
||||
import { safeAssign } from '@/tools/safe-assign'
|
||||
import { getTrigger, isTriggerValid } from '@/triggers'
|
||||
|
||||
@@ -578,12 +578,13 @@ async function executeWebhookJobInternal(
|
||||
deploymentVersionId,
|
||||
})
|
||||
|
||||
const errorWithResult = error as { executionResult?: ExecutionResult }
|
||||
const executionResult = errorWithResult?.executionResult || {
|
||||
success: false,
|
||||
output: {},
|
||||
logs: [],
|
||||
}
|
||||
const executionResult = hasExecutionResult(error)
|
||||
? error.executionResult
|
||||
: {
|
||||
success: false,
|
||||
output: {},
|
||||
logs: [],
|
||||
}
|
||||
const { traceSpans } = buildTraceSpans(executionResult)
|
||||
|
||||
await loggingSession.safeCompleteWithError({
|
||||
|
||||
@@ -9,7 +9,7 @@ import { PauseResumeManager } from '@/lib/workflows/executor/human-in-the-loop-m
|
||||
import { getWorkflowById } from '@/lib/workflows/utils'
|
||||
import { ExecutionSnapshot } from '@/executor/execution/snapshot'
|
||||
import type { ExecutionMetadata } from '@/executor/execution/types'
|
||||
import type { ExecutionResult } from '@/executor/types'
|
||||
import { hasExecutionResult } from '@/executor/utils/errors'
|
||||
import type { CoreTriggerType } from '@/stores/logs/filters/types'
|
||||
|
||||
const logger = createLogger('TriggerWorkflowExecution')
|
||||
@@ -160,8 +160,7 @@ export async function executeWorkflowJob(payload: WorkflowExecutionPayload) {
|
||||
executionId,
|
||||
})
|
||||
|
||||
const errorWithResult = error as { executionResult?: ExecutionResult }
|
||||
const executionResult = errorWithResult?.executionResult
|
||||
const executionResult = hasExecutionResult(error) ? error.executionResult : undefined
|
||||
const { traceSpans } = executionResult ? buildTraceSpans(executionResult) : { traceSpans: [] }
|
||||
|
||||
await loggingSession.safeCompleteWithError({
|
||||
|
||||
31
apps/sim/executor/errors/child-workflow-error.ts
Normal file
31
apps/sim/executor/errors/child-workflow-error.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
import type { TraceSpan } from '@/lib/logs/types'
|
||||
import type { ExecutionResult } from '@/executor/types'
|
||||
|
||||
interface ChildWorkflowErrorOptions {
|
||||
message: string
|
||||
childWorkflowName: string
|
||||
childTraceSpans?: TraceSpan[]
|
||||
executionResult?: ExecutionResult
|
||||
cause?: Error
|
||||
}
|
||||
|
||||
/**
|
||||
* Error raised when a child workflow execution fails.
|
||||
*/
|
||||
export class ChildWorkflowError extends Error {
|
||||
readonly childTraceSpans: TraceSpan[]
|
||||
readonly childWorkflowName: string
|
||||
readonly executionResult?: ExecutionResult
|
||||
|
||||
constructor(options: ChildWorkflowErrorOptions) {
|
||||
super(options.message, { cause: options.cause })
|
||||
this.name = 'ChildWorkflowError'
|
||||
this.childWorkflowName = options.childWorkflowName
|
||||
this.childTraceSpans = options.childTraceSpans ?? []
|
||||
this.executionResult = options.executionResult
|
||||
}
|
||||
|
||||
static isChildWorkflowError(error: unknown): error is ChildWorkflowError {
|
||||
return error instanceof ChildWorkflowError
|
||||
}
|
||||
}
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
isSentinelBlockType,
|
||||
} from '@/executor/constants'
|
||||
import type { DAGNode } from '@/executor/dag/builder'
|
||||
import { ChildWorkflowError } from '@/executor/errors/child-workflow-error'
|
||||
import type { BlockStateWriter, ContextExtensions } from '@/executor/execution/types'
|
||||
import {
|
||||
generatePauseContextId,
|
||||
@@ -213,24 +214,26 @@ export class BlockExecutor {
|
||||
? resolvedInputs
|
||||
: ((block.config?.params as Record<string, any> | undefined) ?? {})
|
||||
|
||||
const errorOutput: NormalizedBlockOutput = {
|
||||
error: errorMessage,
|
||||
}
|
||||
|
||||
if (ChildWorkflowError.isChildWorkflowError(error)) {
|
||||
errorOutput.childTraceSpans = error.childTraceSpans
|
||||
errorOutput.childWorkflowName = error.childWorkflowName
|
||||
}
|
||||
|
||||
this.state.setBlockOutput(node.id, errorOutput, duration)
|
||||
|
||||
if (blockLog) {
|
||||
blockLog.endedAt = new Date().toISOString()
|
||||
blockLog.durationMs = duration
|
||||
blockLog.success = false
|
||||
blockLog.error = errorMessage
|
||||
blockLog.input = input
|
||||
blockLog.output = this.filterOutputForLog(block, errorOutput)
|
||||
}
|
||||
|
||||
const errorOutput: NormalizedBlockOutput = {
|
||||
error: errorMessage,
|
||||
}
|
||||
|
||||
if (error && typeof error === 'object' && 'childTraceSpans' in error) {
|
||||
errorOutput.childTraceSpans = (error as any).childTraceSpans
|
||||
}
|
||||
|
||||
this.state.setBlockOutput(node.id, errorOutput, duration)
|
||||
|
||||
logger.error(
|
||||
phase === 'input_resolution' ? 'Failed to resolve block inputs' : 'Block execution failed',
|
||||
{
|
||||
|
||||
@@ -13,7 +13,7 @@ import type {
|
||||
PausePoint,
|
||||
ResumeStatus,
|
||||
} from '@/executor/types'
|
||||
import { normalizeError } from '@/executor/utils/errors'
|
||||
import { attachExecutionResult, normalizeError } from '@/executor/utils/errors'
|
||||
|
||||
const logger = createLogger('ExecutionEngine')
|
||||
|
||||
@@ -170,8 +170,8 @@ export class ExecutionEngine {
|
||||
metadata: this.context.metadata,
|
||||
}
|
||||
|
||||
if (error && typeof error === 'object') {
|
||||
;(error as any).executionResult = executionResult
|
||||
if (error instanceof Error) {
|
||||
attachExecutionResult(error, executionResult)
|
||||
}
|
||||
throw error
|
||||
}
|
||||
|
||||
@@ -4,12 +4,14 @@ import type { TraceSpan } from '@/lib/logs/types'
|
||||
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 {
|
||||
BlockHandler,
|
||||
ExecutionContext,
|
||||
ExecutionResult,
|
||||
StreamingExecution,
|
||||
} from '@/executor/types'
|
||||
import { hasExecutionResult } from '@/executor/utils/errors'
|
||||
import { buildAPIUrl, buildAuthHeaders } from '@/executor/utils/http'
|
||||
import { parseJSON } from '@/executor/utils/json'
|
||||
import { lazyCleanupInputMapping } from '@/executor/utils/lazy-cleanup'
|
||||
@@ -137,39 +139,39 @@ export class WorkflowBlockHandler implements BlockHandler {
|
||||
)
|
||||
|
||||
return mappedResult
|
||||
} catch (error: any) {
|
||||
} 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.message || 'Unknown error'
|
||||
const wrappedError = new Error(
|
||||
`Error in child workflow "${childWorkflowName}": ${originalError}`
|
||||
)
|
||||
const originalError = error instanceof Error ? error.message : 'Unknown error'
|
||||
let childTraceSpans: WorkflowTraceSpan[] = []
|
||||
let executionResult: ExecutionResult | undefined
|
||||
|
||||
if (error.executionResult?.logs) {
|
||||
const executionResult = error.executionResult as ExecutionResult
|
||||
if (hasExecutionResult(error) && error.executionResult.logs) {
|
||||
executionResult = error.executionResult
|
||||
|
||||
logger.info(`Extracting child trace spans from error.executionResult`, {
|
||||
hasLogs: (executionResult.logs?.length ?? 0) > 0,
|
||||
logCount: executionResult.logs?.length ?? 0,
|
||||
})
|
||||
|
||||
const childTraceSpans = this.captureChildWorkflowLogs(
|
||||
executionResult,
|
||||
childWorkflowName,
|
||||
ctx
|
||||
)
|
||||
childTraceSpans = this.captureChildWorkflowLogs(executionResult, childWorkflowName, ctx)
|
||||
|
||||
logger.info(`Captured ${childTraceSpans.length} child trace spans from failed execution`)
|
||||
;(wrappedError as any).childTraceSpans = childTraceSpans
|
||||
} else if (error.childTraceSpans && Array.isArray(error.childTraceSpans)) {
|
||||
;(wrappedError as any).childTraceSpans = error.childTraceSpans
|
||||
} else if (ChildWorkflowError.isChildWorkflowError(error)) {
|
||||
childTraceSpans = error.childTraceSpans
|
||||
}
|
||||
|
||||
throw wrappedError
|
||||
throw new ChildWorkflowError({
|
||||
message: `Error in child workflow "${childWorkflowName}": ${originalError}`,
|
||||
childWorkflowName,
|
||||
childTraceSpans,
|
||||
executionResult,
|
||||
cause: error instanceof Error ? error : undefined,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -441,11 +443,11 @@ export class WorkflowBlockHandler implements BlockHandler {
|
||||
|
||||
if (!success) {
|
||||
logger.warn(`Child workflow ${childWorkflowName} failed`)
|
||||
const error = new Error(
|
||||
`Error in child workflow "${childWorkflowName}": ${childResult.error || 'Child workflow execution failed'}`
|
||||
)
|
||||
;(error as any).childTraceSpans = childTraceSpans || []
|
||||
throw error
|
||||
throw new ChildWorkflowError({
|
||||
message: `Error in child workflow "${childWorkflowName}": ${childResult.error || 'Child workflow execution failed'}`,
|
||||
childWorkflowName,
|
||||
childTraceSpans: childTraceSpans || [],
|
||||
})
|
||||
}
|
||||
|
||||
return {
|
||||
|
||||
@@ -1,6 +1,39 @@
|
||||
import type { ExecutionContext } from '@/executor/types'
|
||||
import type { ExecutionContext, ExecutionResult } from '@/executor/types'
|
||||
import type { SerializedBlock } from '@/serializer/types'
|
||||
|
||||
/**
|
||||
* Interface for errors that carry an ExecutionResult.
|
||||
* Used when workflow execution fails and we want to preserve partial results.
|
||||
*/
|
||||
export interface ErrorWithExecutionResult extends Error {
|
||||
executionResult: ExecutionResult
|
||||
}
|
||||
|
||||
/**
|
||||
* Type guard to check if an error carries an ExecutionResult.
|
||||
* Validates that executionResult has required fields (success, output).
|
||||
*/
|
||||
export function hasExecutionResult(error: unknown): error is ErrorWithExecutionResult {
|
||||
if (
|
||||
!(error instanceof Error) ||
|
||||
!('executionResult' in error) ||
|
||||
error.executionResult == null ||
|
||||
typeof error.executionResult !== 'object'
|
||||
) {
|
||||
return false
|
||||
}
|
||||
|
||||
const result = error.executionResult as Record<string, unknown>
|
||||
return typeof result.success === 'boolean' && result.output != null
|
||||
}
|
||||
|
||||
/**
|
||||
* Attaches an ExecutionResult to an error for propagation to parent workflows.
|
||||
*/
|
||||
export function attachExecutionResult(error: Error, executionResult: ExecutionResult): void {
|
||||
Object.assign(error, { executionResult })
|
||||
}
|
||||
|
||||
export interface BlockExecutionErrorDetails {
|
||||
block: SerializedBlock
|
||||
error: Error | string
|
||||
|
||||
@@ -100,8 +100,13 @@ export function useExecutionStream() {
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.json()
|
||||
throw new Error(error.error || 'Failed to start execution')
|
||||
const errorResponse = await response.json()
|
||||
const error = new Error(errorResponse.error || 'Failed to start execution')
|
||||
// Attach the execution result from server response for error handling
|
||||
if (errorResponse && typeof errorResponse === 'object') {
|
||||
Object.assign(error, { executionResult: errorResponse })
|
||||
}
|
||||
throw error
|
||||
}
|
||||
|
||||
if (!response.body) {
|
||||
|
||||
@@ -24,6 +24,7 @@ import type {
|
||||
IterationContext,
|
||||
} from '@/executor/execution/types'
|
||||
import type { ExecutionResult, NormalizedBlockOutput } from '@/executor/types'
|
||||
import { hasExecutionResult } from '@/executor/utils/errors'
|
||||
import { Serializer } from '@/serializer'
|
||||
import { mergeSubblockState } from '@/stores/workflows/server-utils'
|
||||
|
||||
@@ -383,20 +384,15 @@ export async function executeWorkflowCore(
|
||||
} catch (error: unknown) {
|
||||
logger.error(`[${requestId}] Execution failed:`, error)
|
||||
|
||||
const errorWithResult = error as {
|
||||
executionResult?: ExecutionResult
|
||||
message?: string
|
||||
stack?: string
|
||||
}
|
||||
const executionResult = errorWithResult?.executionResult
|
||||
const executionResult = hasExecutionResult(error) ? error.executionResult : undefined
|
||||
const { traceSpans } = executionResult ? buildTraceSpans(executionResult) : { traceSpans: [] }
|
||||
|
||||
await loggingSession.safeCompleteWithError({
|
||||
endedAt: new Date().toISOString(),
|
||||
totalDurationMs: executionResult?.metadata?.duration || 0,
|
||||
error: {
|
||||
message: errorWithResult?.message || 'Execution failed',
|
||||
stackTrace: errorWithResult?.stack,
|
||||
message: error instanceof Error ? error.message : 'Execution failed',
|
||||
stackTrace: error instanceof Error ? error.stack : undefined,
|
||||
},
|
||||
traceSpans,
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user