This commit is contained in:
Siddharth Ganesan
2026-01-27 12:25:27 -08:00
parent 23ab11a40d
commit 2c333bfd98
9 changed files with 88 additions and 84 deletions

View File

@@ -11,8 +11,8 @@ import { getPersonalAndWorkspaceEnv } from '@/lib/environment/utils'
import { clearExecutionCancellation, markExecutionCancelled } from '@/lib/execution/cancellation'
import { LoggingSession } from '@/lib/logs/execution/logging-session'
import { buildTraceSpans } from '@/lib/logs/execution/trace-spans/trace-spans'
import { loadWorkflowFromNormalizedTables } from '@/lib/workflows/persistence/utils'
import { type ExecutionEvent, encodeSSEEvent } from '@/lib/workflows/executor/execution-events'
import { loadWorkflowFromNormalizedTables } from '@/lib/workflows/persistence/utils'
import { DAGExecutor } from '@/executor/execution/executor'
import type { IterationContext, SerializableExecutionState } from '@/executor/execution/types'
import type { NormalizedBlockOutput } from '@/executor/types'
@@ -367,7 +367,9 @@ export async function POST(req: NextRequest, { params }: { params: Promise<{ id:
logger.error(`[${requestId}] Run-from-block execution failed: ${errorMessage}`)
const executionResult = hasExecutionResult(error) ? error.executionResult : undefined
const { traceSpans } = executionResult ? buildTraceSpans(executionResult) : { traceSpans: [] }
const { traceSpans } = executionResult
? buildTraceSpans(executionResult)
: { traceSpans: [] }
// Complete logging session with error
await loggingSession.safeCompleteWithError({

View File

@@ -111,17 +111,12 @@ export const ActionBar = memo(
const isSubflowBlock = blockType === 'loop' || blockType === 'parallel'
const isInsideSubflow = parentId && (parentType === 'loop' || parentType === 'parallel')
// Check if run-from-block is available
// Block can run if all its upstream dependencies have cached outputs
const snapshot = activeWorkflowId ? getLastExecutionSnapshot(activeWorkflowId) : null
const hasExecutionSnapshot = !!snapshot
const dependenciesSatisfied = (() => {
if (!snapshot) return false
// Find all blocks that feed into this block
const incomingEdges = edges.filter((edge) => edge.target === blockId)
// If no incoming edges (trigger/start block), dependencies are satisfied
if (incomingEdges.length === 0) return true
// All source blocks must have been executed (have cached outputs)
return incomingEdges.every((edge) => snapshot.executedBlocks.includes(edge.source))
})()
const canRunFromBlock =

View File

@@ -1,4 +1,4 @@
import { useCallback, useEffect, useRef, useState } from 'react'
import { useCallback, useRef, useState } from 'react'
import { createLogger } from '@sim/logger'
import { useQueryClient } from '@tanstack/react-query'
import { v4 as uuidv4 } from 'uuid'
@@ -33,7 +33,6 @@ import { useWorkflowStore } from '@/stores/workflows/workflow/store'
const logger = createLogger('useWorkflowExecution')
// Debug state validation result
interface DebugValidationResult {
isValid: boolean
@@ -924,13 +923,9 @@ export function useWorkflowExecution() {
logger.info('onBlockCompleted received:', { data })
activeBlocksSet.delete(data.blockId)
// Create a new Set to trigger React re-render
setActiveBlocks(new Set(activeBlocksSet))
// Track successful block execution in run path
setBlockRunStatus(data.blockId, 'success')
// Track block state for run-from-block snapshot
executedBlockIds.add(data.blockId)
accumulatedBlockStates.set(data.blockId, {
output: data.output,
@@ -938,17 +933,12 @@ export function useWorkflowExecution() {
executionTime: data.durationMs,
})
// Skip adding loop/parallel containers to console and logs
// They're tracked for run-from-block but shouldn't appear in terminal
const isContainerBlock = data.blockType === 'loop' || data.blockType === 'parallel'
if (isContainerBlock) return
// Edges already tracked in onBlockStarted, no need to track again
const startedAt = new Date(Date.now() - data.durationMs).toISOString()
const endedAt = new Date().toISOString()
// Accumulate block log for the execution result
accumulatedBlockLogs.push({
blockId: data.blockId,
blockName: data.blockName || 'Unknown Block',
@@ -1461,7 +1451,10 @@ export function useWorkflowExecution() {
incomingEdges.every((edge) => snapshot.executedBlocks.includes(edge.source))
if (!dependenciesSatisfied) {
logger.error('Upstream dependencies not satisfied for run-from-block', { workflowId, blockId })
logger.error('Upstream dependencies not satisfied for run-from-block', {
workflowId,
blockId,
})
return
}

View File

@@ -1006,6 +1006,30 @@ const WorkflowContent = React.memo(() => {
handleRunUntilBlock(blockId, workflowIdParam)
}, [contextMenuBlocks, workflowIdParam, handleRunUntilBlock])
const runFromBlockState = useMemo(() => {
if (contextMenuBlocks.length !== 1) {
return { canRun: false, reason: undefined }
}
const block = contextMenuBlocks[0]
const snapshot = getLastExecutionSnapshot(workflowIdParam)
if (!snapshot) return { canRun: false, reason: 'Run workflow first' }
const incomingEdges = edges.filter((edge) => edge.target === block.id)
const dependenciesSatisfied =
incomingEdges.length === 0 ||
incomingEdges.every((edge) => snapshot.executedBlocks.includes(edge.source))
const isNoteBlock = block.type === 'note'
const isInsideSubflow =
block.parentId && (block.parentType === 'loop' || block.parentType === 'parallel')
if (!dependenciesSatisfied) return { canRun: false, reason: 'Run upstream blocks first' }
if (isInsideSubflow) return { canRun: false, reason: 'Cannot run from inside subflow' }
if (isNoteBlock) return { canRun: false, reason: undefined }
if (isExecuting) return { canRun: false, reason: undefined }
return { canRun: true, reason: undefined }
}, [contextMenuBlocks, edges, workflowIdParam, getLastExecutionSnapshot, isExecuting])
const handleContextAddBlock = useCallback(() => {
useSearchModalStore.getState().open()
}, [])
@@ -3332,42 +3356,8 @@ const WorkflowContent = React.memo(() => {
showRemoveFromSubflow={contextMenuBlocks.some(
(b) => b.parentId && (b.parentType === 'loop' || b.parentType === 'parallel')
)}
canRunFromBlock={
contextMenuBlocks.length === 1 &&
(() => {
const block = contextMenuBlocks[0]
const snapshot = getLastExecutionSnapshot(workflowIdParam)
if (!snapshot) return false
// Check if all upstream dependencies have cached outputs
const incomingEdges = edges.filter((edge) => edge.target === block.id)
const dependenciesSatisfied =
incomingEdges.length === 0 ||
incomingEdges.every((edge) => snapshot.executedBlocks.includes(edge.source))
const isNoteBlock = block.type === 'note'
const isInsideSubflow =
block.parentId && (block.parentType === 'loop' || block.parentType === 'parallel')
return dependenciesSatisfied && !isNoteBlock && !isInsideSubflow && !isExecuting
})()
}
runFromBlockDisabledReason={
contextMenuBlocks.length === 1
? (() => {
const block = contextMenuBlocks[0]
const snapshot = getLastExecutionSnapshot(workflowIdParam)
if (!snapshot) return 'Run workflow first'
// Check if all upstream dependencies have cached outputs
const incomingEdges = edges.filter((edge) => edge.target === block.id)
const dependenciesSatisfied =
incomingEdges.length === 0 ||
incomingEdges.every((edge) => snapshot.executedBlocks.includes(edge.source))
const isInsideSubflow =
block.parentId && (block.parentType === 'loop' || block.parentType === 'parallel')
if (!dependenciesSatisfied) return 'Run upstream blocks first'
if (isInsideSubflow) return 'Cannot run from inside subflow'
return undefined
})()
: undefined
}
canRunFromBlock={runFromBlockState.canRun}
runFromBlockDisabledReason={runFromBlockState.reason}
disableEdit={!effectivePermissions.canEdit}
isExecuting={isExecuting}
/>

View File

@@ -17,8 +17,8 @@ import { ParallelOrchestrator } from '@/executor/orchestrators/parallel'
import type { BlockState, ExecutionContext, ExecutionResult } from '@/executor/types'
import {
computeDirtySet,
resolveContainerToSentinelStart,
type RunFromBlockContext,
resolveContainerToSentinelStart,
validateRunFromBlock,
} from '@/executor/utils/run-from-block'
import {
@@ -233,8 +233,6 @@ export class DAGExecutor {
userId: this.contextExtensions.userId,
isDeployedContext: this.contextExtensions.isDeployedContext,
blockStates: state.getBlockStates(),
// For run-from-block, start with empty logs - we only want fresh execution logs for trace spans
// The snapshot's blockLogs are preserved separately for history
blockLogs: overrides?.runFromBlockContext ? [] : (snapshotState?.blockLogs ?? []),
metadata: {
...this.contextExtensions.metadata,
@@ -322,8 +320,6 @@ export class DAGExecutor {
skipStarterBlockInit: true,
})
} else if (overrides?.runFromBlockContext) {
// In run-from-block mode, skip starter block initialization
// All block states come from the snapshot
logger.info('Run-from-block mode: skipping starter block initialization', {
startBlockId: overrides.runFromBlockContext.startBlockId,
})

View File

@@ -281,12 +281,10 @@ export class LoopOrchestrator {
// Emit onBlockComplete for the loop container so the UI can track it
if (this.contextExtensions?.onBlockComplete) {
this.contextExtensions.onBlockComplete(
loopId,
'Loop',
'loop',
{ output, executionTime: DEFAULTS.EXECUTION_TIME }
)
this.contextExtensions.onBlockComplete(loopId, 'Loop', 'loop', {
output,
executionTime: DEFAULTS.EXECUTION_TIME,
})
}
return {

View File

@@ -31,7 +31,6 @@ export class NodeExecutionOrchestrator {
throw new Error(`Node not found in DAG: ${nodeId}`)
}
// In run-from-block mode, skip execution for non-dirty blocks and return cached output
if (ctx.runFromBlockContext && !ctx.runFromBlockContext.dirtySet.has(nodeId)) {
const cachedOutput = this.state.getBlockOutput(nodeId) || {}
logger.debug('Skipping non-dirty block in run-from-block mode', { nodeId })
@@ -42,7 +41,6 @@ export class NodeExecutionOrchestrator {
}
}
// Skip hasExecuted check for dirty blocks in run-from-block mode - they need to be re-executed
const isDirtyBlock = ctx.runFromBlockContext?.dirtySet.has(nodeId) ?? false
if (!isDirtyBlock && this.state.hasExecuted(nodeId)) {
const output = this.state.getBlockOutput(nodeId) || {}

View File

@@ -233,12 +233,10 @@ export class ParallelOrchestrator {
// Emit onBlockComplete for the parallel container so the UI can track it
if (this.contextExtensions?.onBlockComplete) {
this.contextExtensions.onBlockComplete(
parallelId,
'Parallel',
'parallel',
{ output, executionTime: 0 }
)
this.contextExtensions.onBlockComplete(parallelId, 'Parallel', 'parallel', {
output,
executionTime: 0,
})
}
return {

View File

@@ -1,8 +1,8 @@
import { describe, expect, it } from 'vitest'
import type { DAG, DAGNode } from '@/executor/dag/builder'
import type { DAGEdge, NodeMetadata } from '@/executor/dag/types'
import type { SerializedLoop, SerializedParallel } from '@/serializer/types'
import { computeDirtySet, validateRunFromBlock } from '@/executor/utils/run-from-block'
import type { SerializedLoop, SerializedParallel } from '@/serializer/types'
/**
* Helper to create a DAG node for testing
@@ -291,7 +291,9 @@ describe('validateRunFromBlock', () => {
})
it('rejects blocks inside parallels', () => {
const dag = createDAG([createNode('A', [], { isParallelBranch: true, parallelId: 'parallel-1' })])
const dag = createDAG([
createNode('A', [], { isParallelBranch: true, parallelId: 'parallel-1' }),
])
const executedBlocks = new Set(['A'])
const result = validateRunFromBlock('A', dag, executedBlocks)
@@ -352,9 +354,17 @@ describe('validateRunFromBlock', () => {
const sentinelEndId = `loop-${loopId}-sentinel-end`
const dag = createDAG([
createNode('A', [{ target: sentinelStartId }]),
createNode(sentinelStartId, [{ target: 'B' }], { isSentinel: true, sentinelType: 'start', loopId }),
createNode(sentinelStartId, [{ target: 'B' }], {
isSentinel: true,
sentinelType: 'start',
loopId,
}),
createNode('B', [{ target: sentinelEndId }], { isLoopNode: true, loopId }),
createNode(sentinelEndId, [{ target: 'C' }], { isSentinel: true, sentinelType: 'end', loopId }),
createNode(sentinelEndId, [{ target: 'C' }], {
isSentinel: true,
sentinelType: 'end',
loopId,
}),
createNode('C'),
])
dag.loopConfigs.set(loopId, { id: loopId, nodes: ['B'], iterations: 3, loopType: 'for' } as any)
@@ -372,9 +382,17 @@ describe('validateRunFromBlock', () => {
const sentinelEndId = `parallel-${parallelId}-sentinel-end`
const dag = createDAG([
createNode('A', [{ target: sentinelStartId }]),
createNode(sentinelStartId, [{ target: 'B₍0₎' }], { isSentinel: true, sentinelType: 'start', parallelId }),
createNode(sentinelStartId, [{ target: 'B₍0₎' }], {
isSentinel: true,
sentinelType: 'start',
parallelId,
}),
createNode('B₍0₎', [{ target: sentinelEndId }], { isParallelBranch: true, parallelId }),
createNode(sentinelEndId, [{ target: 'C' }], { isSentinel: true, sentinelType: 'end', parallelId }),
createNode(sentinelEndId, [{ target: 'C' }], {
isSentinel: true,
sentinelType: 'end',
parallelId,
}),
createNode('C'),
])
dag.parallelConfigs.set(parallelId, { id: parallelId, nodes: ['B'], count: 2 } as any)
@@ -412,9 +430,17 @@ describe('computeDirtySet with containers', () => {
const sentinelEndId = `loop-${loopId}-sentinel-end`
const dag = createDAG([
createNode('A', [{ target: sentinelStartId }]),
createNode(sentinelStartId, [{ target: 'B' }], { isSentinel: true, sentinelType: 'start', loopId }),
createNode(sentinelStartId, [{ target: 'B' }], {
isSentinel: true,
sentinelType: 'start',
loopId,
}),
createNode('B', [{ target: sentinelEndId }], { isLoopNode: true, loopId }),
createNode(sentinelEndId, [{ target: 'C' }], { isSentinel: true, sentinelType: 'end', loopId }),
createNode(sentinelEndId, [{ target: 'C' }], {
isSentinel: true,
sentinelType: 'end',
loopId,
}),
createNode('C'),
])
dag.loopConfigs.set(loopId, { id: loopId, nodes: ['B'], iterations: 3, loopType: 'for' } as any)
@@ -438,9 +464,17 @@ describe('computeDirtySet with containers', () => {
const sentinelEndId = `parallel-${parallelId}-sentinel-end`
const dag = createDAG([
createNode('A', [{ target: sentinelStartId }]),
createNode(sentinelStartId, [{ target: 'B₍0₎' }], { isSentinel: true, sentinelType: 'start', parallelId }),
createNode(sentinelStartId, [{ target: 'B₍0₎' }], {
isSentinel: true,
sentinelType: 'start',
parallelId,
}),
createNode('B₍0₎', [{ target: sentinelEndId }], { isParallelBranch: true, parallelId }),
createNode(sentinelEndId, [{ target: 'C' }], { isSentinel: true, sentinelType: 'end', parallelId }),
createNode(sentinelEndId, [{ target: 'C' }], {
isSentinel: true,
sentinelType: 'end',
parallelId,
}),
createNode('C'),
])
dag.parallelConfigs.set(parallelId, { id: parallelId, nodes: ['B'], count: 2 } as any)