From 3d0b810a8e02738e868938354d9c16533f020ee3 Mon Sep 17 00:00:00 2001 From: Siddharth Ganesan Date: Mon, 26 Jan 2026 16:19:41 -0800 Subject: [PATCH 01/33] Run from block --- apps/sim/app/api/copilot/user-models/route.ts | 1 + .../[id]/execute-from-block/route.ts | 377 ++++++++++++++++++ .../components/action-bar/action-bar.tsx | 56 ++- .../components/user-input/constants.ts | 1 + .../hooks/use-workflow-execution.ts | 260 +++++++++++- apps/sim/executor/execution/engine.ts | 11 + apps/sim/executor/execution/executor.ts | 106 ++++- apps/sim/executor/execution/types.ts | 9 + apps/sim/executor/orchestrators/node.ts | 15 +- apps/sim/executor/types.ts | 9 + .../sim/executor/utils/run-from-block.test.ts | 272 +++++++++++++ apps/sim/executor/utils/run-from-block.ts | 110 +++++ apps/sim/hooks/use-execution-stream.ts | 143 +++++++ apps/sim/lib/copilot/models.ts | 1 + apps/sim/stores/execution/store.ts | 19 + apps/sim/stores/execution/types.ts | 19 + 16 files changed, 1401 insertions(+), 8 deletions(-) create mode 100644 apps/sim/app/api/workflows/[id]/execute-from-block/route.ts create mode 100644 apps/sim/executor/utils/run-from-block.test.ts create mode 100644 apps/sim/executor/utils/run-from-block.ts diff --git a/apps/sim/app/api/copilot/user-models/route.ts b/apps/sim/app/api/copilot/user-models/route.ts index ead14a5e9d..b88e12b8a4 100644 --- a/apps/sim/app/api/copilot/user-models/route.ts +++ b/apps/sim/app/api/copilot/user-models/route.ts @@ -31,6 +31,7 @@ const DEFAULT_ENABLED_MODELS: Record = { 'claude-4.5-opus': true, 'claude-4.1-opus': false, 'gemini-3-pro': true, + 'auto': true, } // GET - Fetch user's enabled models diff --git a/apps/sim/app/api/workflows/[id]/execute-from-block/route.ts b/apps/sim/app/api/workflows/[id]/execute-from-block/route.ts new file mode 100644 index 0000000000..14cc81248b --- /dev/null +++ b/apps/sim/app/api/workflows/[id]/execute-from-block/route.ts @@ -0,0 +1,377 @@ +import { db, workflow as workflowTable } from '@sim/db' +import { createLogger } from '@sim/logger' +import { eq } from 'drizzle-orm' +import { type NextRequest, NextResponse } from 'next/server' +import { v4 as uuidv4 } from 'uuid' +import { z } from 'zod' +import { checkHybridAuth } from '@/lib/auth/hybrid' +import { generateRequestId } from '@/lib/core/utils/request' +import { SSE_HEADERS } from '@/lib/core/utils/sse' +import { getPersonalAndWorkspaceEnv } from '@/lib/environment/utils' +import { markExecutionCancelled } from '@/lib/execution/cancellation' +import { loadWorkflowFromNormalizedTables } from '@/lib/workflows/persistence/utils' +import { type ExecutionEvent, encodeSSEEvent } from '@/lib/workflows/executor/execution-events' +import { DAGExecutor } from '@/executor/execution/executor' +import type { IterationContext, SerializableExecutionState } from '@/executor/execution/types' +import type { NormalizedBlockOutput } from '@/executor/types' +import { hasExecutionResult } from '@/executor/utils/errors' +import { Serializer } from '@/serializer' +import { mergeSubblockState } from '@/stores/workflows/server-utils' + +const logger = createLogger('ExecuteFromBlockAPI') + +const ExecuteFromBlockSchema = z.object({ + startBlockId: z.string().min(1, 'Start block ID is required'), + sourceSnapshot: z.object({ + blockStates: z.record(z.any()), + executedBlocks: z.array(z.string()), + blockLogs: z.array(z.any()), + decisions: z.object({ + router: z.record(z.string()), + condition: z.record(z.string()), + }), + completedLoops: z.array(z.string()), + loopExecutions: z.record(z.any()).optional(), + parallelExecutions: z.record(z.any()).optional(), + parallelBlockMapping: z.record(z.any()).optional(), + activeExecutionPath: z.array(z.string()), + }), +}) + +export const runtime = 'nodejs' +export const dynamic = 'force-dynamic' + +/** + * POST /api/workflows/[id]/execute-from-block + * + * Executes a workflow starting from a specific block using cached outputs + * for upstream/unaffected blocks from the source snapshot. + */ +export async function POST(req: NextRequest, { params }: { params: Promise<{ id: string }> }) { + const requestId = generateRequestId() + const { id: workflowId } = await params + + try { + const auth = await checkHybridAuth(req, { requireWorkflowId: false }) + if (!auth.success || !auth.userId) { + return NextResponse.json({ error: auth.error || 'Unauthorized' }, { status: 401 }) + } + const userId = auth.userId + + let body: unknown + try { + body = await req.json() + } catch { + return NextResponse.json({ error: 'Invalid JSON body' }, { status: 400 }) + } + + const validation = ExecuteFromBlockSchema.safeParse(body) + if (!validation.success) { + logger.warn(`[${requestId}] Invalid request body:`, validation.error.errors) + return NextResponse.json( + { + error: 'Invalid request body', + details: validation.error.errors.map((e) => ({ + path: e.path.join('.'), + message: e.message, + })), + }, + { status: 400 } + ) + } + + const { startBlockId, sourceSnapshot } = validation.data + + logger.info(`[${requestId}] Starting run-from-block execution`, { + workflowId, + userId, + startBlockId, + executedBlocksCount: sourceSnapshot.executedBlocks.length, + }) + + const executionId = uuidv4() + + // Load workflow record to get workspaceId + const [workflowRecord] = await db + .select({ workspaceId: workflowTable.workspaceId }) + .from(workflowTable) + .where(eq(workflowTable.id, workflowId)) + .limit(1) + + if (!workflowRecord?.workspaceId) { + return NextResponse.json({ error: 'Workflow not found or has no workspace' }, { status: 404 }) + } + + const workspaceId = workflowRecord.workspaceId + + // Load workflow state + const workflowData = await loadWorkflowFromNormalizedTables(workflowId) + if (!workflowData) { + return NextResponse.json({ error: 'Workflow state not found' }, { status: 404 }) + } + + const { blocks, edges, loops, parallels } = workflowData + + // Merge block states + const mergedStates = mergeSubblockState(blocks) + + // Get environment variables + const { personalDecrypted, workspaceDecrypted } = await getPersonalAndWorkspaceEnv( + userId, + workspaceId + ) + const decryptedEnvVars: Record = { ...personalDecrypted, ...workspaceDecrypted } + + // Serialize workflow + const serializedWorkflow = new Serializer().serializeWorkflow( + mergedStates, + edges, + loops, + parallels, + true + ) + + const encoder = new TextEncoder() + const abortController = new AbortController() + let isStreamClosed = false + + const stream = new ReadableStream({ + async start(controller) { + const sendEvent = (event: ExecutionEvent) => { + if (isStreamClosed) return + + try { + controller.enqueue(encodeSSEEvent(event)) + } catch { + isStreamClosed = true + } + } + + try { + const startTime = new Date() + + sendEvent({ + type: 'execution:started', + timestamp: startTime.toISOString(), + executionId, + workflowId, + data: { + startTime: startTime.toISOString(), + }, + }) + + const onBlockStart = async ( + blockId: string, + blockName: string, + blockType: string, + iterationContext?: IterationContext + ) => { + sendEvent({ + type: 'block:started', + timestamp: new Date().toISOString(), + executionId, + workflowId, + data: { + blockId, + blockName, + blockType, + ...(iterationContext && { + iterationCurrent: iterationContext.iterationCurrent, + iterationTotal: iterationContext.iterationTotal, + iterationType: iterationContext.iterationType, + }), + }, + }) + } + + const onBlockComplete = async ( + blockId: string, + blockName: string, + blockType: string, + callbackData: { input?: unknown; output: NormalizedBlockOutput; executionTime: number }, + iterationContext?: IterationContext + ) => { + const hasError = (callbackData.output as any)?.error + + if (hasError) { + sendEvent({ + type: 'block:error', + timestamp: new Date().toISOString(), + executionId, + workflowId, + data: { + blockId, + blockName, + blockType, + input: callbackData.input, + error: (callbackData.output as any).error, + durationMs: callbackData.executionTime || 0, + ...(iterationContext && { + iterationCurrent: iterationContext.iterationCurrent, + iterationTotal: iterationContext.iterationTotal, + iterationType: iterationContext.iterationType, + }), + }, + }) + } else { + sendEvent({ + type: 'block:completed', + timestamp: new Date().toISOString(), + executionId, + workflowId, + data: { + blockId, + blockName, + blockType, + input: callbackData.input, + output: callbackData.output, + durationMs: callbackData.executionTime || 0, + ...(iterationContext && { + iterationCurrent: iterationContext.iterationCurrent, + iterationTotal: iterationContext.iterationTotal, + iterationType: iterationContext.iterationType, + }), + }, + }) + } + } + + const onStream = async (streamingExecution: unknown) => { + const streamingExec = streamingExecution as { stream: ReadableStream; execution: any } + const blockId = streamingExec.execution?.blockId + + const reader = streamingExec.stream.getReader() + const decoder = new TextDecoder() + + try { + while (true) { + const { done, value } = await reader.read() + if (done) break + + const chunk = decoder.decode(value, { stream: true }) + sendEvent({ + type: 'stream:chunk', + timestamp: new Date().toISOString(), + executionId, + workflowId, + data: { blockId, chunk }, + }) + } + + sendEvent({ + type: 'stream:done', + timestamp: new Date().toISOString(), + executionId, + workflowId, + data: { blockId }, + }) + } catch (error) { + logger.error(`[${requestId}] Error streaming block content:`, error) + } finally { + try { + reader.releaseLock() + } catch {} + } + } + + // Create executor and run from block + const executor = new DAGExecutor({ + workflow: serializedWorkflow, + envVarValues: decryptedEnvVars, + workflowInput: {}, + workflowVariables: {}, + contextExtensions: { + stream: true, + executionId, + workspaceId, + userId, + isDeployedContext: false, + onBlockStart, + onBlockComplete, + onStream, + abortSignal: abortController.signal, + }, + }) + + const result = await executor.executeFromBlock( + workflowId, + startBlockId, + sourceSnapshot as SerializableExecutionState + ) + + if (result.status === 'cancelled') { + sendEvent({ + type: 'execution:cancelled', + timestamp: new Date().toISOString(), + executionId, + workflowId, + data: { + duration: result.metadata?.duration || 0, + }, + }) + return + } + + sendEvent({ + type: 'execution:completed', + timestamp: new Date().toISOString(), + executionId, + workflowId, + data: { + success: result.success, + output: result.output, + duration: result.metadata?.duration || 0, + startTime: result.metadata?.startTime || startTime.toISOString(), + endTime: result.metadata?.endTime || new Date().toISOString(), + }, + }) + } catch (error: unknown) { + const errorMessage = error instanceof Error ? error.message : 'Unknown error' + logger.error(`[${requestId}] Run-from-block execution failed: ${errorMessage}`) + + const executionResult = hasExecutionResult(error) ? error.executionResult : undefined + + sendEvent({ + type: 'execution:error', + timestamp: new Date().toISOString(), + executionId, + workflowId, + data: { + error: executionResult?.error || errorMessage, + duration: executionResult?.metadata?.duration || 0, + }, + }) + } finally { + if (!isStreamClosed) { + try { + controller.enqueue(encoder.encode('data: [DONE]\n\n')) + controller.close() + } catch { + // Stream already closed + } + } + } + }, + cancel() { + isStreamClosed = true + logger.info(`[${requestId}] Client aborted SSE stream, signalling cancellation`) + abortController.abort() + markExecutionCancelled(executionId).catch(() => {}) + }, + }) + + return new NextResponse(stream, { + headers: { + ...SSE_HEADERS, + 'X-Execution-Id': executionId, + }, + }) + } catch (error: unknown) { + const errorMessage = error instanceof Error ? error.message : 'Unknown error' + logger.error(`[${requestId}] Failed to start run-from-block execution:`, error) + return NextResponse.json( + { error: errorMessage || 'Failed to start execution' }, + { status: 500 } + ) + } +} diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/action-bar/action-bar.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/action-bar/action-bar.tsx index 42d2c3e84e..f8a816a329 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/action-bar/action-bar.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/action-bar/action-bar.tsx @@ -1,11 +1,12 @@ import { memo, useCallback } from 'react' -import { ArrowLeftRight, ArrowUpDown, Circle, CircleOff, LogOut } from 'lucide-react' +import { ArrowLeftRight, ArrowUpDown, Circle, CircleOff, LogOut, Play } from 'lucide-react' import { Button, Copy, Tooltip, Trash2 } from '@/components/emcn' import { cn } from '@/lib/core/utils/cn' import { isInputDefinitionTrigger } from '@/lib/workflows/triggers/input-definition-triggers' import { useUserPermissionsContext } from '@/app/workspace/[workspaceId]/providers/workspace-permissions-provider' import { validateTriggerPaste } from '@/app/workspace/[workspaceId]/w/[workflowId]/utils' import { useCollaborativeWorkflow } from '@/hooks/use-collaborative-workflow' +import { useExecutionStore } from '@/stores/execution' import { useNotificationStore } from '@/stores/notifications' import { useWorkflowRegistry } from '@/stores/workflows/registry/store' import { useWorkflowStore } from '@/stores/workflows/workflow/store' @@ -97,12 +98,42 @@ export const ActionBar = memo( ) ) + const { activeWorkflowId } = useWorkflowRegistry() + const { isExecuting, getLastExecutionSnapshot } = useExecutionStore() const userPermissions = useUserPermissionsContext() const isStartBlock = isInputDefinitionTrigger(blockType) const isResponseBlock = blockType === 'response' const isNoteBlock = blockType === 'note' const isSubflowBlock = blockType === 'loop' || blockType === 'parallel' + const isInsideSubflow = parentId && (parentType === 'loop' || parentType === 'parallel') + + // Check if run-from-block is available + const hasExecutionSnapshot = activeWorkflowId + ? !!getLastExecutionSnapshot(activeWorkflowId) + : false + const wasExecuted = activeWorkflowId + ? getLastExecutionSnapshot(activeWorkflowId)?.executedBlocks.includes(blockId) ?? false + : false + const canRunFromBlock = + hasExecutionSnapshot && + wasExecuted && + !isStartBlock && + !isNoteBlock && + !isSubflowBlock && + !isInsideSubflow && + !isExecuting + + const handleRunFromBlock = useCallback(() => { + if (!activeWorkflowId || !canRunFromBlock) return + + // Dispatch a custom event to trigger run-from-block execution + window.dispatchEvent( + new CustomEvent('run-from-block', { + detail: { blockId, workflowId: activeWorkflowId }, + }) + ) + }, [blockId, activeWorkflowId, canRunFromBlock]) /** * Get appropriate tooltip message based on disabled state @@ -174,6 +205,29 @@ export const ActionBar = memo( )} + {canRunFromBlock && ( + + + + + + {isExecuting ? 'Execution in progress' : getTooltipMessage('Run from this block')} + + + )} + {!isStartBlock && !isResponseBlock && ( diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/constants.ts b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/constants.ts index b98af5dd21..118915239c 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/constants.ts +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/constants.ts @@ -246,6 +246,7 @@ export function getCommandDisplayLabel(commandId: string): string { * Model configuration options */ export const MODEL_OPTIONS = [ + { value: 'auto', label: 'Auto' }, { value: 'claude-4.5-opus', label: 'Claude 4.5 Opus' }, { value: 'claude-4.5-sonnet', label: 'Claude 4.5 Sonnet' }, { value: 'claude-4.5-haiku', label: 'Claude 4.5 Haiku' }, 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 1c0cbcc7a0..393bd9a00c 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 @@ -1,4 +1,4 @@ -import { useCallback, useRef, useState } from 'react' +import { useCallback, useEffect, useRef, useState } from 'react' import { createLogger } from '@sim/logger' import { useQueryClient } from '@tanstack/react-query' import { v4 as uuidv4 } from 'uuid' @@ -15,7 +15,8 @@ import { TriggerUtils, } 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 type { SerializableExecutionState } from '@/executor/execution/types' +import type { BlockLog, BlockState, ExecutionResult, StreamingExecution } from '@/executor/types' import { hasExecutionResult } from '@/executor/utils/errors' import { coerceValue } from '@/executor/utils/start-block' import { subscriptionKeys } from '@/hooks/queries/subscription' @@ -32,6 +33,9 @@ import { useWorkflowStore } from '@/stores/workflows/workflow/store' const logger = createLogger('useWorkflowExecution') +// Module-level guard to prevent duplicate run-from-block executions across hook instances +let runFromBlockGlobalLock = false + // Debug state validation result interface DebugValidationResult { isValid: boolean @@ -98,6 +102,8 @@ export function useWorkflowExecution() { setActiveBlocks, setBlockRunStatus, setEdgeRunStatus, + setLastExecutionSnapshot, + getLastExecutionSnapshot, } = useExecutionStore() const [executionResult, setExecutionResult] = useState(null) const executionStream = useExecutionStream() @@ -876,6 +882,8 @@ export function useWorkflowExecution() { const activeBlocksSet = new Set() const streamedContent = new Map() const accumulatedBlockLogs: BlockLog[] = [] + const accumulatedBlockStates = new Map() + const executedBlockIds = new Set() // Execute the workflow try { @@ -922,6 +930,14 @@ export function useWorkflowExecution() { // 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, + executed: true, + executionTime: data.durationMs, + }) + // Edges already tracked in onBlockStarted, no need to track again const startedAt = new Date(Date.now() - data.durationMs).toISOString() @@ -1056,6 +1072,23 @@ export function useWorkflowExecution() { }, logs: accumulatedBlockLogs, } + + // Store execution snapshot for run-from-block + if (data.success && activeWorkflowId) { + const snapshot: SerializableExecutionState = { + blockStates: Object.fromEntries(accumulatedBlockStates), + executedBlocks: Array.from(executedBlockIds), + blockLogs: accumulatedBlockLogs, + decisions: { router: {}, condition: {} }, + completedLoops: [], + activeExecutionPath: Array.from(executedBlockIds), + } + setLastExecutionSnapshot(activeWorkflowId, snapshot) + logger.info('Stored execution snapshot for run-from-block', { + workflowId: activeWorkflowId, + executedBlocksCount: executedBlockIds.size, + }) + } }, onExecutionError: (data) => { @@ -1376,6 +1409,228 @@ export function useWorkflowExecution() { setActiveBlocks, ]) + /** + * Handles running workflow from a specific block using cached outputs + */ + const handleRunFromBlock = useCallback( + async (blockId: string, workflowId: string) => { + // Prevent duplicate executions across multiple hook instances (panel.tsx and chat.tsx) + if (runFromBlockGlobalLock) { + logger.debug('Run-from-block already in progress (global lock), ignoring duplicate request', { + workflowId, + blockId, + }) + return + } + runFromBlockGlobalLock = true + + const snapshot = getLastExecutionSnapshot(workflowId) + if (!snapshot) { + logger.error('No execution snapshot available for run-from-block', { workflowId, blockId }) + runFromBlockGlobalLock = false + return + } + + if (!snapshot.executedBlocks.includes(blockId)) { + logger.error('Block was not executed in the source run', { workflowId, blockId }) + runFromBlockGlobalLock = false + return + } + + logger.info('Starting run-from-block execution', { + workflowId, + startBlockId: blockId, + snapshotExecutedBlocks: snapshot.executedBlocks.length, + }) + + setIsExecuting(true) + + const workflowEdges = useWorkflowStore.getState().edges + const executionId = uuidv4() + const accumulatedBlockLogs: BlockLog[] = [] + const accumulatedBlockStates = new Map() + const executedBlockIds = new Set() + const activeBlocksSet = new Set() + + try { + await executionStream.executeFromBlock({ + workflowId, + startBlockId: blockId, + sourceSnapshot: snapshot, + callbacks: { + onExecutionStarted: (data) => { + logger.info('Run-from-block execution started:', data) + }, + + onBlockStarted: (data) => { + activeBlocksSet.add(data.blockId) + setActiveBlocks(new Set(activeBlocksSet)) + + const incomingEdges = workflowEdges.filter((edge) => edge.target === data.blockId) + incomingEdges.forEach((edge) => { + setEdgeRunStatus(edge.id, 'success') + }) + }, + + onBlockCompleted: (data) => { + activeBlocksSet.delete(data.blockId) + setActiveBlocks(new Set(activeBlocksSet)) + + setBlockRunStatus(data.blockId, 'success') + + executedBlockIds.add(data.blockId) + accumulatedBlockStates.set(data.blockId, { + output: data.output, + executed: true, + executionTime: data.durationMs, + }) + + const startedAt = new Date(Date.now() - data.durationMs).toISOString() + const endedAt = new Date().toISOString() + + accumulatedBlockLogs.push({ + blockId: data.blockId, + blockName: data.blockName || 'Unknown Block', + blockType: data.blockType || 'unknown', + input: data.input || {}, + output: data.output, + success: true, + durationMs: data.durationMs, + startedAt, + endedAt, + }) + + addConsole({ + input: data.input || {}, + output: data.output, + success: true, + durationMs: data.durationMs, + startedAt, + endedAt, + workflowId, + blockId: data.blockId, + executionId, + blockName: data.blockName || 'Unknown Block', + blockType: data.blockType || 'unknown', + iterationCurrent: data.iterationCurrent, + iterationTotal: data.iterationTotal, + iterationType: data.iterationType, + }) + }, + + onBlockError: (data) => { + activeBlocksSet.delete(data.blockId) + setActiveBlocks(new Set(activeBlocksSet)) + + setBlockRunStatus(data.blockId, 'error') + + const startedAt = new Date(Date.now() - data.durationMs).toISOString() + const endedAt = new Date().toISOString() + + accumulatedBlockLogs.push({ + blockId: data.blockId, + blockName: data.blockName || 'Unknown Block', + blockType: data.blockType || 'unknown', + input: data.input || {}, + output: {}, + success: false, + error: data.error, + durationMs: data.durationMs, + startedAt, + endedAt, + }) + + addConsole({ + input: data.input || {}, + output: {}, + success: false, + error: data.error, + durationMs: data.durationMs, + startedAt, + endedAt, + workflowId, + blockId: data.blockId, + executionId, + blockName: data.blockName, + blockType: data.blockType, + iterationCurrent: data.iterationCurrent, + iterationTotal: data.iterationTotal, + iterationType: data.iterationType, + }) + }, + + onExecutionCompleted: (data) => { + if (data.success) { + // Merge new states with snapshot states for updated snapshot + const mergedBlockStates: Record = { ...snapshot.blockStates } + for (const [bId, state] of accumulatedBlockStates) { + mergedBlockStates[bId] = state + } + + const mergedExecutedBlocks = new Set([ + ...snapshot.executedBlocks, + ...executedBlockIds, + ]) + + const updatedSnapshot: SerializableExecutionState = { + ...snapshot, + blockStates: mergedBlockStates, + executedBlocks: Array.from(mergedExecutedBlocks), + blockLogs: [...snapshot.blockLogs, ...accumulatedBlockLogs], + activeExecutionPath: Array.from(mergedExecutedBlocks), + } + setLastExecutionSnapshot(workflowId, updatedSnapshot) + logger.info('Updated execution snapshot after run-from-block', { + workflowId, + newBlocksExecuted: executedBlockIds.size, + }) + } + }, + + onExecutionError: (data) => { + logger.error('Run-from-block execution error:', data.error) + }, + + onExecutionCancelled: () => { + logger.info('Run-from-block execution cancelled') + }, + }, + }) + } catch (error) { + if ((error as Error).name !== 'AbortError') { + logger.error('Run-from-block execution failed:', error) + } + } finally { + setIsExecuting(false) + setActiveBlocks(new Set()) + runFromBlockGlobalLock = false + } + }, + [ + getLastExecutionSnapshot, + setLastExecutionSnapshot, + setIsExecuting, + setActiveBlocks, + setBlockRunStatus, + setEdgeRunStatus, + addConsole, + executionStream, + ] + ) + + // Listen for run-from-block events from the action bar + useEffect(() => { + const handleRunFromBlockEvent = (event: CustomEvent<{ blockId: string; workflowId: string }>) => { + const { blockId, workflowId } = event.detail + handleRunFromBlock(blockId, workflowId) + } + + window.addEventListener('run-from-block', handleRunFromBlockEvent as EventListener) + return () => { + window.removeEventListener('run-from-block', handleRunFromBlockEvent as EventListener) + } + }, [handleRunFromBlock]) + return { isExecuting, isDebugging, @@ -1386,5 +1641,6 @@ export function useWorkflowExecution() { handleResumeDebug, handleCancelDebug, handleCancelExecution, + handleRunFromBlock, } } diff --git a/apps/sim/executor/execution/engine.ts b/apps/sim/executor/execution/engine.ts index 05e7e04843..9cea322188 100644 --- a/apps/sim/executor/execution/engine.ts +++ b/apps/sim/executor/execution/engine.ts @@ -259,6 +259,17 @@ export class ExecutionEngine { } private initializeQueue(triggerBlockId?: string): void { + // Run-from-block mode: start directly from specified block + if (this.context.runFromBlockContext) { + const { startBlockId } = this.context.runFromBlockContext + logger.info('Initializing queue for run-from-block mode', { + startBlockId, + dirtySetSize: this.context.runFromBlockContext.dirtySet.size, + }) + this.addToQueue(startBlockId) + return + } + const pendingBlocks = this.context.metadata.pendingBlocks const remainingEdges = (this.context.metadata as any).remainingEdges diff --git a/apps/sim/executor/execution/executor.ts b/apps/sim/executor/execution/executor.ts index b4e1f55f80..3351aaff87 100644 --- a/apps/sim/executor/execution/executor.ts +++ b/apps/sim/executor/execution/executor.ts @@ -5,12 +5,17 @@ import { BlockExecutor } from '@/executor/execution/block-executor' import { EdgeManager } from '@/executor/execution/edge-manager' import { ExecutionEngine } from '@/executor/execution/engine' import { ExecutionState } from '@/executor/execution/state' -import type { ContextExtensions, WorkflowInput } from '@/executor/execution/types' +import type { + ContextExtensions, + SerializableExecutionState, + WorkflowInput, +} from '@/executor/execution/types' import { createBlockHandlers } from '@/executor/handlers/registry' import { LoopOrchestrator } from '@/executor/orchestrators/loop' import { NodeExecutionOrchestrator } from '@/executor/orchestrators/node' import { ParallelOrchestrator } from '@/executor/orchestrators/parallel' import type { BlockState, ExecutionContext, ExecutionResult } from '@/executor/types' +import { computeDirtySet, validateRunFromBlock } from '@/executor/utils/run-from-block' import { buildResolutionFromBlock, buildStartBlockOutput, @@ -89,17 +94,103 @@ export class DAGExecutor { } } + /** + * Execute workflow starting from a specific block, using cached outputs + * for all upstream/unaffected blocks from the source snapshot. + * + * This implements Jupyter notebook-style execution where: + * - The start block and all downstream blocks are re-executed + * - Upstream blocks retain their cached outputs from the source snapshot + * - The result is a merged execution state + * + * @param workflowId - The workflow ID + * @param startBlockId - The block to start execution from + * @param sourceSnapshot - The execution state from a previous run + * @returns Merged execution result with cached + fresh outputs + */ + async executeFromBlock( + workflowId: string, + startBlockId: string, + sourceSnapshot: SerializableExecutionState + ): Promise { + // Build full DAG (no trigger constraint - we need all blocks for validation) + const dag = this.dagBuilder.build(this.workflow) + + // Validate the start block + const executedBlocks = new Set(sourceSnapshot.executedBlocks) + const validation = validateRunFromBlock(startBlockId, dag, executedBlocks) + if (!validation.valid) { + throw new Error(validation.error) + } + + // Compute dirty set (blocks that will be re-executed) + const dirtySet = computeDirtySet(dag, startBlockId) + + logger.info('Executing from block', { + workflowId, + startBlockId, + dirtySetSize: dirtySet.size, + totalBlocks: dag.nodes.size, + dirtyBlocks: Array.from(dirtySet), + }) + + // Create context with snapshot state + runFromBlockContext + const runFromBlockContext = { startBlockId, dirtySet } + const { context, state } = this.createExecutionContext(workflowId, undefined, { + snapshotState: sourceSnapshot, + runFromBlockContext, + }) + + // Setup orchestrators and engine (same as execute()) + const resolver = new VariableResolver(this.workflow, this.workflowVariables, state) + const loopOrchestrator = new LoopOrchestrator(dag, state, resolver) + loopOrchestrator.setContextExtensions(this.contextExtensions) + const parallelOrchestrator = new ParallelOrchestrator(dag, state) + parallelOrchestrator.setResolver(resolver) + parallelOrchestrator.setContextExtensions(this.contextExtensions) + const allHandlers = createBlockHandlers() + const blockExecutor = new BlockExecutor(allHandlers, resolver, this.contextExtensions, state) + const edgeManager = new EdgeManager(dag) + loopOrchestrator.setEdgeManager(edgeManager) + const nodeOrchestrator = new NodeExecutionOrchestrator( + dag, + state, + blockExecutor, + loopOrchestrator, + parallelOrchestrator + ) + const engine = new ExecutionEngine(context, dag, edgeManager, nodeOrchestrator) + + // Run and return result + return await engine.run() + } + private createExecutionContext( workflowId: string, - triggerBlockId?: string + triggerBlockId?: string, + overrides?: { + snapshotState?: SerializableExecutionState + runFromBlockContext?: { startBlockId: string; dirtySet: Set } + } ): { context: ExecutionContext; state: ExecutionState } { - const snapshotState = this.contextExtensions.snapshotState + const snapshotState = overrides?.snapshotState ?? this.contextExtensions.snapshotState const blockStates = snapshotState?.blockStates ? new Map(Object.entries(snapshotState.blockStates)) : new Map() - const executedBlocks = snapshotState?.executedBlocks + let executedBlocks = snapshotState?.executedBlocks ? new Set(snapshotState.executedBlocks) : new Set() + + // In run-from-block mode, clear the executed status for dirty blocks so they can be re-executed + if (overrides?.runFromBlockContext) { + const { dirtySet } = overrides.runFromBlockContext + executedBlocks = new Set([...executedBlocks].filter((id) => !dirtySet.has(id))) + logger.info('Cleared executed status for dirty blocks', { + dirtySetSize: dirtySet.size, + remainingExecutedBlocks: executedBlocks.size, + }) + } + const state = new ExecutionState(blockStates, executedBlocks) const context: ExecutionContext = { @@ -169,6 +260,7 @@ export class DAGExecutor { abortSignal: this.contextExtensions.abortSignal, includeFileBase64: this.contextExtensions.includeFileBase64, base64MaxBytes: this.contextExtensions.base64MaxBytes, + runFromBlockContext: overrides?.runFromBlockContext, } if (this.contextExtensions.resumeFromSnapshot) { @@ -193,6 +285,12 @@ export class DAGExecutor { pendingBlocks: context.metadata.pendingBlocks, 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, + }) } else { this.initializeStarterBlock(context, state, triggerBlockId) } diff --git a/apps/sim/executor/execution/types.ts b/apps/sim/executor/execution/types.ts index 701f5de357..73e6e11baa 100644 --- a/apps/sim/executor/execution/types.ts +++ b/apps/sim/executor/execution/types.ts @@ -105,6 +105,15 @@ export interface ContextExtensions { output: { input?: any; output: NormalizedBlockOutput; executionTime: number }, iterationContext?: IterationContext ) => Promise + + /** + * Run-from-block configuration. When provided, executor runs in partial + * execution mode starting from the specified block. + */ + runFromBlockContext?: { + startBlockId: string + dirtySet: Set + } } export interface WorkflowInput { diff --git a/apps/sim/executor/orchestrators/node.ts b/apps/sim/executor/orchestrators/node.ts index e5d7bc1a11..244b54abd5 100644 --- a/apps/sim/executor/orchestrators/node.ts +++ b/apps/sim/executor/orchestrators/node.ts @@ -31,7 +31,20 @@ export class NodeExecutionOrchestrator { throw new Error(`Node not found in DAG: ${nodeId}`) } - if (this.state.hasExecuted(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 }) + return { + nodeId, + output: cachedOutput, + isFinalOutput: false, + } + } + + // 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) || {} return { nodeId, diff --git a/apps/sim/executor/types.ts b/apps/sim/executor/types.ts index 27eaa0c2bc..aa4e05523b 100644 --- a/apps/sim/executor/types.ts +++ b/apps/sim/executor/types.ts @@ -250,6 +250,15 @@ export interface ExecutionContext { * will not have their base64 content fetched. */ base64MaxBytes?: number + + /** + * Context for "run from block" mode. When present, only blocks in dirtySet + * will be executed; others return cached outputs from the source snapshot. + */ + runFromBlockContext?: { + startBlockId: string + dirtySet: Set + } } export interface ExecutionResult { diff --git a/apps/sim/executor/utils/run-from-block.test.ts b/apps/sim/executor/utils/run-from-block.test.ts new file mode 100644 index 0000000000..2e66d9fdfb --- /dev/null +++ b/apps/sim/executor/utils/run-from-block.test.ts @@ -0,0 +1,272 @@ +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' + +/** + * Helper to create a DAG node for testing + */ +function createNode( + id: string, + outgoingEdges: Array<{ target: string; sourceHandle?: string }> = [], + metadata: Partial = {} +): DAGNode { + const edges = new Map() + for (const edge of outgoingEdges) { + edges.set(edge.target, { target: edge.target, sourceHandle: edge.sourceHandle }) + } + + return { + id, + block: { + id, + position: { x: 0, y: 0 }, + config: { tool: 'test', params: {} }, + inputs: {}, + outputs: {}, + metadata: { id: 'test', name: `block-${id}`, category: 'tools' }, + enabled: true, + }, + incomingEdges: new Set(), + outgoingEdges: edges, + metadata: { + isParallelBranch: false, + isLoopNode: false, + isSentinel: false, + ...metadata, + }, + } +} + +/** + * Helper to create a DAG for testing + */ +function createDAG(nodes: DAGNode[]): DAG { + const nodeMap = new Map() + for (const node of nodes) { + nodeMap.set(node.id, node) + } + + // Set up incoming edges based on outgoing edges + for (const node of nodes) { + for (const [, edge] of node.outgoingEdges) { + const targetNode = nodeMap.get(edge.target) + if (targetNode) { + targetNode.incomingEdges.add(node.id) + } + } + } + + return { + nodes: nodeMap, + loopConfigs: new Map(), + parallelConfigs: new Map(), + } +} + +describe('computeDirtySet', () => { + it.concurrent('includes start block in dirty set', () => { + const dag = createDAG([createNode('A'), createNode('B'), createNode('C')]) + + const dirtySet = computeDirtySet(dag, 'B') + + expect(dirtySet.has('B')).toBe(true) + }) + + it.concurrent('includes all downstream blocks in linear workflow', () => { + // A → B → C → D + const dag = createDAG([ + createNode('A', [{ target: 'B' }]), + createNode('B', [{ target: 'C' }]), + createNode('C', [{ target: 'D' }]), + createNode('D'), + ]) + + const dirtySet = computeDirtySet(dag, 'B') + + expect(dirtySet.has('A')).toBe(false) + expect(dirtySet.has('B')).toBe(true) + expect(dirtySet.has('C')).toBe(true) + expect(dirtySet.has('D')).toBe(true) + expect(dirtySet.size).toBe(3) + }) + + it.concurrent('handles branching paths', () => { + // A → B → C + // ↓ + // D → E + const dag = createDAG([ + createNode('A', [{ target: 'B' }]), + createNode('B', [{ target: 'C' }, { target: 'D' }]), + createNode('C'), + createNode('D', [{ target: 'E' }]), + createNode('E'), + ]) + + const dirtySet = computeDirtySet(dag, 'B') + + expect(dirtySet.has('A')).toBe(false) + expect(dirtySet.has('B')).toBe(true) + expect(dirtySet.has('C')).toBe(true) + expect(dirtySet.has('D')).toBe(true) + expect(dirtySet.has('E')).toBe(true) + expect(dirtySet.size).toBe(4) + }) + + it.concurrent('handles convergence points', () => { + // A → C + // B → C → D + const dag = createDAG([ + createNode('A', [{ target: 'C' }]), + createNode('B', [{ target: 'C' }]), + createNode('C', [{ target: 'D' }]), + createNode('D'), + ]) + + // Run from A: should include A, C, D (but not B) + const dirtySet = computeDirtySet(dag, 'A') + + expect(dirtySet.has('A')).toBe(true) + expect(dirtySet.has('B')).toBe(false) + expect(dirtySet.has('C')).toBe(true) + expect(dirtySet.has('D')).toBe(true) + expect(dirtySet.size).toBe(3) + }) + + it.concurrent('handles diamond pattern', () => { + // B + // ↗ ↘ + // A D + // ↘ ↗ + // C + const dag = createDAG([ + createNode('A', [{ target: 'B' }, { target: 'C' }]), + createNode('B', [{ target: 'D' }]), + createNode('C', [{ target: 'D' }]), + createNode('D'), + ]) + + const dirtySet = computeDirtySet(dag, 'A') + + expect(dirtySet.has('A')).toBe(true) + expect(dirtySet.has('B')).toBe(true) + expect(dirtySet.has('C')).toBe(true) + expect(dirtySet.has('D')).toBe(true) + expect(dirtySet.size).toBe(4) + }) + + it.concurrent('stops at graph boundaries', () => { + // A → B C → D (disconnected) + const dag = createDAG([ + createNode('A', [{ target: 'B' }]), + createNode('B'), + createNode('C', [{ target: 'D' }]), + createNode('D'), + ]) + + const dirtySet = computeDirtySet(dag, 'A') + + expect(dirtySet.has('A')).toBe(true) + expect(dirtySet.has('B')).toBe(true) + expect(dirtySet.has('C')).toBe(false) + expect(dirtySet.has('D')).toBe(false) + expect(dirtySet.size).toBe(2) + }) + + it.concurrent('handles single node workflow', () => { + const dag = createDAG([createNode('A')]) + + const dirtySet = computeDirtySet(dag, 'A') + + expect(dirtySet.has('A')).toBe(true) + expect(dirtySet.size).toBe(1) + }) + + it.concurrent('handles node not in DAG gracefully', () => { + const dag = createDAG([createNode('A'), createNode('B')]) + + const dirtySet = computeDirtySet(dag, 'nonexistent') + + // Should just contain the start block ID even if not found + expect(dirtySet.has('nonexistent')).toBe(true) + expect(dirtySet.size).toBe(1) + }) +}) + +describe('validateRunFromBlock', () => { + it.concurrent('accepts valid block', () => { + const dag = createDAG([createNode('A'), createNode('B')]) + const executedBlocks = new Set(['A', 'B']) + + const result = validateRunFromBlock('A', dag, executedBlocks) + + expect(result.valid).toBe(true) + expect(result.error).toBeUndefined() + }) + + it.concurrent('rejects block not found in DAG', () => { + const dag = createDAG([createNode('A')]) + const executedBlocks = new Set(['A', 'B']) + + const result = validateRunFromBlock('B', dag, executedBlocks) + + expect(result.valid).toBe(false) + expect(result.error).toContain('Block not found') + }) + + it.concurrent('rejects blocks inside loops', () => { + const dag = createDAG([createNode('A', [], { isLoopNode: true, loopId: 'loop-1' })]) + const executedBlocks = new Set(['A']) + + const result = validateRunFromBlock('A', dag, executedBlocks) + + expect(result.valid).toBe(false) + expect(result.error).toContain('inside loop') + expect(result.error).toContain('loop-1') + }) + + it.concurrent('rejects blocks inside parallels', () => { + const dag = createDAG([createNode('A', [], { isParallelBranch: true, parallelId: 'parallel-1' })]) + const executedBlocks = new Set(['A']) + + const result = validateRunFromBlock('A', dag, executedBlocks) + + expect(result.valid).toBe(false) + expect(result.error).toContain('inside parallel') + expect(result.error).toContain('parallel-1') + }) + + it.concurrent('rejects sentinel nodes', () => { + const dag = createDAG([createNode('A', [], { isSentinel: true, sentinelType: 'start' })]) + const executedBlocks = new Set(['A']) + + const result = validateRunFromBlock('A', dag, executedBlocks) + + expect(result.valid).toBe(false) + expect(result.error).toContain('sentinel') + }) + + it.concurrent('rejects unexecuted blocks', () => { + const dag = createDAG([createNode('A'), createNode('B')]) + const executedBlocks = new Set(['A']) // B was not executed + + const result = validateRunFromBlock('B', dag, executedBlocks) + + expect(result.valid).toBe(false) + expect(result.error).toContain('was not executed') + }) + + it.concurrent('accepts regular executed block', () => { + const dag = createDAG([ + createNode('trigger', [{ target: 'A' }]), + createNode('A', [{ target: 'B' }]), + createNode('B'), + ]) + const executedBlocks = new Set(['trigger', 'A', 'B']) + + const result = validateRunFromBlock('A', dag, executedBlocks) + + expect(result.valid).toBe(true) + }) +}) diff --git a/apps/sim/executor/utils/run-from-block.ts b/apps/sim/executor/utils/run-from-block.ts new file mode 100644 index 0000000000..57e1e81e82 --- /dev/null +++ b/apps/sim/executor/utils/run-from-block.ts @@ -0,0 +1,110 @@ +import { createLogger } from '@sim/logger' +import type { DAG } from '@/executor/dag/builder' + +const logger = createLogger('run-from-block') + +/** + * Result of validating a block for run-from-block execution. + */ +export interface RunFromBlockValidation { + valid: boolean + error?: string +} + +/** + * Context for run-from-block execution mode. + */ +export interface RunFromBlockContext { + /** The block ID to start execution from */ + startBlockId: string + /** Set of block IDs that need re-execution (start block + all downstream) */ + dirtySet: Set +} + +/** + * Computes all blocks that need re-execution when running from a specific block. + * Uses BFS to find all downstream blocks reachable via outgoing edges. + * + * @param dag - The workflow DAG + * @param startBlockId - The block to start execution from + * @returns Set of block IDs that are "dirty" and need re-execution + */ +export function computeDirtySet(dag: DAG, startBlockId: string): Set { + const dirty = new Set([startBlockId]) + const queue = [startBlockId] + + while (queue.length > 0) { + const nodeId = queue.shift()! + const node = dag.nodes.get(nodeId) + if (!node) continue + + for (const [, edge] of node.outgoingEdges) { + if (!dirty.has(edge.target)) { + dirty.add(edge.target) + queue.push(edge.target) + } + } + } + + logger.debug('Computed dirty set', { + startBlockId, + dirtySetSize: dirty.size, + dirtyBlocks: Array.from(dirty), + }) + + return dirty +} + +/** + * Validates that a block can be used as a run-from-block starting point. + * + * Validation rules: + * - Block must exist in the DAG + * - Block cannot be inside a loop + * - Block cannot be inside a parallel + * - Block cannot be a sentinel node + * - Block must have been executed in the source run + * + * @param blockId - The block ID to validate + * @param dag - The workflow DAG + * @param executedBlocks - Set of blocks that were executed in the source run + * @returns Validation result with error message if invalid + */ +export function validateRunFromBlock( + blockId: string, + dag: DAG, + executedBlocks: Set +): RunFromBlockValidation { + const node = dag.nodes.get(blockId) + + if (!node) { + return { valid: false, error: `Block not found in workflow: ${blockId}` } + } + + if (node.metadata.isLoopNode) { + return { + valid: false, + error: `Cannot run from block inside loop: ${node.metadata.loopId}`, + } + } + + if (node.metadata.isParallelBranch) { + return { + valid: false, + error: `Cannot run from block inside parallel: ${node.metadata.parallelId}`, + } + } + + if (node.metadata.isSentinel) { + return { valid: false, error: 'Cannot run from sentinel node' } + } + + if (!executedBlocks.has(blockId)) { + return { + valid: false, + error: `Block was not executed in source run: ${blockId}`, + } + } + + return { valid: true } +} diff --git a/apps/sim/hooks/use-execution-stream.ts b/apps/sim/hooks/use-execution-stream.ts index b9d1cc6858..ae5ab2d045 100644 --- a/apps/sim/hooks/use-execution-stream.ts +++ b/apps/sim/hooks/use-execution-stream.ts @@ -1,6 +1,7 @@ import { useCallback, useRef } from 'react' import { createLogger } from '@sim/logger' import type { ExecutionEvent } from '@/lib/workflows/executor/execution-events' +import type { SerializableExecutionState } from '@/executor/execution/types' import type { SubflowType } from '@/stores/workflows/workflow/types' const logger = createLogger('useExecutionStream') @@ -71,6 +72,13 @@ export interface ExecuteStreamOptions { callbacks?: ExecutionStreamCallbacks } +export interface ExecuteFromBlockOptions { + workflowId: string + startBlockId: string + sourceSnapshot: SerializableExecutionState + callbacks?: ExecutionStreamCallbacks +} + /** * Hook for executing workflows via server-side SSE streaming */ @@ -222,6 +230,140 @@ export function useExecutionStream() { } }, []) + const executeFromBlock = useCallback(async (options: ExecuteFromBlockOptions) => { + const { workflowId, startBlockId, sourceSnapshot, callbacks = {} } = options + + if (abortControllerRef.current) { + abortControllerRef.current.abort() + } + + const abortController = new AbortController() + abortControllerRef.current = abortController + currentExecutionRef.current = null + + try { + const response = await fetch(`/api/workflows/${workflowId}/execute-from-block`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ startBlockId, sourceSnapshot }), + signal: abortController.signal, + }) + + if (!response.ok) { + const errorResponse = await response.json() + const error = new Error(errorResponse.error || 'Failed to start execution') + if (errorResponse && typeof errorResponse === 'object') { + Object.assign(error, { executionResult: errorResponse }) + } + throw error + } + + if (!response.body) { + throw new Error('No response body') + } + + const executionId = response.headers.get('X-Execution-Id') + if (executionId) { + currentExecutionRef.current = { workflowId, executionId } + } + + const reader = response.body.getReader() + const decoder = new TextDecoder() + let buffer = '' + + try { + while (true) { + const { done, value } = await reader.read() + + if (done) { + break + } + + buffer += decoder.decode(value, { stream: true }) + + const lines = buffer.split('\n\n') + + buffer = lines.pop() || '' + + for (const line of lines) { + if (!line.trim() || !line.startsWith('data: ')) { + continue + } + + const data = line.substring(6).trim() + + if (data === '[DONE]') { + logger.info('Run-from-block stream completed') + continue + } + + try { + const event = JSON.parse(data) as ExecutionEvent + + logger.info('📡 Run-from-block SSE Event:', { + type: event.type, + executionId: event.executionId, + }) + + switch (event.type) { + case 'execution:started': + callbacks.onExecutionStarted?.(event.data) + break + case 'execution:completed': + callbacks.onExecutionCompleted?.(event.data) + break + case 'execution:error': + callbacks.onExecutionError?.(event.data) + break + case 'execution:cancelled': + callbacks.onExecutionCancelled?.(event.data) + break + case 'block:started': + callbacks.onBlockStarted?.(event.data) + break + case 'block:completed': + callbacks.onBlockCompleted?.(event.data) + break + case 'block:error': + callbacks.onBlockError?.(event.data) + break + case 'stream:chunk': + callbacks.onStreamChunk?.(event.data) + break + case 'stream:done': + callbacks.onStreamDone?.(event.data) + break + default: + logger.warn('Unknown event type:', (event as any).type) + } + } catch (error) { + logger.error('Failed to parse SSE event:', error, { data }) + } + } + } + } finally { + reader.releaseLock() + } + } catch (error: any) { + if (error.name === 'AbortError') { + logger.info('Run-from-block execution cancelled') + callbacks.onExecutionCancelled?.({ duration: 0 }) + } else { + logger.error('Run-from-block execution error:', error) + callbacks.onExecutionError?.({ + error: error.message || 'Unknown error', + duration: 0, + }) + } + throw error + } finally { + abortControllerRef.current = null + currentExecutionRef.current = null + } + }, []) + const cancel = useCallback(() => { const execution = currentExecutionRef.current if (execution) { @@ -239,6 +381,7 @@ export function useExecutionStream() { return { execute, + executeFromBlock, cancel, } } diff --git a/apps/sim/lib/copilot/models.ts b/apps/sim/lib/copilot/models.ts index 83a90169be..3dec2ef884 100644 --- a/apps/sim/lib/copilot/models.ts +++ b/apps/sim/lib/copilot/models.ts @@ -21,6 +21,7 @@ export const COPILOT_MODEL_IDS = [ 'claude-4.5-opus', 'claude-4.1-opus', 'gemini-3-pro', + 'auto', ] as const export type CopilotModelId = (typeof COPILOT_MODEL_IDS)[number] diff --git a/apps/sim/stores/execution/store.ts b/apps/sim/stores/execution/store.ts index 87fa444ecf..912579d4c2 100644 --- a/apps/sim/stores/execution/store.ts +++ b/apps/sim/stores/execution/store.ts @@ -35,4 +35,23 @@ export const useExecutionStore = create()((se }, clearRunPath: () => set({ lastRunPath: new Map(), lastRunEdges: new Map() }), reset: () => set(initialState), + + setLastExecutionSnapshot: (workflowId, snapshot) => { + const { lastExecutionSnapshots } = get() + const newSnapshots = new Map(lastExecutionSnapshots) + newSnapshots.set(workflowId, snapshot) + set({ lastExecutionSnapshots: newSnapshots }) + }, + + getLastExecutionSnapshot: (workflowId) => { + const { lastExecutionSnapshots } = get() + return lastExecutionSnapshots.get(workflowId) + }, + + clearLastExecutionSnapshot: (workflowId) => { + const { lastExecutionSnapshots } = get() + const newSnapshots = new Map(lastExecutionSnapshots) + newSnapshots.delete(workflowId) + set({ lastExecutionSnapshots: newSnapshots }) + }, })) diff --git a/apps/sim/stores/execution/types.ts b/apps/sim/stores/execution/types.ts index 3994f4aab9..27e3f79d3b 100644 --- a/apps/sim/stores/execution/types.ts +++ b/apps/sim/stores/execution/types.ts @@ -1,4 +1,5 @@ import type { Executor } from '@/executor' +import type { SerializableExecutionState } from '@/executor/execution/types' import type { ExecutionContext } from '@/executor/types' /** @@ -28,6 +29,11 @@ export interface ExecutionState { * Cleared when a new run starts. Used to show run path indicators on edges. */ lastRunEdges: Map + /** + * Stores the last successful execution snapshot per workflow. + * Used for run-from-block functionality. + */ + lastExecutionSnapshots: Map } export interface ExecutionActions { @@ -41,6 +47,18 @@ export interface ExecutionActions { setEdgeRunStatus: (edgeId: string, status: EdgeRunStatus) => void clearRunPath: () => void reset: () => void + /** + * Store the execution snapshot for a workflow after successful execution. + */ + setLastExecutionSnapshot: (workflowId: string, snapshot: SerializableExecutionState) => void + /** + * Get the last execution snapshot for a workflow. + */ + getLastExecutionSnapshot: (workflowId: string) => SerializableExecutionState | undefined + /** + * Clear the execution snapshot for a workflow. + */ + clearLastExecutionSnapshot: (workflowId: string) => void } export const initialState: ExecutionState = { @@ -52,4 +70,5 @@ export const initialState: ExecutionState = { debugContext: null, lastRunPath: new Map(), lastRunEdges: new Map(), + lastExecutionSnapshots: new Map(), } From e8534bea7afbd2f692447a23ee09c7b1f519199f Mon Sep 17 00:00:00 2001 From: Siddharth Ganesan Date: Mon, 26 Jan 2026 16:40:14 -0800 Subject: [PATCH 02/33] Fixes --- .../components/action-bar/action-bar.tsx | 23 -- apps/sim/executor/execution/executor.ts | 31 ++- apps/sim/executor/execution/types.ts | 6 +- apps/sim/executor/types.ts | 6 +- .../sim/executor/utils/run-from-block.test.ts | 51 ++-- apps/sim/hooks/use-execution-stream.ts | 237 ++++++------------ 6 files changed, 145 insertions(+), 209 deletions(-) diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/action-bar/action-bar.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/action-bar/action-bar.tsx index f8a816a329..1976d9e815 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/action-bar/action-bar.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/action-bar/action-bar.tsx @@ -182,29 +182,6 @@ export const ActionBar = memo( )} - {isSubflowBlock && ( - - - - - - {getTooltipMessage(isEnabled ? 'Disable Block' : 'Enable Block')} - - - )} - {canRunFromBlock && ( diff --git a/apps/sim/executor/execution/executor.ts b/apps/sim/executor/execution/executor.ts index 3351aaff87..021fda0227 100644 --- a/apps/sim/executor/execution/executor.ts +++ b/apps/sim/executor/execution/executor.ts @@ -15,7 +15,11 @@ import { LoopOrchestrator } from '@/executor/orchestrators/loop' import { NodeExecutionOrchestrator } from '@/executor/orchestrators/node' import { ParallelOrchestrator } from '@/executor/orchestrators/parallel' import type { BlockState, ExecutionContext, ExecutionResult } from '@/executor/types' -import { computeDirtySet, validateRunFromBlock } from '@/executor/utils/run-from-block' +import { + computeDirtySet, + type RunFromBlockContext, + validateRunFromBlock, +} from '@/executor/utils/run-from-block' import { buildResolutionFromBlock, buildStartBlockOutput, @@ -134,6 +138,29 @@ export class DAGExecutor { dirtyBlocks: Array.from(dirtySet), }) + // For convergent blocks in the dirty set, remove incoming edges from non-dirty sources. + // This ensures that a dirty block waiting on multiple inputs doesn't wait for non-dirty + // upstream blocks (whose outputs are already cached). + for (const nodeId of dirtySet) { + const node = dag.nodes.get(nodeId) + if (!node) continue + + const nonDirtyIncoming: string[] = [] + for (const sourceId of node.incomingEdges) { + if (!dirtySet.has(sourceId)) { + nonDirtyIncoming.push(sourceId) + } + } + + for (const sourceId of nonDirtyIncoming) { + node.incomingEdges.delete(sourceId) + logger.debug('Removed non-dirty incoming edge for run-from-block', { + nodeId, + sourceId, + }) + } + } + // Create context with snapshot state + runFromBlockContext const runFromBlockContext = { startBlockId, dirtySet } const { context, state } = this.createExecutionContext(workflowId, undefined, { @@ -170,7 +197,7 @@ export class DAGExecutor { triggerBlockId?: string, overrides?: { snapshotState?: SerializableExecutionState - runFromBlockContext?: { startBlockId: string; dirtySet: Set } + runFromBlockContext?: RunFromBlockContext } ): { context: ExecutionContext; state: ExecutionState } { const snapshotState = overrides?.snapshotState ?? this.contextExtensions.snapshotState diff --git a/apps/sim/executor/execution/types.ts b/apps/sim/executor/execution/types.ts index 73e6e11baa..9a4ffb691b 100644 --- a/apps/sim/executor/execution/types.ts +++ b/apps/sim/executor/execution/types.ts @@ -1,5 +1,6 @@ import type { Edge } from 'reactflow' import type { BlockLog, BlockState, NormalizedBlockOutput } from '@/executor/types' +import type { RunFromBlockContext } from '@/executor/utils/run-from-block' import type { SubflowType } from '@/stores/workflows/workflow/types' export interface ExecutionMetadata { @@ -110,10 +111,7 @@ export interface ContextExtensions { * Run-from-block configuration. When provided, executor runs in partial * execution mode starting from the specified block. */ - runFromBlockContext?: { - startBlockId: string - dirtySet: Set - } + runFromBlockContext?: RunFromBlockContext } export interface WorkflowInput { diff --git a/apps/sim/executor/types.ts b/apps/sim/executor/types.ts index aa4e05523b..35ff1c3c00 100644 --- a/apps/sim/executor/types.ts +++ b/apps/sim/executor/types.ts @@ -1,6 +1,7 @@ import type { TraceSpan } from '@/lib/logs/types' import type { PermissionGroupConfig } from '@/lib/permission-groups/types' import type { BlockOutput } from '@/blocks/types' +import type { RunFromBlockContext } from '@/executor/utils/run-from-block' import type { SerializedBlock, SerializedWorkflow } from '@/serializer/types' export interface UserFile { @@ -255,10 +256,7 @@ export interface ExecutionContext { * Context for "run from block" mode. When present, only blocks in dirtySet * will be executed; others return cached outputs from the source snapshot. */ - runFromBlockContext?: { - startBlockId: string - dirtySet: Set - } + runFromBlockContext?: RunFromBlockContext } export interface ExecutionResult { diff --git a/apps/sim/executor/utils/run-from-block.test.ts b/apps/sim/executor/utils/run-from-block.test.ts index 2e66d9fdfb..687e86f0da 100644 --- a/apps/sim/executor/utils/run-from-block.test.ts +++ b/apps/sim/executor/utils/run-from-block.test.ts @@ -66,7 +66,7 @@ function createDAG(nodes: DAGNode[]): DAG { } describe('computeDirtySet', () => { - it.concurrent('includes start block in dirty set', () => { + it('includes start block in dirty set', () => { const dag = createDAG([createNode('A'), createNode('B'), createNode('C')]) const dirtySet = computeDirtySet(dag, 'B') @@ -74,7 +74,7 @@ describe('computeDirtySet', () => { expect(dirtySet.has('B')).toBe(true) }) - it.concurrent('includes all downstream blocks in linear workflow', () => { + it('includes all downstream blocks in linear workflow', () => { // A → B → C → D const dag = createDAG([ createNode('A', [{ target: 'B' }]), @@ -92,7 +92,7 @@ describe('computeDirtySet', () => { expect(dirtySet.size).toBe(3) }) - it.concurrent('handles branching paths', () => { + it('handles branching paths', () => { // A → B → C // ↓ // D → E @@ -114,7 +114,7 @@ describe('computeDirtySet', () => { expect(dirtySet.size).toBe(4) }) - it.concurrent('handles convergence points', () => { + it('handles convergence points', () => { // A → C // B → C → D const dag = createDAG([ @@ -134,7 +134,7 @@ describe('computeDirtySet', () => { expect(dirtySet.size).toBe(3) }) - it.concurrent('handles diamond pattern', () => { + it('handles diamond pattern', () => { // B // ↗ ↘ // A D @@ -156,7 +156,7 @@ describe('computeDirtySet', () => { expect(dirtySet.size).toBe(4) }) - it.concurrent('stops at graph boundaries', () => { + it('stops at graph boundaries', () => { // A → B C → D (disconnected) const dag = createDAG([ createNode('A', [{ target: 'B' }]), @@ -174,7 +174,7 @@ describe('computeDirtySet', () => { expect(dirtySet.size).toBe(2) }) - it.concurrent('handles single node workflow', () => { + it('handles single node workflow', () => { const dag = createDAG([createNode('A')]) const dirtySet = computeDirtySet(dag, 'A') @@ -183,7 +183,7 @@ describe('computeDirtySet', () => { expect(dirtySet.size).toBe(1) }) - it.concurrent('handles node not in DAG gracefully', () => { + it('handles node not in DAG gracefully', () => { const dag = createDAG([createNode('A'), createNode('B')]) const dirtySet = computeDirtySet(dag, 'nonexistent') @@ -192,10 +192,31 @@ describe('computeDirtySet', () => { expect(dirtySet.has('nonexistent')).toBe(true) expect(dirtySet.size).toBe(1) }) + + it('includes convergent block when running from one branch of parallel', () => { + // Parallel branches converging: + // A → B → D + // A → C → D + // Running from B should include B and D (but not A or C) + const dag = createDAG([ + createNode('A', [{ target: 'B' }, { target: 'C' }]), + createNode('B', [{ target: 'D' }]), + createNode('C', [{ target: 'D' }]), + createNode('D'), + ]) + + const dirtySet = computeDirtySet(dag, 'B') + + expect(dirtySet.has('A')).toBe(false) + expect(dirtySet.has('B')).toBe(true) + expect(dirtySet.has('C')).toBe(false) + expect(dirtySet.has('D')).toBe(true) + expect(dirtySet.size).toBe(2) + }) }) describe('validateRunFromBlock', () => { - it.concurrent('accepts valid block', () => { + it('accepts valid block', () => { const dag = createDAG([createNode('A'), createNode('B')]) const executedBlocks = new Set(['A', 'B']) @@ -205,7 +226,7 @@ describe('validateRunFromBlock', () => { expect(result.error).toBeUndefined() }) - it.concurrent('rejects block not found in DAG', () => { + it('rejects block not found in DAG', () => { const dag = createDAG([createNode('A')]) const executedBlocks = new Set(['A', 'B']) @@ -215,7 +236,7 @@ describe('validateRunFromBlock', () => { expect(result.error).toContain('Block not found') }) - it.concurrent('rejects blocks inside loops', () => { + it('rejects blocks inside loops', () => { const dag = createDAG([createNode('A', [], { isLoopNode: true, loopId: 'loop-1' })]) const executedBlocks = new Set(['A']) @@ -226,7 +247,7 @@ describe('validateRunFromBlock', () => { expect(result.error).toContain('loop-1') }) - it.concurrent('rejects blocks inside parallels', () => { + it('rejects blocks inside parallels', () => { const dag = createDAG([createNode('A', [], { isParallelBranch: true, parallelId: 'parallel-1' })]) const executedBlocks = new Set(['A']) @@ -237,7 +258,7 @@ describe('validateRunFromBlock', () => { expect(result.error).toContain('parallel-1') }) - it.concurrent('rejects sentinel nodes', () => { + it('rejects sentinel nodes', () => { const dag = createDAG([createNode('A', [], { isSentinel: true, sentinelType: 'start' })]) const executedBlocks = new Set(['A']) @@ -247,7 +268,7 @@ describe('validateRunFromBlock', () => { expect(result.error).toContain('sentinel') }) - it.concurrent('rejects unexecuted blocks', () => { + it('rejects unexecuted blocks', () => { const dag = createDAG([createNode('A'), createNode('B')]) const executedBlocks = new Set(['A']) // B was not executed @@ -257,7 +278,7 @@ describe('validateRunFromBlock', () => { expect(result.error).toContain('was not executed') }) - it.concurrent('accepts regular executed block', () => { + it('accepts regular executed block', () => { const dag = createDAG([ createNode('trigger', [{ target: 'A' }]), createNode('A', [{ target: 'B' }]), diff --git a/apps/sim/hooks/use-execution-stream.ts b/apps/sim/hooks/use-execution-stream.ts index ae5ab2d045..bd36cb55da 100644 --- a/apps/sim/hooks/use-execution-stream.ts +++ b/apps/sim/hooks/use-execution-stream.ts @@ -6,6 +6,80 @@ import type { SubflowType } from '@/stores/workflows/workflow/types' const logger = createLogger('useExecutionStream') +/** + * Processes SSE events from a response body and invokes appropriate callbacks. + */ +async function processSSEStream( + reader: ReadableStreamDefaultReader, + callbacks: ExecutionStreamCallbacks, + logPrefix: string +): Promise { + const decoder = new TextDecoder() + let buffer = '' + + try { + while (true) { + const { done, value } = await reader.read() + + if (done) break + + buffer += decoder.decode(value, { stream: true }) + const lines = buffer.split('\n\n') + buffer = lines.pop() || '' + + for (const line of lines) { + if (!line.trim() || !line.startsWith('data: ')) continue + + const data = line.substring(6).trim() + if (data === '[DONE]') { + logger.info(`${logPrefix} stream completed`) + continue + } + + try { + const event = JSON.parse(data) as ExecutionEvent + + switch (event.type) { + case 'execution:started': + callbacks.onExecutionStarted?.(event.data) + break + case 'execution:completed': + callbacks.onExecutionCompleted?.(event.data) + break + case 'execution:error': + callbacks.onExecutionError?.(event.data) + break + case 'execution:cancelled': + callbacks.onExecutionCancelled?.(event.data) + break + case 'block:started': + callbacks.onBlockStarted?.(event.data) + break + case 'block:completed': + callbacks.onBlockCompleted?.(event.data) + break + case 'block:error': + callbacks.onBlockError?.(event.data) + break + case 'stream:chunk': + callbacks.onStreamChunk?.(event.data) + break + case 'stream:done': + callbacks.onStreamDone?.(event.data) + break + default: + logger.warn('Unknown event type:', (event as any).type) + } + } catch (error) { + logger.error('Failed to parse SSE event:', error, { data }) + } + } + } + } finally { + reader.releaseLock() + } +} + export interface ExecutionStreamCallbacks { onExecutionStarted?: (data: { startTime: string }) => void onExecutionCompleted?: (data: { @@ -127,91 +201,7 @@ export function useExecutionStream() { } const reader = response.body.getReader() - const decoder = new TextDecoder() - let buffer = '' - - try { - while (true) { - const { done, value } = await reader.read() - - if (done) { - break - } - - buffer += decoder.decode(value, { stream: true }) - - const lines = buffer.split('\n\n') - - buffer = lines.pop() || '' - - for (const line of lines) { - if (!line.trim() || !line.startsWith('data: ')) { - continue - } - - const data = line.substring(6).trim() - - if (data === '[DONE]') { - logger.info('Stream completed') - continue - } - - try { - const event = JSON.parse(data) as ExecutionEvent - - logger.info('📡 SSE Event received:', { - type: event.type, - executionId: event.executionId, - data: event.data, - }) - - switch (event.type) { - case 'execution:started': - logger.info('🚀 Execution started') - callbacks.onExecutionStarted?.(event.data) - break - case 'execution:completed': - logger.info('✅ Execution completed') - callbacks.onExecutionCompleted?.(event.data) - break - case 'execution:error': - logger.error('❌ Execution error') - callbacks.onExecutionError?.(event.data) - break - case 'execution:cancelled': - logger.warn('🛑 Execution cancelled') - callbacks.onExecutionCancelled?.(event.data) - break - case 'block:started': - logger.info('🔷 Block started:', event.data.blockId) - callbacks.onBlockStarted?.(event.data) - break - case 'block:completed': - logger.info('✓ Block completed:', event.data.blockId) - callbacks.onBlockCompleted?.(event.data) - break - case 'block:error': - logger.error('✗ Block error:', event.data.blockId) - callbacks.onBlockError?.(event.data) - break - case 'stream:chunk': - callbacks.onStreamChunk?.(event.data) - break - case 'stream:done': - logger.info('Stream done:', event.data.blockId) - callbacks.onStreamDone?.(event.data) - break - default: - logger.warn('Unknown event type:', (event as any).type) - } - } catch (error) { - logger.error('Failed to parse SSE event:', error, { data }) - } - } - } - } finally { - reader.releaseLock() - } + await processSSEStream(reader, callbacks, 'Execution') } catch (error: any) { if (error.name === 'AbortError') { logger.info('Execution stream cancelled') @@ -270,82 +260,7 @@ export function useExecutionStream() { } const reader = response.body.getReader() - const decoder = new TextDecoder() - let buffer = '' - - try { - while (true) { - const { done, value } = await reader.read() - - if (done) { - break - } - - buffer += decoder.decode(value, { stream: true }) - - const lines = buffer.split('\n\n') - - buffer = lines.pop() || '' - - for (const line of lines) { - if (!line.trim() || !line.startsWith('data: ')) { - continue - } - - const data = line.substring(6).trim() - - if (data === '[DONE]') { - logger.info('Run-from-block stream completed') - continue - } - - try { - const event = JSON.parse(data) as ExecutionEvent - - logger.info('📡 Run-from-block SSE Event:', { - type: event.type, - executionId: event.executionId, - }) - - switch (event.type) { - case 'execution:started': - callbacks.onExecutionStarted?.(event.data) - break - case 'execution:completed': - callbacks.onExecutionCompleted?.(event.data) - break - case 'execution:error': - callbacks.onExecutionError?.(event.data) - break - case 'execution:cancelled': - callbacks.onExecutionCancelled?.(event.data) - break - case 'block:started': - callbacks.onBlockStarted?.(event.data) - break - case 'block:completed': - callbacks.onBlockCompleted?.(event.data) - break - case 'block:error': - callbacks.onBlockError?.(event.data) - break - case 'stream:chunk': - callbacks.onStreamChunk?.(event.data) - break - case 'stream:done': - callbacks.onStreamDone?.(event.data) - break - default: - logger.warn('Unknown event type:', (event as any).type) - } - } catch (error) { - logger.error('Failed to parse SSE event:', error, { data }) - } - } - } - } finally { - reader.releaseLock() - } + await processSSEStream(reader, callbacks, 'Run-from-block') } catch (error: any) { if (error.name === 'AbortError') { logger.info('Run-from-block execution cancelled') From da5d4ac9d57c9dcf68cc9c6ddbe49548fe871d19 Mon Sep 17 00:00:00 2001 From: Siddharth Ganesan Date: Mon, 26 Jan 2026 17:16:35 -0800 Subject: [PATCH 03/33] Fix --- .../sim/executor/utils/run-from-block.test.ts | 43 +++++++++++++++++++ 1 file changed, 43 insertions(+) diff --git a/apps/sim/executor/utils/run-from-block.test.ts b/apps/sim/executor/utils/run-from-block.test.ts index 687e86f0da..5c7ac3b5cc 100644 --- a/apps/sim/executor/utils/run-from-block.test.ts +++ b/apps/sim/executor/utils/run-from-block.test.ts @@ -213,6 +213,49 @@ describe('computeDirtySet', () => { expect(dirtySet.has('D')).toBe(true) expect(dirtySet.size).toBe(2) }) + + it('handles running from convergent block itself (all upstream non-dirty)', () => { + // A → C + // B → C + // Running from C should only include C + const dag = createDAG([ + createNode('A', [{ target: 'C' }]), + createNode('B', [{ target: 'C' }]), + createNode('C', [{ target: 'D' }]), + createNode('D'), + ]) + + const dirtySet = computeDirtySet(dag, 'C') + + expect(dirtySet.has('A')).toBe(false) + expect(dirtySet.has('B')).toBe(false) + expect(dirtySet.has('C')).toBe(true) + expect(dirtySet.has('D')).toBe(true) + expect(dirtySet.size).toBe(2) + }) + + it('handles deep downstream chains', () => { + // A → B → C → D → E → F + // Running from C should include C, D, E, F + const dag = createDAG([ + createNode('A', [{ target: 'B' }]), + createNode('B', [{ target: 'C' }]), + createNode('C', [{ target: 'D' }]), + createNode('D', [{ target: 'E' }]), + createNode('E', [{ target: 'F' }]), + createNode('F'), + ]) + + const dirtySet = computeDirtySet(dag, 'C') + + expect(dirtySet.has('A')).toBe(false) + expect(dirtySet.has('B')).toBe(false) + expect(dirtySet.has('C')).toBe(true) + expect(dirtySet.has('D')).toBe(true) + expect(dirtySet.has('E')).toBe(true) + expect(dirtySet.has('F')).toBe(true) + expect(dirtySet.size).toBe(4) + }) }) describe('validateRunFromBlock', () => { From be95a7dbd896a78a005b17e5a9e9ecc289f55fe8 Mon Sep 17 00:00:00 2001 From: Siddharth Ganesan Date: Tue, 27 Jan 2026 10:33:31 -0800 Subject: [PATCH 04/33] Fix --- .../[id]/execute-from-block/route.ts | 54 ++++++++- .../components/trace-spans/trace-spans.tsx | 9 +- .../components/action-bar/action-bar.tsx | 17 ++- .../components/block-menu/block-menu.tsx | 32 +++++ .../workflow-block/workflow-block.tsx | 4 +- .../[workspaceId]/w/[workflowId]/workflow.tsx | 56 +++++++-- apps/sim/executor/orchestrators/loop.ts | 13 ++- apps/sim/executor/orchestrators/parallel.ts | 16 ++- .../sim/executor/utils/run-from-block.test.ts | 109 ++++++++++++++++++ apps/sim/executor/utils/run-from-block.ts | 93 ++++++++++++--- apps/sim/stores/logs/filters/types.ts | 2 + 11 files changed, 365 insertions(+), 40 deletions(-) diff --git a/apps/sim/app/api/workflows/[id]/execute-from-block/route.ts b/apps/sim/app/api/workflows/[id]/execute-from-block/route.ts index 14cc81248b..5a2bb9f341 100644 --- a/apps/sim/app/api/workflows/[id]/execute-from-block/route.ts +++ b/apps/sim/app/api/workflows/[id]/execute-from-block/route.ts @@ -8,7 +8,9 @@ import { checkHybridAuth } from '@/lib/auth/hybrid' import { generateRequestId } from '@/lib/core/utils/request' import { SSE_HEADERS } from '@/lib/core/utils/sse' import { getPersonalAndWorkspaceEnv } from '@/lib/environment/utils' -import { markExecutionCancelled } from '@/lib/execution/cancellation' +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 { DAGExecutor } from '@/executor/execution/executor' @@ -93,7 +95,7 @@ export async function POST(req: NextRequest, { params }: { params: Promise<{ id: // Load workflow record to get workspaceId const [workflowRecord] = await db - .select({ workspaceId: workflowTable.workspaceId }) + .select({ workspaceId: workflowTable.workspaceId, userId: workflowTable.userId }) .from(workflowTable) .where(eq(workflowTable.id, workflowId)) .limit(1) @@ -103,6 +105,10 @@ export async function POST(req: NextRequest, { params }: { params: Promise<{ id: } const workspaceId = workflowRecord.workspaceId + const workflowUserId = workflowRecord.userId + + // Initialize logging session for cost tracking + const loggingSession = new LoggingSession(workflowId, executionId, 'manual', requestId) // Load workflow state const workflowData = await loadWorkflowFromNormalizedTables(workflowId) @@ -131,6 +137,13 @@ export async function POST(req: NextRequest, { params }: { params: Promise<{ id: true ) + // Start logging session + await loggingSession.safeStart({ + userId, + workspaceId, + variables: {}, + }) + const encoder = new TextEncoder() const abortController = new AbortController() let isStreamClosed = false @@ -191,6 +204,9 @@ export async function POST(req: NextRequest, { params }: { params: Promise<{ id: callbackData: { input?: unknown; output: NormalizedBlockOutput; executionTime: number }, iterationContext?: IterationContext ) => { + // Log to session for cost tracking + await loggingSession.onBlockComplete(blockId, blockName, blockType, callbackData) + const hasError = (callbackData.output as any)?.error if (hasError) { @@ -299,7 +315,18 @@ export async function POST(req: NextRequest, { params }: { params: Promise<{ id: sourceSnapshot as SerializableExecutionState ) + // Build trace spans from fresh execution logs only + // Trace spans show what actually executed in this run + const { traceSpans, totalDuration } = buildTraceSpans(result) + if (result.status === 'cancelled') { + await loggingSession.safeCompleteWithCancellation({ + endedAt: new Date().toISOString(), + totalDurationMs: totalDuration || 0, + traceSpans: traceSpans || [], + }) + await clearExecutionCancellation(executionId) + sendEvent({ type: 'execution:cancelled', timestamp: new Date().toISOString(), @@ -312,6 +339,16 @@ export async function POST(req: NextRequest, { params }: { params: Promise<{ id: return } + // Complete logging session + await loggingSession.safeComplete({ + endedAt: new Date().toISOString(), + totalDurationMs: totalDuration || 0, + finalOutput: result.output || {}, + traceSpans: traceSpans || [], + workflowInput: {}, + }) + await clearExecutionCancellation(executionId) + sendEvent({ type: 'execution:completed', timestamp: new Date().toISOString(), @@ -330,6 +367,19 @@ 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: [] } + + // Complete logging session with error + await loggingSession.safeCompleteWithError({ + endedAt: new Date().toISOString(), + totalDurationMs: executionResult?.metadata?.duration || 0, + error: { + message: errorMessage, + stackTrace: error instanceof Error ? error.stack : undefined, + }, + traceSpans, + }) + await clearExecutionCancellation(executionId) sendEvent({ type: 'execution:error', diff --git a/apps/sim/app/workspace/[workspaceId]/logs/components/log-details/components/trace-spans/trace-spans.tsx b/apps/sim/app/workspace/[workspaceId]/logs/components/log-details/components/trace-spans/trace-spans.tsx index dab65614c5..73998bb2fc 100644 --- a/apps/sim/app/workspace/[workspaceId]/logs/components/log-details/components/trace-spans/trace-spans.tsx +++ b/apps/sim/app/workspace/[workspaceId]/logs/components/log-details/components/trace-spans/trace-spans.tsx @@ -528,6 +528,7 @@ const TraceSpanNode = memo(function TraceSpanNode({ const isDirectError = span.status === 'error' const hasNestedError = hasErrorInTree(span) const showErrorStyle = isDirectError || hasNestedError + const isCached = span.cached === true const { icon: BlockIcon, bgColor } = getBlockIconAndColor(span.type, span.name) @@ -586,7 +587,7 @@ const TraceSpanNode = memo(function TraceSpanNode({ isIterationType(lowerType) || lowerType === 'workflow' || lowerType === 'workflow_input' return ( -
+
{/* Node Header Row */}
{!isIterationType(span.type) && (
{BlockIcon && } @@ -623,6 +627,7 @@ const TraceSpanNode = memo(function TraceSpanNode({ style={{ color: showErrorStyle ? 'var(--text-error)' : 'var(--text-secondary)' }} > {span.name} + {isCached && (cached)} {isToggleable && ( )} - {canRunFromBlock && ( + {!isNoteBlock && ( - {isExecuting ? 'Execution in progress' : getTooltipMessage('Run from this block')} + {(() => { + if (disabled) return getTooltipMessage('Run from this block') + if (isExecuting) return 'Execution in progress' + if (!hasExecutionSnapshot) return 'Run workflow first' + if (!wasExecuted) return 'Block not executed in last run' + if (isInsideSubflow) return 'Cannot run from inside subflow' + return 'Run from this block' + })()} )} diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/block-menu/block-menu.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/block-menu/block-menu.tsx index c3a4d2ea80..28edd6784f 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/block-menu/block-menu.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/block-menu/block-menu.tsx @@ -40,9 +40,15 @@ export interface BlockMenuProps { onRemoveFromSubflow: () => void onOpenEditor: () => void onRename: () => void + onRunFromBlock?: () => void hasClipboard?: boolean showRemoveFromSubflow?: boolean + /** Whether run from block is available (has snapshot, was executed, not inside subflow) */ + canRunFromBlock?: boolean + /** Reason why run from block is disabled (for tooltip) */ + runFromBlockDisabledReason?: string disableEdit?: boolean + isExecuting?: boolean } /** @@ -65,9 +71,13 @@ export function BlockMenu({ onRemoveFromSubflow, onOpenEditor, onRename, + onRunFromBlock, hasClipboard = false, showRemoveFromSubflow = false, + canRunFromBlock = false, + runFromBlockDisabledReason, disableEdit = false, + isExecuting = false, }: BlockMenuProps) { const isSingleBlock = selectedBlocks.length === 1 @@ -203,6 +213,28 @@ export function BlockMenu({ )} + {/* Run from block - only for single non-note block selection */} + {isSingleBlock && !allNoteBlocks && ( + <> + + { + if (canRunFromBlock && !isExecuting) { + onRunFromBlock?.() + onClose() + } + }} + > + {isExecuting + ? 'Execution in progress...' + : !canRunFromBlock && runFromBlockDisabledReason + ? runFromBlockDisabledReason + : 'Run from this block'} + + + )} + {/* Destructive action */} {isPending && (
diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/workflow.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/workflow.tsx index 030781c4e3..491995b0bb 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/workflow.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/workflow.tsx @@ -758,13 +758,16 @@ const WorkflowContent = React.memo(() => { [collaborativeBatchAddBlocks, setSelectedEdges, setPendingSelection] ) - const { activeBlockIds, pendingBlocks, isDebugging } = useExecutionStore( - useShallow((state) => ({ - activeBlockIds: state.activeBlockIds, - pendingBlocks: state.pendingBlocks, - isDebugging: state.isDebugging, - })) - ) + const { activeBlockIds, pendingBlocks, isDebugging, isExecuting, getLastExecutionSnapshot } = + useExecutionStore( + useShallow((state) => ({ + activeBlockIds: state.activeBlockIds, + pendingBlocks: state.pendingBlocks, + isDebugging: state.isDebugging, + isExecuting: state.isExecuting, + getLastExecutionSnapshot: state.getLastExecutionSnapshot, + })) + ) const [dragStartParentId, setDragStartParentId] = useState(null) @@ -988,6 +991,16 @@ const WorkflowContent = React.memo(() => { } }, [contextMenuBlocks]) + const handleContextRunFromBlock = useCallback(() => { + if (contextMenuBlocks.length !== 1) return + const blockId = contextMenuBlocks[0].id + window.dispatchEvent( + new CustomEvent('run-from-block', { + detail: { blockId, workflowId: workflowIdParam }, + }) + ) + }, [contextMenuBlocks, workflowIdParam]) + const handleContextAddBlock = useCallback(() => { useSearchModalStore.getState().open() }, []) @@ -3308,11 +3321,40 @@ const WorkflowContent = React.memo(() => { onRemoveFromSubflow={handleContextRemoveFromSubflow} onOpenEditor={handleContextOpenEditor} onRename={handleContextRename} + onRunFromBlock={handleContextRunFromBlock} hasClipboard={hasClipboard()} showRemoveFromSubflow={contextMenuBlocks.some( (b) => b.parentId && (b.parentType === 'loop' || b.parentType === 'parallel') )} + canRunFromBlock={ + contextMenuBlocks.length === 1 && + (() => { + const block = contextMenuBlocks[0] + const snapshot = getLastExecutionSnapshot(workflowIdParam) + const wasExecuted = snapshot?.executedBlocks.includes(block.id) ?? false + const isNoteBlock = block.type === 'note' + const isInsideSubflow = + block.parentId && (block.parentType === 'loop' || block.parentType === 'parallel') + return !!snapshot && wasExecuted && !isNoteBlock && !isInsideSubflow && !isExecuting + })() + } + runFromBlockDisabledReason={ + contextMenuBlocks.length === 1 + ? (() => { + const block = contextMenuBlocks[0] + const snapshot = getLastExecutionSnapshot(workflowIdParam) + const wasExecuted = snapshot?.executedBlocks.includes(block.id) ?? false + const isInsideSubflow = + block.parentId && (block.parentType === 'loop' || block.parentType === 'parallel') + if (!snapshot) return 'Run workflow first' + if (!wasExecuted) return 'Block not executed in last run' + if (isInsideSubflow) return 'Cannot run from inside subflow' + return undefined + })() + : undefined + } disableEdit={!effectivePermissions.canEdit} + isExecuting={isExecuting} /> { expect(result.valid).toBe(true) }) + + it('accepts loop container when executed', () => { + // Loop container with sentinel nodes + const loopId = 'loop-container-1' + const sentinelStartId = `loop-${loopId}-sentinel-start` + const sentinelEndId = `loop-${loopId}-sentinel-end` + const dag = createDAG([ + createNode('A', [{ target: sentinelStartId }]), + 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('C'), + ]) + dag.loopConfigs.set(loopId, { id: loopId, nodes: ['B'], iterations: 3, loopType: 'for' } as any) + const executedBlocks = new Set(['A', loopId, sentinelStartId, 'B', sentinelEndId, 'C']) + + const result = validateRunFromBlock(loopId, dag, executedBlocks) + + expect(result.valid).toBe(true) + }) + + it('accepts parallel container when executed', () => { + // Parallel container with sentinel nodes + const parallelId = 'parallel-container-1' + const sentinelStartId = `parallel-${parallelId}-sentinel-start` + const sentinelEndId = `parallel-${parallelId}-sentinel-end` + const dag = createDAG([ + createNode('A', [{ target: sentinelStartId }]), + 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('C'), + ]) + dag.parallelConfigs.set(parallelId, { id: parallelId, nodes: ['B'], count: 2 } as any) + const executedBlocks = new Set(['A', parallelId, sentinelStartId, 'B₍0₎', sentinelEndId, 'C']) + + const result = validateRunFromBlock(parallelId, dag, executedBlocks) + + expect(result.valid).toBe(true) + }) + + it('rejects loop container that was not executed', () => { + const loopId = 'loop-container-1' + const sentinelStartId = `loop-${loopId}-sentinel-start` + const dag = createDAG([ + createNode(sentinelStartId, [], { isSentinel: true, sentinelType: 'start', loopId }), + ]) + dag.loopConfigs.set(loopId, { id: loopId, nodes: [], iterations: 3, loopType: 'for' } as any) + const executedBlocks = new Set() // Loop was not executed + + const result = validateRunFromBlock(loopId, dag, executedBlocks) + + expect(result.valid).toBe(false) + expect(result.error).toContain('was not executed') + }) +}) + +describe('computeDirtySet with containers', () => { + it('includes loop container and all downstream when running from loop', () => { + // A → loop-sentinel-start → B (inside loop) → loop-sentinel-end → C + const loopId = 'loop-1' + const sentinelStartId = `loop-${loopId}-sentinel-start` + const sentinelEndId = `loop-${loopId}-sentinel-end` + const dag = createDAG([ + createNode('A', [{ target: sentinelStartId }]), + 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('C'), + ]) + dag.loopConfigs.set(loopId, { id: loopId, nodes: ['B'], iterations: 3, loopType: 'for' } as any) + + const dirtySet = computeDirtySet(dag, loopId) + + // Should include loop container, sentinel-start, B, sentinel-end, C + expect(dirtySet.has(loopId)).toBe(true) + expect(dirtySet.has(sentinelStartId)).toBe(true) + expect(dirtySet.has('B')).toBe(true) + expect(dirtySet.has(sentinelEndId)).toBe(true) + expect(dirtySet.has('C')).toBe(true) + // Should NOT include A (upstream) + expect(dirtySet.has('A')).toBe(false) + }) + + it('includes parallel container and all downstream when running from parallel', () => { + // A → parallel-sentinel-start → B₍0₎ → parallel-sentinel-end → C + const parallelId = 'parallel-1' + const sentinelStartId = `parallel-${parallelId}-sentinel-start` + const sentinelEndId = `parallel-${parallelId}-sentinel-end` + const dag = createDAG([ + createNode('A', [{ target: sentinelStartId }]), + 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('C'), + ]) + dag.parallelConfigs.set(parallelId, { id: parallelId, nodes: ['B'], count: 2 } as any) + + const dirtySet = computeDirtySet(dag, parallelId) + + // Should include parallel container, sentinel-start, B₍0₎, sentinel-end, C + expect(dirtySet.has(parallelId)).toBe(true) + expect(dirtySet.has(sentinelStartId)).toBe(true) + expect(dirtySet.has('B₍0₎')).toBe(true) + expect(dirtySet.has(sentinelEndId)).toBe(true) + expect(dirtySet.has('C')).toBe(true) + // Should NOT include A (upstream) + expect(dirtySet.has('A')).toBe(false) + }) }) diff --git a/apps/sim/executor/utils/run-from-block.ts b/apps/sim/executor/utils/run-from-block.ts index 57e1e81e82..94dccb056a 100644 --- a/apps/sim/executor/utils/run-from-block.ts +++ b/apps/sim/executor/utils/run-from-block.ts @@ -1,8 +1,37 @@ import { createLogger } from '@sim/logger' +import { LOOP, PARALLEL } from '@/executor/constants' import type { DAG } from '@/executor/dag/builder' const logger = createLogger('run-from-block') +/** + * Builds the sentinel-start node ID for a loop. + */ +function buildLoopSentinelStartId(loopId: string): string { + return `${LOOP.SENTINEL.PREFIX}${loopId}${LOOP.SENTINEL.START_SUFFIX}` +} + +/** + * Builds the sentinel-start node ID for a parallel. + */ +function buildParallelSentinelStartId(parallelId: string): string { + return `${PARALLEL.SENTINEL.PREFIX}${parallelId}${PARALLEL.SENTINEL.START_SUFFIX}` +} + +/** + * Checks if a block ID is a loop or parallel container and returns the sentinel-start ID if so. + * Returns null if the block is not a container. + */ +function resolveContainerToSentinelStart(blockId: string, dag: DAG): string | null { + if (dag.loopConfigs.has(blockId)) { + return buildLoopSentinelStartId(blockId) + } + if (dag.parallelConfigs.has(blockId)) { + return buildParallelSentinelStartId(blockId) + } + return null +} + /** * Result of validating a block for run-from-block execution. */ @@ -25,13 +54,25 @@ export interface RunFromBlockContext { * Computes all blocks that need re-execution when running from a specific block. * Uses BFS to find all downstream blocks reachable via outgoing edges. * + * For loop/parallel containers, starts from the sentinel-start node and includes + * the container ID itself in the dirty set. + * * @param dag - The workflow DAG * @param startBlockId - The block to start execution from * @returns Set of block IDs that are "dirty" and need re-execution */ export function computeDirtySet(dag: DAG, startBlockId: string): Set { const dirty = new Set([startBlockId]) - const queue = [startBlockId] + + // For loop/parallel containers, resolve to sentinel-start for BFS traversal + const sentinelStartId = resolveContainerToSentinelStart(startBlockId, dag) + const traversalStartId = sentinelStartId ?? startBlockId + + if (sentinelStartId) { + dirty.add(sentinelStartId) + } + + const queue = [traversalStartId] while (queue.length > 0) { const nodeId = queue.shift()! @@ -48,6 +89,7 @@ export function computeDirtySet(dag: DAG, startBlockId: string): Set { logger.debug('Computed dirty set', { startBlockId, + traversalStartId, dirtySetSize: dirty.size, dirtyBlocks: Array.from(dirty), }) @@ -59,9 +101,9 @@ export function computeDirtySet(dag: DAG, startBlockId: string): Set { * Validates that a block can be used as a run-from-block starting point. * * Validation rules: - * - Block must exist in the DAG - * - Block cannot be inside a loop - * - Block cannot be inside a parallel + * - Block must exist in the DAG (or be a loop/parallel container) + * - Block cannot be inside a loop (but loop containers are allowed) + * - Block cannot be inside a parallel (but parallel containers are allowed) * - Block cannot be a sentinel node * - Block must have been executed in the source run * @@ -77,26 +119,45 @@ export function validateRunFromBlock( ): RunFromBlockValidation { const node = dag.nodes.get(blockId) - if (!node) { + // Check if this is a loop or parallel container (not in dag.nodes but in configs) + const isLoopContainer = dag.loopConfigs.has(blockId) + const isParallelContainer = dag.parallelConfigs.has(blockId) + const isContainer = isLoopContainer || isParallelContainer + + if (!node && !isContainer) { return { valid: false, error: `Block not found in workflow: ${blockId}` } } - if (node.metadata.isLoopNode) { - return { - valid: false, - error: `Cannot run from block inside loop: ${node.metadata.loopId}`, + // For containers, verify the sentinel-start exists + if (isContainer) { + const sentinelStartId = resolveContainerToSentinelStart(blockId, dag) + if (!sentinelStartId || !dag.nodes.has(sentinelStartId)) { + return { + valid: false, + error: `Container sentinel not found for: ${blockId}`, + } } } - if (node.metadata.isParallelBranch) { - return { - valid: false, - error: `Cannot run from block inside parallel: ${node.metadata.parallelId}`, + // For regular nodes, check if inside loop/parallel + if (node) { + if (node.metadata.isLoopNode) { + return { + valid: false, + error: `Cannot run from block inside loop: ${node.metadata.loopId}`, + } } - } - if (node.metadata.isSentinel) { - return { valid: false, error: 'Cannot run from sentinel node' } + if (node.metadata.isParallelBranch) { + return { + valid: false, + error: `Cannot run from block inside parallel: ${node.metadata.parallelId}`, + } + } + + if (node.metadata.isSentinel) { + return { valid: false, error: 'Cannot run from sentinel node' } + } } if (!executedBlocks.has(blockId)) { diff --git a/apps/sim/stores/logs/filters/types.ts b/apps/sim/stores/logs/filters/types.ts index f533b69961..de6ec7b892 100644 --- a/apps/sim/stores/logs/filters/types.ts +++ b/apps/sim/stores/logs/filters/types.ts @@ -98,6 +98,8 @@ export interface TraceSpan { total?: number } providerTiming?: ProviderTiming + /** Whether this span represents a cached (not re-executed) block in run-from-block mode */ + cached?: boolean } export interface WorkflowLog { From 72594df7666680157515d90fce885548b88651c6 Mon Sep 17 00:00:00 2001 From: Siddharth Ganesan Date: Tue, 27 Jan 2026 11:03:13 -0800 Subject: [PATCH 05/33] Minor improvements --- apps/sim/app/api/copilot/user-models/route.ts | 1 - .../log-details/components/trace-spans/trace-spans.tsx | 9 ++------- .../copilot/components/user-input/constants.ts | 1 - .../components/workflow-block/workflow-block.tsx | 4 +++- apps/sim/executor/execution/executor.ts | 8 +++++++- apps/sim/executor/utils/run-from-block.ts | 2 +- apps/sim/lib/copilot/models.ts | 1 - apps/sim/stores/logs/filters/types.ts | 2 -- 8 files changed, 13 insertions(+), 15 deletions(-) diff --git a/apps/sim/app/api/copilot/user-models/route.ts b/apps/sim/app/api/copilot/user-models/route.ts index b88e12b8a4..ead14a5e9d 100644 --- a/apps/sim/app/api/copilot/user-models/route.ts +++ b/apps/sim/app/api/copilot/user-models/route.ts @@ -31,7 +31,6 @@ const DEFAULT_ENABLED_MODELS: Record = { 'claude-4.5-opus': true, 'claude-4.1-opus': false, 'gemini-3-pro': true, - 'auto': true, } // GET - Fetch user's enabled models diff --git a/apps/sim/app/workspace/[workspaceId]/logs/components/log-details/components/trace-spans/trace-spans.tsx b/apps/sim/app/workspace/[workspaceId]/logs/components/log-details/components/trace-spans/trace-spans.tsx index 73998bb2fc..dab65614c5 100644 --- a/apps/sim/app/workspace/[workspaceId]/logs/components/log-details/components/trace-spans/trace-spans.tsx +++ b/apps/sim/app/workspace/[workspaceId]/logs/components/log-details/components/trace-spans/trace-spans.tsx @@ -528,7 +528,6 @@ const TraceSpanNode = memo(function TraceSpanNode({ const isDirectError = span.status === 'error' const hasNestedError = hasErrorInTree(span) const showErrorStyle = isDirectError || hasNestedError - const isCached = span.cached === true const { icon: BlockIcon, bgColor } = getBlockIconAndColor(span.type, span.name) @@ -587,7 +586,7 @@ const TraceSpanNode = memo(function TraceSpanNode({ isIterationType(lowerType) || lowerType === 'workflow' || lowerType === 'workflow_input' return ( -
+
{/* Node Header Row */}
{!isIterationType(span.type) && (
{BlockIcon && } @@ -627,7 +623,6 @@ const TraceSpanNode = memo(function TraceSpanNode({ style={{ color: showErrorStyle ? 'var(--text-error)' : 'var(--text-secondary)' }} > {span.name} - {isCached && (cached)} {isToggleable && ( {isPending && (
diff --git a/apps/sim/executor/execution/executor.ts b/apps/sim/executor/execution/executor.ts index 021fda0227..cc2dcba4f4 100644 --- a/apps/sim/executor/execution/executor.ts +++ b/apps/sim/executor/execution/executor.ts @@ -17,6 +17,7 @@ import { ParallelOrchestrator } from '@/executor/orchestrators/parallel' import type { BlockState, ExecutionContext, ExecutionResult } from '@/executor/types' import { computeDirtySet, + resolveContainerToSentinelStart, type RunFromBlockContext, validateRunFromBlock, } from '@/executor/utils/run-from-block' @@ -130,9 +131,14 @@ export class DAGExecutor { // Compute dirty set (blocks that will be re-executed) const dirtySet = computeDirtySet(dag, startBlockId) + // Resolve container IDs to sentinel IDs for execution + // The engine needs to start from the sentinel node, not the container ID + const effectiveStartBlockId = resolveContainerToSentinelStart(startBlockId, dag) ?? startBlockId + logger.info('Executing from block', { workflowId, startBlockId, + effectiveStartBlockId, dirtySetSize: dirtySet.size, totalBlocks: dag.nodes.size, dirtyBlocks: Array.from(dirtySet), @@ -162,7 +168,7 @@ export class DAGExecutor { } // Create context with snapshot state + runFromBlockContext - const runFromBlockContext = { startBlockId, dirtySet } + const runFromBlockContext = { startBlockId: effectiveStartBlockId, dirtySet } const { context, state } = this.createExecutionContext(workflowId, undefined, { snapshotState: sourceSnapshot, runFromBlockContext, diff --git a/apps/sim/executor/utils/run-from-block.ts b/apps/sim/executor/utils/run-from-block.ts index 94dccb056a..0b364063ca 100644 --- a/apps/sim/executor/utils/run-from-block.ts +++ b/apps/sim/executor/utils/run-from-block.ts @@ -22,7 +22,7 @@ function buildParallelSentinelStartId(parallelId: string): string { * Checks if a block ID is a loop or parallel container and returns the sentinel-start ID if so. * Returns null if the block is not a container. */ -function resolveContainerToSentinelStart(blockId: string, dag: DAG): string | null { +export function resolveContainerToSentinelStart(blockId: string, dag: DAG): string | null { if (dag.loopConfigs.has(blockId)) { return buildLoopSentinelStartId(blockId) } diff --git a/apps/sim/lib/copilot/models.ts b/apps/sim/lib/copilot/models.ts index 3dec2ef884..83a90169be 100644 --- a/apps/sim/lib/copilot/models.ts +++ b/apps/sim/lib/copilot/models.ts @@ -21,7 +21,6 @@ export const COPILOT_MODEL_IDS = [ 'claude-4.5-opus', 'claude-4.1-opus', 'gemini-3-pro', - 'auto', ] as const export type CopilotModelId = (typeof COPILOT_MODEL_IDS)[number] diff --git a/apps/sim/stores/logs/filters/types.ts b/apps/sim/stores/logs/filters/types.ts index de6ec7b892..f533b69961 100644 --- a/apps/sim/stores/logs/filters/types.ts +++ b/apps/sim/stores/logs/filters/types.ts @@ -98,8 +98,6 @@ export interface TraceSpan { total?: number } providerTiming?: ProviderTiming - /** Whether this span represents a cached (not re-executed) block in run-from-block mode */ - cached?: boolean } export interface WorkflowLog { From 5c1e6208315d5b00adabb24a0e8b4f17363b8096 Mon Sep 17 00:00:00 2001 From: Siddharth Ganesan Date: Tue, 27 Jan 2026 11:03:34 -0800 Subject: [PATCH 06/33] Fix --- apps/sim/tools/rds/execute.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/sim/tools/rds/execute.ts b/apps/sim/tools/rds/execute.ts index b297a48385..9a26ed3feb 100644 --- a/apps/sim/tools/rds/execute.ts +++ b/apps/sim/tools/rds/execute.ts @@ -1,4 +1,4 @@ -import type { RdsExecuteParams, RdsExecuteResponse } from '@/tools/rds/types' + import type { RdsExecuteParams, RdsExecuteResponse } from '@/tools/rds/types' import type { ToolConfig } from '@/tools/types' export const executeTool: ToolConfig = { From d38fb29e054d87c41e9c9dffa50f27dbaf370be2 Mon Sep 17 00:00:00 2001 From: Siddharth Ganesan Date: Tue, 27 Jan 2026 11:21:42 -0800 Subject: [PATCH 07/33] Fix trace spans --- apps/sim/executor/execution/executor.ts | 4 +++- apps/sim/tools/rds/execute.ts | 2 +- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/apps/sim/executor/execution/executor.ts b/apps/sim/executor/execution/executor.ts index cc2dcba4f4..dd303217eb 100644 --- a/apps/sim/executor/execution/executor.ts +++ b/apps/sim/executor/execution/executor.ts @@ -233,7 +233,9 @@ export class DAGExecutor { userId: this.contextExtensions.userId, isDeployedContext: this.contextExtensions.isDeployedContext, blockStates: state.getBlockStates(), - blockLogs: snapshotState?.blockLogs ?? [], + // 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, startTime: new Date().toISOString(), diff --git a/apps/sim/tools/rds/execute.ts b/apps/sim/tools/rds/execute.ts index 9a26ed3feb..b297a48385 100644 --- a/apps/sim/tools/rds/execute.ts +++ b/apps/sim/tools/rds/execute.ts @@ -1,4 +1,4 @@ - import type { RdsExecuteParams, RdsExecuteResponse } from '@/tools/rds/types' +import type { RdsExecuteParams, RdsExecuteResponse } from '@/tools/rds/types' import type { ToolConfig } from '@/tools/types' export const executeTool: ToolConfig = { From 3231955a072c872b8ae0d73358fb323300b398ed Mon Sep 17 00:00:00 2001 From: Siddharth Ganesan Date: Tue, 27 Jan 2026 11:36:09 -0800 Subject: [PATCH 08/33] Fix loop l ogs --- .../w/[workflowId]/hooks/use-workflow-execution.ts | 9 +++++++++ 1 file changed, 9 insertions(+) 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 393bd9a00c..7c05cb040a 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 @@ -938,6 +938,11 @@ 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() @@ -1485,6 +1490,10 @@ export function useWorkflowExecution() { executionTime: data.durationMs, }) + // Skip adding loop/parallel containers to console and logs + const isContainerBlock = data.blockType === 'loop' || data.blockType === 'parallel' + if (isContainerBlock) return + const startedAt = new Date(Date.now() - data.durationMs).toISOString() const endedAt = new Date().toISOString() From 6e541949ecf8c72ea4f545a9301896d1e2b487c0 Mon Sep 17 00:00:00 2001 From: Siddharth Ganesan Date: Tue, 27 Jan 2026 11:37:36 -0800 Subject: [PATCH 09/33] Change ordering --- .../components/action-bar/action-bar.tsx | 34 +++++++++---------- 1 file changed, 17 insertions(+), 17 deletions(-) diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/action-bar/action-bar.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/action-bar/action-bar.tsx index 62184940f7..162c4f24ff 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/action-bar/action-bar.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/action-bar/action-bar.tsx @@ -164,18 +164,25 @@ export const ActionBar = memo( variant='ghost' onClick={(e) => { e.stopPropagation() - if (!disabled) { - collaborativeBatchToggleBlockEnabled([blockId]) + if (canRunFromBlock && !disabled) { + handleRunFromBlock() } }} className={ACTION_BUTTON_STYLES} - disabled={disabled} + disabled={disabled || !canRunFromBlock} > - {isEnabled ? : } + - {getTooltipMessage(isEnabled ? 'Disable Block' : 'Enable Block')} + {(() => { + if (disabled) return getTooltipMessage('Run from this block') + if (isExecuting) return 'Execution in progress' + if (!hasExecutionSnapshot) return 'Run workflow first' + if (!wasExecuted) return 'Block not executed in last run' + if (isInsideSubflow) return 'Cannot run from inside subflow' + return 'Run from this block' + })()} )} @@ -187,25 +194,18 @@ export const ActionBar = memo( variant='ghost' onClick={(e) => { e.stopPropagation() - if (canRunFromBlock && !disabled) { - handleRunFromBlock() + if (!disabled) { + collaborativeBatchToggleBlockEnabled([blockId]) } }} className={ACTION_BUTTON_STYLES} - disabled={disabled || !canRunFromBlock} + disabled={disabled} > - + {isEnabled ? : } - {(() => { - if (disabled) return getTooltipMessage('Run from this block') - if (isExecuting) return 'Execution in progress' - if (!hasExecutionSnapshot) return 'Run workflow first' - if (!wasExecuted) return 'Block not executed in last run' - if (isInsideSubflow) return 'Cannot run from inside subflow' - return 'Run from this block' - })()} + {getTooltipMessage(isEnabled ? 'Disable Block' : 'Enable Block')} )} From 23ab11a40dfc1ff5dff531c0e98250cdb107c087 Mon Sep 17 00:00:00 2001 From: Siddharth Ganesan Date: Tue, 27 Jan 2026 12:13:09 -0800 Subject: [PATCH 10/33] Run u ntil block --- .../app/api/workflows/[id]/execute/route.ts | 5 + .../components/action-bar/action-bar.tsx | 39 ++--- .../components/block-menu/block-menu.tsx | 15 +- .../hooks/use-workflow-execution.ts | 134 ++++++++++++------ .../[workspaceId]/w/[workflowId]/workflow.tsx | 37 +++-- apps/sim/executor/execution/engine.ts | 10 +- apps/sim/executor/execution/executor.ts | 1 + apps/sim/executor/execution/types.ts | 5 + apps/sim/executor/types.ts | 5 + .../sim/executor/utils/run-from-block.test.ts | 30 ++-- apps/sim/executor/utils/run-from-block.ts | 18 ++- apps/sim/hooks/use-execution-stream.ts | 1 + .../lib/workflows/executor/execution-core.ts | 3 + 13 files changed, 215 insertions(+), 88 deletions(-) diff --git a/apps/sim/app/api/workflows/[id]/execute/route.ts b/apps/sim/app/api/workflows/[id]/execute/route.ts index 856a1a3c94..47f81ef122 100644 --- a/apps/sim/app/api/workflows/[id]/execute/route.ts +++ b/apps/sim/app/api/workflows/[id]/execute/route.ts @@ -53,6 +53,7 @@ const ExecuteWorkflowSchema = z.object({ parallels: z.record(z.any()).optional(), }) .optional(), + stopAfterBlockId: z.string().optional(), }) export const runtime = 'nodejs' @@ -222,6 +223,7 @@ export async function POST(req: NextRequest, { params }: { params: Promise<{ id: includeFileBase64, base64MaxBytes, workflowStateOverride, + stopAfterBlockId, } = validation.data // For API key and internal JWT auth, the entire body is the input (except for our control fields) @@ -237,6 +239,7 @@ export async function POST(req: NextRequest, { params }: { params: Promise<{ id: includeFileBase64, base64MaxBytes, workflowStateOverride, + stopAfterBlockId: _stopAfterBlockId, workflowId: _workflowId, // Also exclude workflowId used for internal JWT auth ...rest } = body @@ -434,6 +437,7 @@ export async function POST(req: NextRequest, { params }: { params: Promise<{ id: loggingSession, includeFileBase64, base64MaxBytes, + stopAfterBlockId, }) const outputWithBase64 = includeFileBase64 @@ -722,6 +726,7 @@ export async function POST(req: NextRequest, { params }: { params: Promise<{ id: abortSignal: abortController.signal, includeFileBase64, base64MaxBytes, + stopAfterBlockId, }) if (result.status === 'paused') { diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/action-bar/action-bar.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/action-bar/action-bar.tsx index 162c4f24ff..33edef0ecd 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/action-bar/action-bar.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/action-bar/action-bar.tsx @@ -4,6 +4,7 @@ import { Button, Copy, Tooltip, Trash2 } from '@/components/emcn' import { cn } from '@/lib/core/utils/cn' import { isInputDefinitionTrigger } from '@/lib/workflows/triggers/input-definition-triggers' import { useUserPermissionsContext } from '@/app/workspace/[workspaceId]/providers/workspace-permissions-provider' +import { useWorkflowExecution } from '@/app/workspace/[workspaceId]/w/[workflowId]/hooks' import { validateTriggerPaste } from '@/app/workspace/[workspaceId]/w/[workflowId]/utils' import { useCollaborativeWorkflow } from '@/hooks/use-collaborative-workflow' import { useExecutionStore } from '@/stores/execution' @@ -50,6 +51,7 @@ export const ActionBar = memo( collaborativeBatchToggleBlockHandles, } = useCollaborativeWorkflow() const { setPendingSelection } = useWorkflowRegistry() + const { handleRunFromBlock } = useWorkflowExecution() const addNotification = useNotificationStore((s) => s.addNotification) @@ -101,6 +103,7 @@ export const ActionBar = memo( const { activeWorkflowId } = useWorkflowRegistry() const { isExecuting, getLastExecutionSnapshot } = useExecutionStore() const userPermissions = useUserPermissionsContext() + const edges = useWorkflowStore((state) => state.edges) const isStartBlock = isInputDefinitionTrigger(blockType) const isResponseBlock = blockType === 'response' @@ -109,29 +112,29 @@ export const ActionBar = memo( const isInsideSubflow = parentId && (parentType === 'loop' || parentType === 'parallel') // Check if run-from-block is available - const hasExecutionSnapshot = activeWorkflowId - ? !!getLastExecutionSnapshot(activeWorkflowId) - : false - const wasExecuted = activeWorkflowId - ? getLastExecutionSnapshot(activeWorkflowId)?.executedBlocks.includes(blockId) ?? false - : false + // 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 = hasExecutionSnapshot && - wasExecuted && + dependenciesSatisfied && !isNoteBlock && !isInsideSubflow && !isExecuting - const handleRunFromBlock = useCallback(() => { + const handleRunFromBlockClick = useCallback(() => { if (!activeWorkflowId || !canRunFromBlock) return - - // Dispatch a custom event to trigger run-from-block execution - window.dispatchEvent( - new CustomEvent('run-from-block', { - detail: { blockId, workflowId: activeWorkflowId }, - }) - ) - }, [blockId, activeWorkflowId, canRunFromBlock]) + handleRunFromBlock(blockId, activeWorkflowId) + }, [blockId, activeWorkflowId, canRunFromBlock, handleRunFromBlock]) /** * Get appropriate tooltip message based on disabled state @@ -165,7 +168,7 @@ export const ActionBar = memo( onClick={(e) => { e.stopPropagation() if (canRunFromBlock && !disabled) { - handleRunFromBlock() + handleRunFromBlockClick() } }} className={ACTION_BUTTON_STYLES} @@ -179,7 +182,7 @@ export const ActionBar = memo( if (disabled) return getTooltipMessage('Run from this block') if (isExecuting) return 'Execution in progress' if (!hasExecutionSnapshot) return 'Run workflow first' - if (!wasExecuted) return 'Block not executed in last run' + if (!dependenciesSatisfied) return 'Run upstream blocks first' if (isInsideSubflow) return 'Cannot run from inside subflow' return 'Run from this block' })()} diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/block-menu/block-menu.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/block-menu/block-menu.tsx index 28edd6784f..8e1290ab6d 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/block-menu/block-menu.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/block-menu/block-menu.tsx @@ -41,6 +41,7 @@ export interface BlockMenuProps { onOpenEditor: () => void onRename: () => void onRunFromBlock?: () => void + onRunUntilBlock?: () => void hasClipboard?: boolean showRemoveFromSubflow?: boolean /** Whether run from block is available (has snapshot, was executed, not inside subflow) */ @@ -72,6 +73,7 @@ export function BlockMenu({ onOpenEditor, onRename, onRunFromBlock, + onRunUntilBlock, hasClipboard = false, showRemoveFromSubflow = false, canRunFromBlock = false, @@ -213,7 +215,7 @@ export function BlockMenu({ )} - {/* Run from block - only for single non-note block selection */} + {/* Run from/until block - only for single non-note block selection */} {isSingleBlock && !allNoteBlocks && ( <> @@ -232,6 +234,17 @@ export function BlockMenu({ ? runFromBlockDisabledReason : 'Run from this block'} + { + if (!isExecuting) { + onRunUntilBlock?.() + onClose() + } + }} + > + {isExecuting ? 'Execution in progress...' : 'Run until this block'} + )} 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 7c05cb040a..f2907a19b6 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 @@ -33,8 +33,6 @@ import { useWorkflowStore } from '@/stores/workflows/workflow/store' const logger = createLogger('useWorkflowExecution') -// Module-level guard to prevent duplicate run-from-block executions across hook instances -let runFromBlockGlobalLock = false // Debug state validation result interface DebugValidationResult { @@ -674,7 +672,8 @@ export function useWorkflowExecution() { onStream?: (se: StreamingExecution) => Promise, executionId?: string, onBlockComplete?: (blockId: string, output: any) => Promise, - overrideTriggerType?: 'chat' | 'manual' | 'api' + overrideTriggerType?: 'chat' | 'manual' | 'api', + stopAfterBlockId?: string ): Promise => { // Use diff workflow for execution when available, regardless of canvas view state const executionWorkflowState = null as { @@ -895,6 +894,7 @@ export function useWorkflowExecution() { triggerType: overrideTriggerType || 'manual', useDraftState: true, isClientSession: true, + stopAfterBlockId, workflowStateOverride: executionWorkflowState ? { blocks: executionWorkflowState.blocks, @@ -1080,19 +1080,47 @@ export function useWorkflowExecution() { // Store execution snapshot for run-from-block if (data.success && activeWorkflowId) { - const snapshot: SerializableExecutionState = { - blockStates: Object.fromEntries(accumulatedBlockStates), - executedBlocks: Array.from(executedBlockIds), - blockLogs: accumulatedBlockLogs, - decisions: { router: {}, condition: {} }, - completedLoops: [], - activeExecutionPath: Array.from(executedBlockIds), + if (stopAfterBlockId) { + // Partial run (run-until-block): merge with existing snapshot + const existingSnapshot = getLastExecutionSnapshot(activeWorkflowId) + const mergedBlockStates = { + ...(existingSnapshot?.blockStates || {}), + ...Object.fromEntries(accumulatedBlockStates), + } + const mergedExecutedBlocks = new Set([ + ...(existingSnapshot?.executedBlocks || []), + ...executedBlockIds, + ]) + const snapshot: SerializableExecutionState = { + blockStates: mergedBlockStates, + executedBlocks: Array.from(mergedExecutedBlocks), + blockLogs: [...(existingSnapshot?.blockLogs || []), ...accumulatedBlockLogs], + decisions: existingSnapshot?.decisions || { router: {}, condition: {} }, + completedLoops: existingSnapshot?.completedLoops || [], + activeExecutionPath: Array.from(mergedExecutedBlocks), + } + setLastExecutionSnapshot(activeWorkflowId, snapshot) + logger.info('Merged execution snapshot after run-until-block', { + workflowId: activeWorkflowId, + newBlocksExecuted: executedBlockIds.size, + totalExecutedBlocks: mergedExecutedBlocks.size, + }) + } else { + // Full run: replace snapshot entirely + const snapshot: SerializableExecutionState = { + blockStates: Object.fromEntries(accumulatedBlockStates), + executedBlocks: Array.from(executedBlockIds), + blockLogs: accumulatedBlockLogs, + decisions: { router: {}, condition: {} }, + completedLoops: [], + activeExecutionPath: Array.from(executedBlockIds), + } + setLastExecutionSnapshot(activeWorkflowId, snapshot) + logger.info('Stored execution snapshot for run-from-block', { + workflowId: activeWorkflowId, + executedBlocksCount: executedBlockIds.size, + }) } - setLastExecutionSnapshot(activeWorkflowId, snapshot) - logger.info('Stored execution snapshot for run-from-block', { - workflowId: activeWorkflowId, - executedBlocksCount: executedBlockIds.size, - }) } }, @@ -1419,26 +1447,21 @@ export function useWorkflowExecution() { */ const handleRunFromBlock = useCallback( async (blockId: string, workflowId: string) => { - // Prevent duplicate executions across multiple hook instances (panel.tsx and chat.tsx) - if (runFromBlockGlobalLock) { - logger.debug('Run-from-block already in progress (global lock), ignoring duplicate request', { - workflowId, - blockId, - }) - return - } - runFromBlockGlobalLock = true - const snapshot = getLastExecutionSnapshot(workflowId) if (!snapshot) { logger.error('No execution snapshot available for run-from-block', { workflowId, blockId }) - runFromBlockGlobalLock = false return } - if (!snapshot.executedBlocks.includes(blockId)) { - logger.error('Block was not executed in the source run', { workflowId, blockId }) - runFromBlockGlobalLock = false + // Check if all upstream dependencies have cached outputs + const workflowEdges = useWorkflowStore.getState().edges + const incomingEdges = workflowEdges.filter((edge) => edge.target === blockId) + const dependenciesSatisfied = + incomingEdges.length === 0 || + incomingEdges.every((edge) => snapshot.executedBlocks.includes(edge.source)) + + if (!dependenciesSatisfied) { + logger.error('Upstream dependencies not satisfied for run-from-block', { workflowId, blockId }) return } @@ -1449,8 +1472,6 @@ export function useWorkflowExecution() { }) setIsExecuting(true) - - const workflowEdges = useWorkflowStore.getState().edges const executionId = uuidv4() const accumulatedBlockLogs: BlockLog[] = [] const accumulatedBlockStates = new Map() @@ -1612,7 +1633,6 @@ export function useWorkflowExecution() { } finally { setIsExecuting(false) setActiveBlocks(new Set()) - runFromBlockGlobalLock = false } }, [ @@ -1627,18 +1647,45 @@ export function useWorkflowExecution() { ] ) - // Listen for run-from-block events from the action bar - useEffect(() => { - const handleRunFromBlockEvent = (event: CustomEvent<{ blockId: string; workflowId: string }>) => { - const { blockId, workflowId } = event.detail - handleRunFromBlock(blockId, workflowId) - } + /** + * Handles running workflow until a specific block (stops after that block completes) + */ + const handleRunUntilBlock = useCallback( + async (blockId: string, workflowId: string) => { + if (!workflowId || workflowId !== activeWorkflowId) { + logger.error('Invalid workflow ID for run-until-block', { workflowId, activeWorkflowId }) + return + } - window.addEventListener('run-from-block', handleRunFromBlockEvent as EventListener) - return () => { - window.removeEventListener('run-from-block', handleRunFromBlockEvent as EventListener) - } - }, [handleRunFromBlock]) + logger.info('Starting run-until-block execution', { workflowId, stopAfterBlockId: blockId }) + + setExecutionResult(null) + setIsExecuting(true) + + const executionId = uuidv4() + try { + const result = await executeWorkflow( + undefined, + undefined, + executionId, + undefined, + 'manual', + blockId + ) + if (result && 'success' in result) { + setExecutionResult(result) + } + } catch (error) { + const errorResult = handleExecutionError(error, { executionId }) + return errorResult + } finally { + setIsExecuting(false) + setIsDebugging(false) + setActiveBlocks(new Set()) + } + }, + [activeWorkflowId, setExecutionResult, setIsExecuting, setIsDebugging, setActiveBlocks] + ) return { isExecuting, @@ -1651,5 +1698,6 @@ export function useWorkflowExecution() { handleCancelDebug, handleCancelExecution, handleRunFromBlock, + handleRunUntilBlock, } } diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/workflow.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/workflow.tsx index 491995b0bb..0d13f7d636 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/workflow.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/workflow.tsx @@ -47,6 +47,7 @@ import { useCurrentWorkflow, useNodeUtilities, useShiftSelectionLock, + useWorkflowExecution, } from '@/app/workspace/[workspaceId]/w/[workflowId]/hooks' import { calculateContainerDimensions, @@ -325,6 +326,8 @@ const WorkflowContent = React.memo(() => { const showTrainingModal = useCopilotTrainingStore((state) => state.showModal) + const { handleRunFromBlock, handleRunUntilBlock } = useWorkflowExecution() + const snapToGridSize = useSnapToGridSize() const snapToGrid = snapToGridSize > 0 @@ -994,12 +997,14 @@ const WorkflowContent = React.memo(() => { const handleContextRunFromBlock = useCallback(() => { if (contextMenuBlocks.length !== 1) return const blockId = contextMenuBlocks[0].id - window.dispatchEvent( - new CustomEvent('run-from-block', { - detail: { blockId, workflowId: workflowIdParam }, - }) - ) - }, [contextMenuBlocks, workflowIdParam]) + handleRunFromBlock(blockId, workflowIdParam) + }, [contextMenuBlocks, workflowIdParam, handleRunFromBlock]) + + const handleContextRunUntilBlock = useCallback(() => { + if (contextMenuBlocks.length !== 1) return + const blockId = contextMenuBlocks[0].id + handleRunUntilBlock(blockId, workflowIdParam) + }, [contextMenuBlocks, workflowIdParam, handleRunUntilBlock]) const handleContextAddBlock = useCallback(() => { useSearchModalStore.getState().open() @@ -3322,6 +3327,7 @@ const WorkflowContent = React.memo(() => { onOpenEditor={handleContextOpenEditor} onRename={handleContextRename} onRunFromBlock={handleContextRunFromBlock} + onRunUntilBlock={handleContextRunUntilBlock} hasClipboard={hasClipboard()} showRemoveFromSubflow={contextMenuBlocks.some( (b) => b.parentId && (b.parentType === 'loop' || b.parentType === 'parallel') @@ -3331,11 +3337,16 @@ const WorkflowContent = React.memo(() => { (() => { const block = contextMenuBlocks[0] const snapshot = getLastExecutionSnapshot(workflowIdParam) - const wasExecuted = snapshot?.executedBlocks.includes(block.id) ?? false + 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 !!snapshot && wasExecuted && !isNoteBlock && !isInsideSubflow && !isExecuting + return dependenciesSatisfied && !isNoteBlock && !isInsideSubflow && !isExecuting })() } runFromBlockDisabledReason={ @@ -3343,11 +3354,15 @@ const WorkflowContent = React.memo(() => { ? (() => { const block = contextMenuBlocks[0] const snapshot = getLastExecutionSnapshot(workflowIdParam) - const wasExecuted = snapshot?.executedBlocks.includes(block.id) ?? false + 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 (!snapshot) return 'Run workflow first' - if (!wasExecuted) return 'Block not executed in last run' + if (!dependenciesSatisfied) return 'Run upstream blocks first' if (isInsideSubflow) return 'Cannot run from inside subflow' return undefined })() diff --git a/apps/sim/executor/execution/engine.ts b/apps/sim/executor/execution/engine.ts index 9cea322188..b519fceefc 100644 --- a/apps/sim/executor/execution/engine.ts +++ b/apps/sim/executor/execution/engine.ts @@ -26,6 +26,7 @@ export class ExecutionEngine { private allowResumeTriggers: boolean private cancelledFlag = false private errorFlag = false + private stoppedEarlyFlag = false private executionError: Error | null = null private lastCancellationCheck = 0 private readonly useRedisCancellation: boolean @@ -105,7 +106,7 @@ export class ExecutionEngine { this.initializeQueue(triggerBlockId) while (this.hasWork()) { - if ((await this.checkCancellation()) || this.errorFlag) { + if ((await this.checkCancellation()) || this.errorFlag || this.stoppedEarlyFlag) { break } await this.processQueue() @@ -396,6 +397,13 @@ export class ExecutionEngine { this.finalOutput = output } + // Check if we should stop after this block (run-until-block feature) + if (this.context.stopAfterBlockId === nodeId) { + logger.info('Stopping execution after target block', { nodeId }) + this.stoppedEarlyFlag = true + return + } + const readyNodes = this.edgeManager.processOutgoingEdges(node, output, false) logger.info('Processing outgoing edges', { diff --git a/apps/sim/executor/execution/executor.ts b/apps/sim/executor/execution/executor.ts index dd303217eb..4112eb6e60 100644 --- a/apps/sim/executor/execution/executor.ts +++ b/apps/sim/executor/execution/executor.ts @@ -296,6 +296,7 @@ export class DAGExecutor { includeFileBase64: this.contextExtensions.includeFileBase64, base64MaxBytes: this.contextExtensions.base64MaxBytes, runFromBlockContext: overrides?.runFromBlockContext, + stopAfterBlockId: this.contextExtensions.stopAfterBlockId, } if (this.contextExtensions.resumeFromSnapshot) { diff --git a/apps/sim/executor/execution/types.ts b/apps/sim/executor/execution/types.ts index 9a4ffb691b..40c4c61cd2 100644 --- a/apps/sim/executor/execution/types.ts +++ b/apps/sim/executor/execution/types.ts @@ -112,6 +112,11 @@ export interface ContextExtensions { * execution mode starting from the specified block. */ runFromBlockContext?: RunFromBlockContext + + /** + * Stop execution after this block completes. Used for "run until block" feature. + */ + stopAfterBlockId?: string } export interface WorkflowInput { diff --git a/apps/sim/executor/types.ts b/apps/sim/executor/types.ts index 35ff1c3c00..9752b7701a 100644 --- a/apps/sim/executor/types.ts +++ b/apps/sim/executor/types.ts @@ -257,6 +257,11 @@ export interface ExecutionContext { * will be executed; others return cached outputs from the source snapshot. */ runFromBlockContext?: RunFromBlockContext + + /** + * Stop execution after this block completes. Used for "run until block" feature. + */ + stopAfterBlockId?: string } export interface ExecutionResult { diff --git a/apps/sim/executor/utils/run-from-block.test.ts b/apps/sim/executor/utils/run-from-block.test.ts index a641d805bc..098d6e5c9f 100644 --- a/apps/sim/executor/utils/run-from-block.test.ts +++ b/apps/sim/executor/utils/run-from-block.test.ts @@ -311,14 +311,25 @@ describe('validateRunFromBlock', () => { expect(result.error).toContain('sentinel') }) - it('rejects unexecuted blocks', () => { - const dag = createDAG([createNode('A'), createNode('B')]) - const executedBlocks = new Set(['A']) // B was not executed + it('rejects blocks with unexecuted upstream dependencies', () => { + // A → B, only A executed but B depends on A + const dag = createDAG([createNode('A', [{ target: 'B' }]), createNode('B')]) + const executedBlocks = new Set() // A was not executed const result = validateRunFromBlock('B', dag, executedBlocks) expect(result.valid).toBe(false) - expect(result.error).toContain('was not executed') + expect(result.error).toContain('Upstream dependency not executed') + }) + + it('allows blocks with no dependencies even if not previously executed', () => { + // A and B are independent (no edges) + const dag = createDAG([createNode('A'), createNode('B')]) + const executedBlocks = new Set(['A']) // B was not executed but has no deps + + const result = validateRunFromBlock('B', dag, executedBlocks) + + expect(result.valid).toBe(true) // B has no incoming edges, so it's valid }) it('accepts regular executed block', () => { @@ -374,19 +385,22 @@ describe('validateRunFromBlock', () => { expect(result.valid).toBe(true) }) - it('rejects loop container that was not executed', () => { + it('allows loop container with no upstream dependencies', () => { + // Loop containers are validated via their sentinel nodes, not incoming edges on the container itself + // If the loop has no upstream dependencies, it should be valid const loopId = 'loop-container-1' const sentinelStartId = `loop-${loopId}-sentinel-start` const dag = createDAG([ createNode(sentinelStartId, [], { isSentinel: true, sentinelType: 'start', loopId }), ]) dag.loopConfigs.set(loopId, { id: loopId, nodes: [], iterations: 3, loopType: 'for' } as any) - const executedBlocks = new Set() // Loop was not executed + const executedBlocks = new Set() // Nothing executed but loop has no deps const result = validateRunFromBlock(loopId, dag, executedBlocks) - expect(result.valid).toBe(false) - expect(result.error).toContain('was not executed') + // Loop container validation doesn't check incoming edges (containers don't have nodes in dag.nodes) + // So this is valid - the loop can start fresh + expect(result.valid).toBe(true) }) }) diff --git a/apps/sim/executor/utils/run-from-block.ts b/apps/sim/executor/utils/run-from-block.ts index 0b364063ca..a12be5ab29 100644 --- a/apps/sim/executor/utils/run-from-block.ts +++ b/apps/sim/executor/utils/run-from-block.ts @@ -105,7 +105,7 @@ export function computeDirtySet(dag: DAG, startBlockId: string): Set { * - Block cannot be inside a loop (but loop containers are allowed) * - Block cannot be inside a parallel (but parallel containers are allowed) * - Block cannot be a sentinel node - * - Block must have been executed in the source run + * - All upstream dependencies must have been executed (have cached outputs) * * @param blockId - The block ID to validate * @param dag - The workflow DAG @@ -158,12 +158,18 @@ export function validateRunFromBlock( if (node.metadata.isSentinel) { return { valid: false, error: 'Cannot run from sentinel node' } } - } - if (!executedBlocks.has(blockId)) { - return { - valid: false, - error: `Block was not executed in source run: ${blockId}`, + // Check if all upstream dependencies have been executed (have cached outputs) + // If no incoming edges (trigger/start block), dependencies are satisfied + if (node.incomingEdges.size > 0) { + for (const sourceId of node.incomingEdges.keys()) { + if (!executedBlocks.has(sourceId)) { + return { + valid: false, + error: `Upstream dependency not executed: ${sourceId}`, + } + } + } } } diff --git a/apps/sim/hooks/use-execution-stream.ts b/apps/sim/hooks/use-execution-stream.ts index bd36cb55da..0273165e4a 100644 --- a/apps/sim/hooks/use-execution-stream.ts +++ b/apps/sim/hooks/use-execution-stream.ts @@ -143,6 +143,7 @@ export interface ExecuteStreamOptions { loops?: Record parallels?: Record } + stopAfterBlockId?: string callbacks?: ExecutionStreamCallbacks } diff --git a/apps/sim/lib/workflows/executor/execution-core.ts b/apps/sim/lib/workflows/executor/execution-core.ts index c2b300f084..2ce87873f4 100644 --- a/apps/sim/lib/workflows/executor/execution-core.ts +++ b/apps/sim/lib/workflows/executor/execution-core.ts @@ -40,6 +40,7 @@ export interface ExecuteWorkflowCoreOptions { abortSignal?: AbortSignal includeFileBase64?: boolean base64MaxBytes?: number + stopAfterBlockId?: string } function parseVariableValueByType(value: unknown, type: string): unknown { @@ -114,6 +115,7 @@ export async function executeWorkflowCore( abortSignal, includeFileBase64, base64MaxBytes, + stopAfterBlockId, } = options const { metadata, workflow, input, workflowVariables, selectedOutputs } = snapshot const { requestId, workflowId, userId, triggerType, executionId, triggerBlockId, useDraftState } = @@ -297,6 +299,7 @@ export async function executeWorkflowCore( abortSignal, includeFileBase64, base64MaxBytes, + stopAfterBlockId, } const executorInstance = new Executor({ From 2c333bfd98f8e50c2a920cf3bba6fe01e66a593f Mon Sep 17 00:00:00 2001 From: Siddharth Ganesan Date: Tue, 27 Jan 2026 12:25:27 -0800 Subject: [PATCH 11/33] Lint --- .../[id]/execute-from-block/route.ts | 6 +- .../components/action-bar/action-bar.tsx | 5 -- .../hooks/use-workflow-execution.ts | 17 ++--- .../[workspaceId]/w/[workflowId]/workflow.tsx | 62 ++++++++----------- apps/sim/executor/execution/executor.ts | 6 +- apps/sim/executor/orchestrators/loop.ts | 10 ++- apps/sim/executor/orchestrators/node.ts | 2 - apps/sim/executor/orchestrators/parallel.ts | 10 ++- .../sim/executor/utils/run-from-block.test.ts | 54 +++++++++++++--- 9 files changed, 88 insertions(+), 84 deletions(-) diff --git a/apps/sim/app/api/workflows/[id]/execute-from-block/route.ts b/apps/sim/app/api/workflows/[id]/execute-from-block/route.ts index 5a2bb9f341..88d6de179a 100644 --- a/apps/sim/app/api/workflows/[id]/execute-from-block/route.ts +++ b/apps/sim/app/api/workflows/[id]/execute-from-block/route.ts @@ -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({ diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/action-bar/action-bar.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/action-bar/action-bar.tsx index 33edef0ecd..cac127c002 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/action-bar/action-bar.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/action-bar/action-bar.tsx @@ -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 = 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 f2907a19b6..10ee2ba75c 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 @@ -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 } diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/workflow.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/workflow.tsx index 0d13f7d636..1a51f0a4ae 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/workflow.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/workflow.tsx @@ -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} /> diff --git a/apps/sim/executor/execution/executor.ts b/apps/sim/executor/execution/executor.ts index 4112eb6e60..d20b5a22ff 100644 --- a/apps/sim/executor/execution/executor.ts +++ b/apps/sim/executor/execution/executor.ts @@ -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, }) diff --git a/apps/sim/executor/orchestrators/loop.ts b/apps/sim/executor/orchestrators/loop.ts index 52b0414ae0..2cce72272b 100644 --- a/apps/sim/executor/orchestrators/loop.ts +++ b/apps/sim/executor/orchestrators/loop.ts @@ -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 { diff --git a/apps/sim/executor/orchestrators/node.ts b/apps/sim/executor/orchestrators/node.ts index 244b54abd5..be7698b50c 100644 --- a/apps/sim/executor/orchestrators/node.ts +++ b/apps/sim/executor/orchestrators/node.ts @@ -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) || {} diff --git a/apps/sim/executor/orchestrators/parallel.ts b/apps/sim/executor/orchestrators/parallel.ts index 8536b1941b..517d3f6ffb 100644 --- a/apps/sim/executor/orchestrators/parallel.ts +++ b/apps/sim/executor/orchestrators/parallel.ts @@ -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 { diff --git a/apps/sim/executor/utils/run-from-block.test.ts b/apps/sim/executor/utils/run-from-block.test.ts index 098d6e5c9f..284379095f 100644 --- a/apps/sim/executor/utils/run-from-block.test.ts +++ b/apps/sim/executor/utils/run-from-block.test.ts @@ -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) From 7a0aaa460dd211b9870a119e9f4eac7718a9ed8f Mon Sep 17 00:00:00 2001 From: Siddharth Ganesan Date: Tue, 27 Jan 2026 12:30:46 -0800 Subject: [PATCH 12/33] Clean up --- .../hooks/use-workflow-execution.ts | 6 ----- apps/sim/executor/execution/engine.ts | 2 -- apps/sim/executor/execution/executor.ts | 27 ++----------------- apps/sim/executor/utils/run-from-block.ts | 8 ------ 4 files changed, 2 insertions(+), 41 deletions(-) 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 10ee2ba75c..c52785294a 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 @@ -1068,10 +1068,8 @@ export function useWorkflowExecution() { logs: accumulatedBlockLogs, } - // Store execution snapshot for run-from-block if (data.success && activeWorkflowId) { if (stopAfterBlockId) { - // Partial run (run-until-block): merge with existing snapshot const existingSnapshot = getLastExecutionSnapshot(activeWorkflowId) const mergedBlockStates = { ...(existingSnapshot?.blockStates || {}), @@ -1096,7 +1094,6 @@ export function useWorkflowExecution() { totalExecutedBlocks: mergedExecutedBlocks.size, }) } else { - // Full run: replace snapshot entirely const snapshot: SerializableExecutionState = { blockStates: Object.fromEntries(accumulatedBlockStates), executedBlocks: Array.from(executedBlockIds), @@ -1443,7 +1440,6 @@ export function useWorkflowExecution() { return } - // Check if all upstream dependencies have cached outputs const workflowEdges = useWorkflowStore.getState().edges const incomingEdges = workflowEdges.filter((edge) => edge.target === blockId) const dependenciesSatisfied = @@ -1504,7 +1500,6 @@ export function useWorkflowExecution() { executionTime: data.durationMs, }) - // Skip adding loop/parallel containers to console and logs const isContainerBlock = data.blockType === 'loop' || data.blockType === 'parallel' if (isContainerBlock) return @@ -1584,7 +1579,6 @@ export function useWorkflowExecution() { onExecutionCompleted: (data) => { if (data.success) { - // Merge new states with snapshot states for updated snapshot const mergedBlockStates: Record = { ...snapshot.blockStates } for (const [bId, state] of accumulatedBlockStates) { mergedBlockStates[bId] = state diff --git a/apps/sim/executor/execution/engine.ts b/apps/sim/executor/execution/engine.ts index b519fceefc..0c6b6a1e59 100644 --- a/apps/sim/executor/execution/engine.ts +++ b/apps/sim/executor/execution/engine.ts @@ -260,7 +260,6 @@ export class ExecutionEngine { } private initializeQueue(triggerBlockId?: string): void { - // Run-from-block mode: start directly from specified block if (this.context.runFromBlockContext) { const { startBlockId } = this.context.runFromBlockContext logger.info('Initializing queue for run-from-block mode', { @@ -397,7 +396,6 @@ export class ExecutionEngine { this.finalOutput = output } - // Check if we should stop after this block (run-until-block feature) if (this.context.stopAfterBlockId === nodeId) { logger.info('Stopping execution after target block', { nodeId }) this.stoppedEarlyFlag = true diff --git a/apps/sim/executor/execution/executor.ts b/apps/sim/executor/execution/executor.ts index d20b5a22ff..ec5fde24f6 100644 --- a/apps/sim/executor/execution/executor.ts +++ b/apps/sim/executor/execution/executor.ts @@ -100,39 +100,22 @@ export class DAGExecutor { } /** - * Execute workflow starting from a specific block, using cached outputs - * for all upstream/unaffected blocks from the source snapshot. - * - * This implements Jupyter notebook-style execution where: - * - The start block and all downstream blocks are re-executed - * - Upstream blocks retain their cached outputs from the source snapshot - * - The result is a merged execution state - * - * @param workflowId - The workflow ID - * @param startBlockId - The block to start execution from - * @param sourceSnapshot - The execution state from a previous run - * @returns Merged execution result with cached + fresh outputs + * Execute from a specific block using cached outputs for upstream blocks. */ async executeFromBlock( workflowId: string, startBlockId: string, sourceSnapshot: SerializableExecutionState ): Promise { - // Build full DAG (no trigger constraint - we need all blocks for validation) const dag = this.dagBuilder.build(this.workflow) - // Validate the start block const executedBlocks = new Set(sourceSnapshot.executedBlocks) const validation = validateRunFromBlock(startBlockId, dag, executedBlocks) if (!validation.valid) { throw new Error(validation.error) } - // Compute dirty set (blocks that will be re-executed) const dirtySet = computeDirtySet(dag, startBlockId) - - // Resolve container IDs to sentinel IDs for execution - // The engine needs to start from the sentinel node, not the container ID const effectiveStartBlockId = resolveContainerToSentinelStart(startBlockId, dag) ?? startBlockId logger.info('Executing from block', { @@ -144,9 +127,7 @@ export class DAGExecutor { dirtyBlocks: Array.from(dirtySet), }) - // For convergent blocks in the dirty set, remove incoming edges from non-dirty sources. - // This ensures that a dirty block waiting on multiple inputs doesn't wait for non-dirty - // upstream blocks (whose outputs are already cached). + // Remove incoming edges from non-dirty sources so convergent blocks don't wait for cached upstream for (const nodeId of dirtySet) { const node = dag.nodes.get(nodeId) if (!node) continue @@ -167,14 +148,12 @@ export class DAGExecutor { } } - // Create context with snapshot state + runFromBlockContext const runFromBlockContext = { startBlockId: effectiveStartBlockId, dirtySet } const { context, state } = this.createExecutionContext(workflowId, undefined, { snapshotState: sourceSnapshot, runFromBlockContext, }) - // Setup orchestrators and engine (same as execute()) const resolver = new VariableResolver(this.workflow, this.workflowVariables, state) const loopOrchestrator = new LoopOrchestrator(dag, state, resolver) loopOrchestrator.setContextExtensions(this.contextExtensions) @@ -194,7 +173,6 @@ export class DAGExecutor { ) const engine = new ExecutionEngine(context, dag, edgeManager, nodeOrchestrator) - // Run and return result return await engine.run() } @@ -214,7 +192,6 @@ export class DAGExecutor { ? new Set(snapshotState.executedBlocks) : new Set() - // In run-from-block mode, clear the executed status for dirty blocks so they can be re-executed if (overrides?.runFromBlockContext) { const { dirtySet } = overrides.runFromBlockContext executedBlocks = new Set([...executedBlocks].filter((id) => !dirtySet.has(id))) diff --git a/apps/sim/executor/utils/run-from-block.ts b/apps/sim/executor/utils/run-from-block.ts index a12be5ab29..260ffe7964 100644 --- a/apps/sim/executor/utils/run-from-block.ts +++ b/apps/sim/executor/utils/run-from-block.ts @@ -63,8 +63,6 @@ export interface RunFromBlockContext { */ export function computeDirtySet(dag: DAG, startBlockId: string): Set { const dirty = new Set([startBlockId]) - - // For loop/parallel containers, resolve to sentinel-start for BFS traversal const sentinelStartId = resolveContainerToSentinelStart(startBlockId, dag) const traversalStartId = sentinelStartId ?? startBlockId @@ -118,8 +116,6 @@ export function validateRunFromBlock( executedBlocks: Set ): RunFromBlockValidation { const node = dag.nodes.get(blockId) - - // Check if this is a loop or parallel container (not in dag.nodes but in configs) const isLoopContainer = dag.loopConfigs.has(blockId) const isParallelContainer = dag.parallelConfigs.has(blockId) const isContainer = isLoopContainer || isParallelContainer @@ -128,7 +124,6 @@ export function validateRunFromBlock( return { valid: false, error: `Block not found in workflow: ${blockId}` } } - // For containers, verify the sentinel-start exists if (isContainer) { const sentinelStartId = resolveContainerToSentinelStart(blockId, dag) if (!sentinelStartId || !dag.nodes.has(sentinelStartId)) { @@ -139,7 +134,6 @@ export function validateRunFromBlock( } } - // For regular nodes, check if inside loop/parallel if (node) { if (node.metadata.isLoopNode) { return { @@ -159,8 +153,6 @@ export function validateRunFromBlock( return { valid: false, error: 'Cannot run from sentinel node' } } - // Check if all upstream dependencies have been executed (have cached outputs) - // If no incoming edges (trigger/start block), dependencies are satisfied if (node.incomingEdges.size > 0) { for (const sourceId of node.incomingEdges.keys()) { if (!executedBlocks.has(sourceId)) { From 8dc45e6e7e74371fe8465938ed7a81ec86fb7c5c Mon Sep 17 00:00:00 2001 From: Siddharth Ganesan Date: Tue, 27 Jan 2026 12:32:18 -0800 Subject: [PATCH 13/33] Fix --- apps/sim/stores/execution/types.ts | 21 --------------------- 1 file changed, 21 deletions(-) diff --git a/apps/sim/stores/execution/types.ts b/apps/sim/stores/execution/types.ts index 27e3f79d3b..bc39d0491c 100644 --- a/apps/sim/stores/execution/types.ts +++ b/apps/sim/stores/execution/types.ts @@ -19,20 +19,8 @@ export interface ExecutionState { pendingBlocks: string[] executor: Executor | null debugContext: ExecutionContext | null - /** - * Tracks blocks from the last execution run and their success/error status. - * Cleared when a new run starts. Used to show run path indicators (rings on blocks). - */ lastRunPath: Map - /** - * Tracks edges from the last execution run and their success/error status. - * Cleared when a new run starts. Used to show run path indicators on edges. - */ lastRunEdges: Map - /** - * Stores the last successful execution snapshot per workflow. - * Used for run-from-block functionality. - */ lastExecutionSnapshots: Map } @@ -47,17 +35,8 @@ export interface ExecutionActions { setEdgeRunStatus: (edgeId: string, status: EdgeRunStatus) => void clearRunPath: () => void reset: () => void - /** - * Store the execution snapshot for a workflow after successful execution. - */ setLastExecutionSnapshot: (workflowId: string, snapshot: SerializableExecutionState) => void - /** - * Get the last execution snapshot for a workflow. - */ getLastExecutionSnapshot: (workflowId: string) => SerializableExecutionState | undefined - /** - * Clear the execution snapshot for a workflow. - */ clearLastExecutionSnapshot: (workflowId: string) => void } From 415acda40388fd3b3ec39481959ab5d64c911ac6 Mon Sep 17 00:00:00 2001 From: Siddharth Ganesan Date: Tue, 27 Jan 2026 12:50:16 -0800 Subject: [PATCH 14/33] Allow run from block for triggers --- .../components/action-bar/action-bar.tsx | 21 ++++------- .../hooks/use-workflow-execution.ts | 36 +++++++++++++------ .../[workspaceId]/w/[workflowId]/workflow.tsx | 9 +++-- 3 files changed, 36 insertions(+), 30 deletions(-) diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/action-bar/action-bar.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/action-bar/action-bar.tsx index cac127c002..bd95692486 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/action-bar/action-bar.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/action-bar/action-bar.tsx @@ -112,19 +112,13 @@ export const ActionBar = memo( const isInsideSubflow = parentId && (parentType === 'loop' || parentType === 'parallel') const snapshot = activeWorkflowId ? getLastExecutionSnapshot(activeWorkflowId) : null - const hasExecutionSnapshot = !!snapshot - const dependenciesSatisfied = (() => { - if (!snapshot) return false - const incomingEdges = edges.filter((edge) => edge.target === blockId) - if (incomingEdges.length === 0) return true - return incomingEdges.every((edge) => snapshot.executedBlocks.includes(edge.source)) - })() + const incomingEdges = edges.filter((edge) => edge.target === blockId) + const isTriggerBlock = incomingEdges.length === 0 + const dependenciesSatisfied = + isTriggerBlock || + (snapshot && incomingEdges.every((edge) => snapshot.executedBlocks.includes(edge.source))) const canRunFromBlock = - hasExecutionSnapshot && - dependenciesSatisfied && - !isNoteBlock && - !isInsideSubflow && - !isExecuting + dependenciesSatisfied && !isNoteBlock && !isInsideSubflow && !isExecuting const handleRunFromBlockClick = useCallback(() => { if (!activeWorkflowId || !canRunFromBlock) return @@ -176,9 +170,8 @@ export const ActionBar = memo( {(() => { if (disabled) return getTooltipMessage('Run from this block') if (isExecuting) return 'Execution in progress' - if (!hasExecutionSnapshot) return 'Run workflow first' - if (!dependenciesSatisfied) return 'Run upstream blocks first' if (isInsideSubflow) return 'Cannot run from inside subflow' + if (!dependenciesSatisfied) return 'Run upstream blocks first' return 'Run from this block' })()} 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 c52785294a..983896fe2c 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 @@ -1435,16 +1435,18 @@ export function useWorkflowExecution() { const handleRunFromBlock = useCallback( async (blockId: string, workflowId: string) => { const snapshot = getLastExecutionSnapshot(workflowId) - if (!snapshot) { + const workflowEdges = useWorkflowStore.getState().edges + const incomingEdges = workflowEdges.filter((edge) => edge.target === blockId) + const isTriggerBlock = incomingEdges.length === 0 + + if (!snapshot && !isTriggerBlock) { logger.error('No execution snapshot available for run-from-block', { workflowId, blockId }) return } - const workflowEdges = useWorkflowStore.getState().edges - const incomingEdges = workflowEdges.filter((edge) => edge.target === blockId) const dependenciesSatisfied = - incomingEdges.length === 0 || - incomingEdges.every((edge) => snapshot.executedBlocks.includes(edge.source)) + isTriggerBlock || + (snapshot && incomingEdges.every((edge) => snapshot.executedBlocks.includes(edge.source))) if (!dependenciesSatisfied) { logger.error('Upstream dependencies not satisfied for run-from-block', { @@ -1454,10 +1456,20 @@ export function useWorkflowExecution() { return } + // For trigger blocks with no snapshot, create an empty one + const effectiveSnapshot: SerializableExecutionState = snapshot || { + blockStates: {}, + executedBlocks: [], + blockLogs: [], + decisions: { router: {}, condition: {} }, + completedLoops: [], + activeExecutionPath: [], + } + logger.info('Starting run-from-block execution', { workflowId, startBlockId: blockId, - snapshotExecutedBlocks: snapshot.executedBlocks.length, + isTriggerBlock, }) setIsExecuting(true) @@ -1471,7 +1483,7 @@ export function useWorkflowExecution() { await executionStream.executeFromBlock({ workflowId, startBlockId: blockId, - sourceSnapshot: snapshot, + sourceSnapshot: effectiveSnapshot, callbacks: { onExecutionStarted: (data) => { logger.info('Run-from-block execution started:', data) @@ -1579,21 +1591,23 @@ export function useWorkflowExecution() { onExecutionCompleted: (data) => { if (data.success) { - const mergedBlockStates: Record = { ...snapshot.blockStates } + const mergedBlockStates: Record = { + ...effectiveSnapshot.blockStates, + } for (const [bId, state] of accumulatedBlockStates) { mergedBlockStates[bId] = state } const mergedExecutedBlocks = new Set([ - ...snapshot.executedBlocks, + ...effectiveSnapshot.executedBlocks, ...executedBlockIds, ]) const updatedSnapshot: SerializableExecutionState = { - ...snapshot, + ...effectiveSnapshot, blockStates: mergedBlockStates, executedBlocks: Array.from(mergedExecutedBlocks), - blockLogs: [...snapshot.blockLogs, ...accumulatedBlockLogs], + blockLogs: [...effectiveSnapshot.blockLogs, ...accumulatedBlockLogs], activeExecutionPath: Array.from(mergedExecutedBlocks), } setLastExecutionSnapshot(workflowId, updatedSnapshot) diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/workflow.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/workflow.tsx index 1a51f0a4ae..33b10a3935 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/workflow.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/workflow.tsx @@ -1012,18 +1012,17 @@ const WorkflowContent = React.memo(() => { } 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 isTriggerBlock = incomingEdges.length === 0 const dependenciesSatisfied = - incomingEdges.length === 0 || - incomingEdges.every((edge) => snapshot.executedBlocks.includes(edge.source)) + isTriggerBlock || + (snapshot && 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 (!dependenciesSatisfied) return { canRun: false, reason: 'Run upstream blocks first' } if (isNoteBlock) return { canRun: false, reason: undefined } if (isExecuting) return { canRun: false, reason: undefined } From c14c614e3373262e227f1c557d68d139dca9ae8a Mon Sep 17 00:00:00 2001 From: Siddharth Ganesan Date: Tue, 27 Jan 2026 12:55:27 -0800 Subject: [PATCH 15/33] Consolidation --- .../[id]/execute-from-block/route.ts | 384 ++++++------------ .../lib/workflows/executor/execution-core.ts | 18 +- 2 files changed, 144 insertions(+), 258 deletions(-) diff --git a/apps/sim/app/api/workflows/[id]/execute-from-block/route.ts b/apps/sim/app/api/workflows/[id]/execute-from-block/route.ts index 88d6de179a..d7d284a06b 100644 --- a/apps/sim/app/api/workflows/[id]/execute-from-block/route.ts +++ b/apps/sim/app/api/workflows/[id]/execute-from-block/route.ts @@ -7,18 +7,14 @@ import { z } from 'zod' import { checkHybridAuth } from '@/lib/auth/hybrid' import { generateRequestId } from '@/lib/core/utils/request' import { SSE_HEADERS } from '@/lib/core/utils/sse' -import { getPersonalAndWorkspaceEnv } from '@/lib/environment/utils' -import { clearExecutionCancellation, markExecutionCancelled } from '@/lib/execution/cancellation' +import { markExecutionCancelled } from '@/lib/execution/cancellation' import { LoggingSession } from '@/lib/logs/execution/logging-session' -import { buildTraceSpans } from '@/lib/logs/execution/trace-spans/trace-spans' +import { executeWorkflowCore } from '@/lib/workflows/executor/execution-core' import { type ExecutionEvent, encodeSSEEvent } from '@/lib/workflows/executor/execution-events' -import { loadWorkflowFromNormalizedTables } from '@/lib/workflows/persistence/utils' -import { DAGExecutor } from '@/executor/execution/executor' +import { ExecutionSnapshot } from '@/executor/execution/snapshot' import type { IterationContext, SerializableExecutionState } from '@/executor/execution/types' import type { NormalizedBlockOutput } from '@/executor/types' import { hasExecutionResult } from '@/executor/utils/errors' -import { Serializer } from '@/serializer' -import { mergeSubblockState } from '@/stores/workflows/server-utils' const logger = createLogger('ExecuteFromBlockAPI') @@ -43,12 +39,6 @@ const ExecuteFromBlockSchema = z.object({ export const runtime = 'nodejs' export const dynamic = 'force-dynamic' -/** - * POST /api/workflows/[id]/execute-from-block - * - * Executes a workflow starting from a specific block using cached outputs - * for upstream/unaffected blocks from the source snapshot. - */ export async function POST(req: NextRequest, { params }: { params: Promise<{ id: string }> }) { const requestId = generateRequestId() const { id: workflowId } = await params @@ -83,17 +73,8 @@ export async function POST(req: NextRequest, { params }: { params: Promise<{ id: } const { startBlockId, sourceSnapshot } = validation.data - - logger.info(`[${requestId}] Starting run-from-block execution`, { - workflowId, - userId, - startBlockId, - executedBlocksCount: sourceSnapshot.executedBlocks.length, - }) - const executionId = uuidv4() - // Load workflow record to get workspaceId const [workflowRecord] = await db .select({ workspaceId: workflowTable.workspaceId, userId: workflowTable.userId }) .from(workflowTable) @@ -107,44 +88,13 @@ export async function POST(req: NextRequest, { params }: { params: Promise<{ id: const workspaceId = workflowRecord.workspaceId const workflowUserId = workflowRecord.userId - // Initialize logging session for cost tracking - const loggingSession = new LoggingSession(workflowId, executionId, 'manual', requestId) - - // Load workflow state - const workflowData = await loadWorkflowFromNormalizedTables(workflowId) - if (!workflowData) { - return NextResponse.json({ error: 'Workflow state not found' }, { status: 404 }) - } - - const { blocks, edges, loops, parallels } = workflowData - - // Merge block states - const mergedStates = mergeSubblockState(blocks) - - // Get environment variables - const { personalDecrypted, workspaceDecrypted } = await getPersonalAndWorkspaceEnv( - userId, - workspaceId - ) - const decryptedEnvVars: Record = { ...personalDecrypted, ...workspaceDecrypted } - - // Serialize workflow - const serializedWorkflow = new Serializer().serializeWorkflow( - mergedStates, - edges, - loops, - parallels, - true - ) - - // Start logging session - await loggingSession.safeStart({ - userId, - workspaceId, - variables: {}, + logger.info(`[${requestId}] Starting run-from-block execution`, { + workflowId, + startBlockId, + executedBlocksCount: sourceSnapshot.executedBlocks.length, }) - const encoder = new TextEncoder() + const loggingSession = new LoggingSession(workflowId, executionId, 'manual', requestId) const abortController = new AbortController() let isStreamClosed = false @@ -152,7 +102,6 @@ export async function POST(req: NextRequest, { params }: { params: Promise<{ id: async start(controller) { const sendEvent = (event: ExecutionEvent) => { if (isStreamClosed) return - try { controller.enqueue(encodeSSEEvent(event)) } catch { @@ -160,6 +109,18 @@ export async function POST(req: NextRequest, { params }: { params: Promise<{ id: } } + const snapshot = new ExecutionSnapshot({ + requestId, + workflowId, + userId, + executionId, + triggerType: 'manual', + workspaceId, + workflowUserId, + useDraftState: true, + isClientSession: true, + }) + try { const startTime = new Date() @@ -168,220 +129,141 @@ export async function POST(req: NextRequest, { params }: { params: Promise<{ id: timestamp: startTime.toISOString(), executionId, workflowId, - data: { - startTime: startTime.toISOString(), - }, + data: { startTime: startTime.toISOString() }, }) - const onBlockStart = async ( - blockId: string, - blockName: string, - blockType: string, - iterationContext?: IterationContext - ) => { - sendEvent({ - type: 'block:started', - timestamp: new Date().toISOString(), - executionId, - workflowId, - data: { - blockId, - blockName, - blockType, - ...(iterationContext && { - iterationCurrent: iterationContext.iterationCurrent, - iterationTotal: iterationContext.iterationTotal, - iterationType: iterationContext.iterationType, - }), + const result = await executeWorkflowCore({ + snapshot, + loggingSession, + abortSignal: abortController.signal, + runFromBlock: { + startBlockId, + sourceSnapshot: sourceSnapshot as SerializableExecutionState, + }, + callbacks: { + onBlockStart: async ( + blockId: string, + blockName: string, + blockType: string, + iterationContext?: IterationContext + ) => { + sendEvent({ + type: 'block:started', + timestamp: new Date().toISOString(), + executionId, + workflowId, + data: { + blockId, + blockName, + blockType, + ...(iterationContext && { + iterationCurrent: iterationContext.iterationCurrent, + iterationTotal: iterationContext.iterationTotal, + iterationType: iterationContext.iterationType, + }), + }, + }) }, - }) - } - - const onBlockComplete = async ( - blockId: string, - blockName: string, - blockType: string, - callbackData: { input?: unknown; output: NormalizedBlockOutput; executionTime: number }, - iterationContext?: IterationContext - ) => { - // Log to session for cost tracking - await loggingSession.onBlockComplete(blockId, blockName, blockType, callbackData) - - const hasError = (callbackData.output as any)?.error - - if (hasError) { - sendEvent({ - type: 'block:error', - timestamp: new Date().toISOString(), - executionId, - workflowId, - data: { - blockId, - blockName, - blockType, - input: callbackData.input, - error: (callbackData.output as any).error, - durationMs: callbackData.executionTime || 0, - ...(iterationContext && { - iterationCurrent: iterationContext.iterationCurrent, - iterationTotal: iterationContext.iterationTotal, - iterationType: iterationContext.iterationType, - }), - }, - }) - } else { - sendEvent({ - type: 'block:completed', - timestamp: new Date().toISOString(), - executionId, - workflowId, - data: { - blockId, - blockName, - blockType, - input: callbackData.input, - output: callbackData.output, - durationMs: callbackData.executionTime || 0, - ...(iterationContext && { - iterationCurrent: iterationContext.iterationCurrent, - iterationTotal: iterationContext.iterationTotal, - iterationType: iterationContext.iterationType, - }), + onBlockComplete: async ( + blockId: string, + blockName: string, + blockType: string, + callbackData: { + input?: unknown + output: NormalizedBlockOutput + executionTime: number }, - }) - } - } - - const onStream = async (streamingExecution: unknown) => { - const streamingExec = streamingExecution as { stream: ReadableStream; execution: any } - const blockId = streamingExec.execution?.blockId - - const reader = streamingExec.stream.getReader() - const decoder = new TextDecoder() - - try { - while (true) { - const { done, value } = await reader.read() - if (done) break - - const chunk = decoder.decode(value, { stream: true }) + iterationContext?: IterationContext + ) => { + const hasError = (callbackData.output as any)?.error sendEvent({ - type: 'stream:chunk', + type: hasError ? 'block:error' : 'block:completed', timestamp: new Date().toISOString(), executionId, workflowId, - data: { blockId, chunk }, + data: { + blockId, + blockName, + blockType, + input: callbackData.input, + ...(hasError + ? { error: (callbackData.output as any).error } + : { output: callbackData.output }), + durationMs: callbackData.executionTime || 0, + ...(iterationContext && { + iterationCurrent: iterationContext.iterationCurrent, + iterationTotal: iterationContext.iterationTotal, + iterationType: iterationContext.iterationType, + }), + }, }) - } - - sendEvent({ - type: 'stream:done', - timestamp: new Date().toISOString(), - executionId, - workflowId, - data: { blockId }, - }) - } catch (error) { - logger.error(`[${requestId}] Error streaming block content:`, error) - } finally { - try { - reader.releaseLock() - } catch {} - } - } - - // Create executor and run from block - const executor = new DAGExecutor({ - workflow: serializedWorkflow, - envVarValues: decryptedEnvVars, - workflowInput: {}, - workflowVariables: {}, - contextExtensions: { - stream: true, - executionId, - workspaceId, - userId, - isDeployedContext: false, - onBlockStart, - onBlockComplete, - onStream, - abortSignal: abortController.signal, + }, + onStream: async (streamingExecution: unknown) => { + const streamingExec = streamingExecution as { + stream: ReadableStream + execution: any + } + const blockId = streamingExec.execution?.blockId + const reader = streamingExec.stream.getReader() + const decoder = new TextDecoder() + + try { + while (true) { + const { done, value } = await reader.read() + if (done) break + const chunk = decoder.decode(value, { stream: true }) + sendEvent({ + type: 'stream:chunk', + timestamp: new Date().toISOString(), + executionId, + workflowId, + data: { blockId, chunk }, + }) + } + sendEvent({ + type: 'stream:done', + timestamp: new Date().toISOString(), + executionId, + workflowId, + data: { blockId }, + }) + } finally { + try { + reader.releaseLock() + } catch {} + } + }, }, }) - const result = await executor.executeFromBlock( - workflowId, - startBlockId, - sourceSnapshot as SerializableExecutionState - ) - - // Build trace spans from fresh execution logs only - // Trace spans show what actually executed in this run - const { traceSpans, totalDuration } = buildTraceSpans(result) - if (result.status === 'cancelled') { - await loggingSession.safeCompleteWithCancellation({ - endedAt: new Date().toISOString(), - totalDurationMs: totalDuration || 0, - traceSpans: traceSpans || [], - }) - await clearExecutionCancellation(executionId) - sendEvent({ type: 'execution:cancelled', timestamp: new Date().toISOString(), executionId, workflowId, + data: { duration: result.metadata?.duration || 0 }, + }) + } else { + sendEvent({ + type: 'execution:completed', + timestamp: new Date().toISOString(), + executionId, + workflowId, data: { + success: result.success, + output: result.output, duration: result.metadata?.duration || 0, + startTime: result.metadata?.startTime || startTime.toISOString(), + endTime: result.metadata?.endTime || new Date().toISOString(), }, }) - return } - - // Complete logging session - await loggingSession.safeComplete({ - endedAt: new Date().toISOString(), - totalDurationMs: totalDuration || 0, - finalOutput: result.output || {}, - traceSpans: traceSpans || [], - workflowInput: {}, - }) - await clearExecutionCancellation(executionId) - - sendEvent({ - type: 'execution:completed', - timestamp: new Date().toISOString(), - executionId, - workflowId, - data: { - success: result.success, - output: result.output, - duration: result.metadata?.duration || 0, - startTime: result.metadata?.startTime || startTime.toISOString(), - endTime: result.metadata?.endTime || new Date().toISOString(), - }, - }) } catch (error: unknown) { const errorMessage = error instanceof Error ? error.message : 'Unknown error' logger.error(`[${requestId}] Run-from-block execution failed: ${errorMessage}`) const executionResult = hasExecutionResult(error) ? error.executionResult : undefined - const { traceSpans } = executionResult - ? buildTraceSpans(executionResult) - : { traceSpans: [] } - - // Complete logging session with error - await loggingSession.safeCompleteWithError({ - endedAt: new Date().toISOString(), - totalDurationMs: executionResult?.metadata?.duration || 0, - error: { - message: errorMessage, - stackTrace: error instanceof Error ? error.stack : undefined, - }, - traceSpans, - }) - await clearExecutionCancellation(executionId) sendEvent({ type: 'execution:error', @@ -396,27 +278,21 @@ export async function POST(req: NextRequest, { params }: { params: Promise<{ id: } finally { if (!isStreamClosed) { try { - controller.enqueue(encoder.encode('data: [DONE]\n\n')) + controller.enqueue(new TextEncoder().encode('data: [DONE]\n\n')) controller.close() - } catch { - // Stream already closed - } + } catch {} } } }, cancel() { isStreamClosed = true - logger.info(`[${requestId}] Client aborted SSE stream, signalling cancellation`) abortController.abort() markExecutionCancelled(executionId).catch(() => {}) }, }) return new NextResponse(stream, { - headers: { - ...SSE_HEADERS, - 'X-Execution-Id': executionId, - }, + headers: { ...SSE_HEADERS, 'X-Execution-Id': executionId }, }) } catch (error: unknown) { const errorMessage = error instanceof Error ? error.message : 'Unknown error' diff --git a/apps/sim/lib/workflows/executor/execution-core.ts b/apps/sim/lib/workflows/executor/execution-core.ts index 2ce87873f4..350e06b2f8 100644 --- a/apps/sim/lib/workflows/executor/execution-core.ts +++ b/apps/sim/lib/workflows/executor/execution-core.ts @@ -22,6 +22,7 @@ import type { ContextExtensions, ExecutionCallbacks, IterationContext, + SerializableExecutionState, } from '@/executor/execution/types' import type { ExecutionResult, NormalizedBlockOutput } from '@/executor/types' import { hasExecutionResult } from '@/executor/utils/errors' @@ -41,6 +42,11 @@ export interface ExecuteWorkflowCoreOptions { includeFileBase64?: boolean base64MaxBytes?: number stopAfterBlockId?: string + /** Run-from-block mode: execute starting from a specific block using cached upstream outputs */ + runFromBlock?: { + startBlockId: string + sourceSnapshot: SerializableExecutionState + } } function parseVariableValueByType(value: unknown, type: string): unknown { @@ -116,6 +122,7 @@ export async function executeWorkflowCore( includeFileBase64, base64MaxBytes, stopAfterBlockId, + runFromBlock, } = options const { metadata, workflow, input, workflowVariables, selectedOutputs } = snapshot const { requestId, workflowId, userId, triggerType, executionId, triggerBlockId, useDraftState } = @@ -322,10 +329,13 @@ export async function executeWorkflowCore( } } - const result = (await executorInstance.execute( - workflowId, - resolvedTriggerBlockId - )) as ExecutionResult + const result = runFromBlock + ? ((await executorInstance.executeFromBlock( + workflowId, + runFromBlock.startBlockId, + runFromBlock.sourceSnapshot + )) as ExecutionResult) + : ((await executorInstance.execute(workflowId, resolvedTriggerBlockId)) as ExecutionResult) // Build trace spans for logging from the full execution result const { traceSpans, totalDuration } = buildTraceSpans(result) From f55f6cc4532608b4317703bfbafba7572ba40cdd Mon Sep 17 00:00:00 2001 From: Siddharth Ganesan Date: Tue, 27 Jan 2026 14:08:32 -0800 Subject: [PATCH 16/33] Fix lint --- .../[id]/execute-from-block/route.ts | 122 ++-------------- .../workflows/executor/execution-events.ts | 137 ++++++++++++++++++ 2 files changed, 153 insertions(+), 106 deletions(-) diff --git a/apps/sim/app/api/workflows/[id]/execute-from-block/route.ts b/apps/sim/app/api/workflows/[id]/execute-from-block/route.ts index d7d284a06b..88b0521f2b 100644 --- a/apps/sim/app/api/workflows/[id]/execute-from-block/route.ts +++ b/apps/sim/app/api/workflows/[id]/execute-from-block/route.ts @@ -10,10 +10,9 @@ import { SSE_HEADERS } from '@/lib/core/utils/sse' import { markExecutionCancelled } from '@/lib/execution/cancellation' import { LoggingSession } from '@/lib/logs/execution/logging-session' import { executeWorkflowCore } from '@/lib/workflows/executor/execution-core' -import { type ExecutionEvent, encodeSSEEvent } from '@/lib/workflows/executor/execution-events' +import { createSSECallbacks } from '@/lib/workflows/executor/execution-events' import { ExecutionSnapshot } from '@/executor/execution/snapshot' -import type { IterationContext, SerializableExecutionState } from '@/executor/execution/types' -import type { NormalizedBlockOutput } from '@/executor/types' +import type { ExecutionMetadata, SerializableExecutionState } from '@/executor/execution/types' import { hasExecutionResult } from '@/executor/utils/errors' const logger = createLogger('ExecuteFromBlockAPI') @@ -100,16 +99,17 @@ export async function POST(req: NextRequest, { params }: { params: Promise<{ id: const stream = new ReadableStream({ async start(controller) { - const sendEvent = (event: ExecutionEvent) => { - if (isStreamClosed) return - try { - controller.enqueue(encodeSSEEvent(event)) - } catch { + const { sendEvent, onBlockStart, onBlockComplete, onStream } = createSSECallbacks({ + executionId, + workflowId, + controller, + isStreamClosed: () => isStreamClosed, + setStreamClosed: () => { isStreamClosed = true - } - } + }, + }) - const snapshot = new ExecutionSnapshot({ + const metadata: ExecutionMetadata = { requestId, workflowId, userId, @@ -119,7 +119,10 @@ export async function POST(req: NextRequest, { params }: { params: Promise<{ id: workflowUserId, useDraftState: true, isClientSession: true, - }) + startTime: new Date().toISOString(), + } + + const snapshot = new ExecutionSnapshot(metadata, {}, {}, {}) try { const startTime = new Date() @@ -140,100 +143,7 @@ export async function POST(req: NextRequest, { params }: { params: Promise<{ id: startBlockId, sourceSnapshot: sourceSnapshot as SerializableExecutionState, }, - callbacks: { - onBlockStart: async ( - blockId: string, - blockName: string, - blockType: string, - iterationContext?: IterationContext - ) => { - sendEvent({ - type: 'block:started', - timestamp: new Date().toISOString(), - executionId, - workflowId, - data: { - blockId, - blockName, - blockType, - ...(iterationContext && { - iterationCurrent: iterationContext.iterationCurrent, - iterationTotal: iterationContext.iterationTotal, - iterationType: iterationContext.iterationType, - }), - }, - }) - }, - onBlockComplete: async ( - blockId: string, - blockName: string, - blockType: string, - callbackData: { - input?: unknown - output: NormalizedBlockOutput - executionTime: number - }, - iterationContext?: IterationContext - ) => { - const hasError = (callbackData.output as any)?.error - sendEvent({ - type: hasError ? 'block:error' : 'block:completed', - timestamp: new Date().toISOString(), - executionId, - workflowId, - data: { - blockId, - blockName, - blockType, - input: callbackData.input, - ...(hasError - ? { error: (callbackData.output as any).error } - : { output: callbackData.output }), - durationMs: callbackData.executionTime || 0, - ...(iterationContext && { - iterationCurrent: iterationContext.iterationCurrent, - iterationTotal: iterationContext.iterationTotal, - iterationType: iterationContext.iterationType, - }), - }, - }) - }, - onStream: async (streamingExecution: unknown) => { - const streamingExec = streamingExecution as { - stream: ReadableStream - execution: any - } - const blockId = streamingExec.execution?.blockId - const reader = streamingExec.stream.getReader() - const decoder = new TextDecoder() - - try { - while (true) { - const { done, value } = await reader.read() - if (done) break - const chunk = decoder.decode(value, { stream: true }) - sendEvent({ - type: 'stream:chunk', - timestamp: new Date().toISOString(), - executionId, - workflowId, - data: { blockId, chunk }, - }) - } - sendEvent({ - type: 'stream:done', - timestamp: new Date().toISOString(), - executionId, - workflowId, - data: { blockId }, - }) - } finally { - try { - reader.releaseLock() - } catch {} - } - }, - }, + callbacks: { onBlockStart, onBlockComplete, onStream }, }) if (result.status === 'cancelled') { diff --git a/apps/sim/lib/workflows/executor/execution-events.ts b/apps/sim/lib/workflows/executor/execution-events.ts index 291c119b7c..436df010ea 100644 --- a/apps/sim/lib/workflows/executor/execution-events.ts +++ b/apps/sim/lib/workflows/executor/execution-events.ts @@ -180,3 +180,140 @@ export function formatSSEEvent(event: ExecutionEvent): string { export function encodeSSEEvent(event: ExecutionEvent): Uint8Array { return new TextEncoder().encode(formatSSEEvent(event)) } + +/** + * Options for creating SSE execution callbacks + */ +export interface SSECallbackOptions { + executionId: string + workflowId: string + controller: ReadableStreamDefaultController + isStreamClosed: () => boolean + setStreamClosed: () => void +} + +/** + * Creates SSE callbacks for workflow execution streaming + */ +export function createSSECallbacks(options: SSECallbackOptions) { + const { executionId, workflowId, controller, isStreamClosed, setStreamClosed } = options + + const sendEvent = (event: ExecutionEvent) => { + if (isStreamClosed()) return + try { + controller.enqueue(encodeSSEEvent(event)) + } catch { + setStreamClosed() + } + } + + const onBlockStart = async ( + blockId: string, + blockName: string, + blockType: string, + iterationContext?: { iterationCurrent: number; iterationTotal: number; iterationType: string } + ) => { + sendEvent({ + type: 'block:started', + timestamp: new Date().toISOString(), + executionId, + workflowId, + data: { + blockId, + blockName, + blockType, + ...(iterationContext && { + iterationCurrent: iterationContext.iterationCurrent, + iterationTotal: iterationContext.iterationTotal, + iterationType: iterationContext.iterationType as any, + }), + }, + }) + } + + const onBlockComplete = async ( + blockId: string, + blockName: string, + blockType: string, + callbackData: { input?: unknown; output: any; executionTime: number }, + iterationContext?: { iterationCurrent: number; iterationTotal: number; iterationType: string } + ) => { + const hasError = callbackData.output?.error + const iterationData = iterationContext + ? { + iterationCurrent: iterationContext.iterationCurrent, + iterationTotal: iterationContext.iterationTotal, + iterationType: iterationContext.iterationType as any, + } + : {} + + if (hasError) { + sendEvent({ + type: 'block:error', + timestamp: new Date().toISOString(), + executionId, + workflowId, + data: { + blockId, + blockName, + blockType, + input: callbackData.input, + error: callbackData.output.error, + durationMs: callbackData.executionTime || 0, + ...iterationData, + }, + }) + } else { + sendEvent({ + type: 'block:completed', + timestamp: new Date().toISOString(), + executionId, + workflowId, + data: { + blockId, + blockName, + blockType, + input: callbackData.input, + output: callbackData.output, + durationMs: callbackData.executionTime || 0, + ...iterationData, + }, + }) + } + } + + const onStream = async (streamingExecution: unknown) => { + const streamingExec = streamingExecution as { stream: ReadableStream; execution: any } + const blockId = streamingExec.execution?.blockId + const reader = streamingExec.stream.getReader() + const decoder = new TextDecoder() + + try { + while (true) { + const { done, value } = await reader.read() + if (done) break + const chunk = decoder.decode(value, { stream: true }) + sendEvent({ + type: 'stream:chunk', + timestamp: new Date().toISOString(), + executionId, + workflowId, + data: { blockId, chunk }, + }) + } + sendEvent({ + type: 'stream:done', + timestamp: new Date().toISOString(), + executionId, + workflowId, + data: { blockId }, + }) + } finally { + try { + reader.releaseLock() + } catch {} + } + } + + return { sendEvent, onBlockStart, onBlockComplete, onStream } +} From 2f504ce07e8d05ff123aafdeefdd28f8fb9923e0 Mon Sep 17 00:00:00 2001 From: Siddharth Ganesan Date: Tue, 27 Jan 2026 14:55:07 -0800 Subject: [PATCH 17/33] Fix --- .../components/action-bar/action-bar.tsx | 4 ++-- .../components/block-menu/block-menu.tsx | 11 ++-------- .../hooks/use-workflow-execution.ts | 22 +++++++++++++++++++ .../[workspaceId]/w/[workflowId]/workflow.tsx | 1 - 4 files changed, 26 insertions(+), 12 deletions(-) diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/action-bar/action-bar.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/action-bar/action-bar.tsx index bd95692486..e016918a15 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/action-bar/action-bar.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/action-bar/action-bar.tsx @@ -168,11 +168,11 @@ export const ActionBar = memo( {(() => { - if (disabled) return getTooltipMessage('Run from this block') + if (disabled) return getTooltipMessage('Run from block') if (isExecuting) return 'Execution in progress' if (isInsideSubflow) return 'Cannot run from inside subflow' if (!dependenciesSatisfied) return 'Run upstream blocks first' - return 'Run from this block' + return 'Run from block' })()} diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/block-menu/block-menu.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/block-menu/block-menu.tsx index 8e1290ab6d..dfeb6b0bd9 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/block-menu/block-menu.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/block-menu/block-menu.tsx @@ -46,8 +46,6 @@ export interface BlockMenuProps { showRemoveFromSubflow?: boolean /** Whether run from block is available (has snapshot, was executed, not inside subflow) */ canRunFromBlock?: boolean - /** Reason why run from block is disabled (for tooltip) */ - runFromBlockDisabledReason?: string disableEdit?: boolean isExecuting?: boolean } @@ -77,7 +75,6 @@ export function BlockMenu({ hasClipboard = false, showRemoveFromSubflow = false, canRunFromBlock = false, - runFromBlockDisabledReason, disableEdit = false, isExecuting = false, }: BlockMenuProps) { @@ -228,11 +225,7 @@ export function BlockMenu({ } }} > - {isExecuting - ? 'Execution in progress...' - : !canRunFromBlock && runFromBlockDisabledReason - ? runFromBlockDisabledReason - : 'Run from this block'} + Run from block - {isExecuting ? 'Execution in progress...' : 'Run until this block'} + Run until block )} 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 983896fe2c..7fd40b7754 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 @@ -23,6 +23,7 @@ import { subscriptionKeys } from '@/hooks/queries/subscription' import { useExecutionStream } from '@/hooks/use-execution-stream' import { WorkflowValidationError } from '@/serializer' import { useExecutionStore } from '@/stores/execution' +import { useNotificationStore } from '@/stores/notifications' import { useVariablesStore } from '@/stores/panel' import { useEnvironmentStore } from '@/stores/settings/environment' import { type ConsoleEntry, useTerminalConsoleStore } from '@/stores/terminal' @@ -101,11 +102,13 @@ export function useWorkflowExecution() { setEdgeRunStatus, setLastExecutionSnapshot, getLastExecutionSnapshot, + clearLastExecutionSnapshot, } = useExecutionStore() const [executionResult, setExecutionResult] = useState(null) const executionStream = useExecutionStream() const currentChatExecutionIdRef = useRef(null) const isViewingDiff = useWorkflowDiffStore((state) => state.isShowingDiff) + const addNotification = useNotificationStore((state) => state.addNotification) /** * Validates debug state before performing debug operations @@ -1620,6 +1623,23 @@ export function useWorkflowExecution() { onExecutionError: (data) => { logger.error('Run-from-block execution error:', data.error) + + // If block not found, the snapshot is stale - clear it + if (data.error?.includes('Block not found in workflow')) { + clearLastExecutionSnapshot(workflowId) + addNotification({ + level: 'info', + message: 'Workflow was modified. Run the workflow again to refresh.', + workflowId, + }) + logger.info('Cleared stale execution snapshot', { workflowId }) + } else { + addNotification({ + level: 'error', + message: data.error || 'Run from block failed', + workflowId, + }) + } }, onExecutionCancelled: () => { @@ -1639,10 +1659,12 @@ export function useWorkflowExecution() { [ getLastExecutionSnapshot, setLastExecutionSnapshot, + clearLastExecutionSnapshot, setIsExecuting, setActiveBlocks, setBlockRunStatus, setEdgeRunStatus, + addNotification, addConsole, executionStream, ] diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/workflow.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/workflow.tsx index 33b10a3935..9727a9eb07 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/workflow.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/workflow.tsx @@ -3356,7 +3356,6 @@ const WorkflowContent = React.memo(() => { (b) => b.parentId && (b.parentType === 'loop' || b.parentType === 'parallel') )} canRunFromBlock={runFromBlockState.canRun} - runFromBlockDisabledReason={runFromBlockState.reason} disableEdit={!effectivePermissions.canEdit} isExecuting={isExecuting} /> From 6f66d33e6287bbcc1d84e781ef7ed922567bac38 Mon Sep 17 00:00:00 2001 From: Siddharth Ganesan Date: Tue, 27 Jan 2026 14:59:34 -0800 Subject: [PATCH 18/33] Fix mock payload --- .../[id]/execute-from-block/route.ts | 5 +-- .../hooks/use-workflow-execution.ts | 36 +++++++++++++++++++ apps/sim/hooks/use-execution-stream.ts | 5 +-- 3 files changed, 42 insertions(+), 4 deletions(-) diff --git a/apps/sim/app/api/workflows/[id]/execute-from-block/route.ts b/apps/sim/app/api/workflows/[id]/execute-from-block/route.ts index 88b0521f2b..647012589c 100644 --- a/apps/sim/app/api/workflows/[id]/execute-from-block/route.ts +++ b/apps/sim/app/api/workflows/[id]/execute-from-block/route.ts @@ -33,6 +33,7 @@ const ExecuteFromBlockSchema = z.object({ parallelBlockMapping: z.record(z.any()).optional(), activeExecutionPath: z.array(z.string()), }), + input: z.any().optional(), }) export const runtime = 'nodejs' @@ -71,7 +72,7 @@ export async function POST(req: NextRequest, { params }: { params: Promise<{ id: ) } - const { startBlockId, sourceSnapshot } = validation.data + const { startBlockId, sourceSnapshot, input } = validation.data const executionId = uuidv4() const [workflowRecord] = await db @@ -122,7 +123,7 @@ export async function POST(req: NextRequest, { params }: { params: Promise<{ id: startTime: new Date().toISOString(), } - const snapshot = new ExecutionSnapshot(metadata, {}, {}, {}) + const snapshot = new ExecutionSnapshot(metadata, {}, input || {}, {}) try { const startTime = new Date() 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 7fd40b7754..ec37906fa8 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 @@ -1469,10 +1469,45 @@ export function useWorkflowExecution() { activeExecutionPath: [], } + // Extract mock payload for trigger blocks + let workflowInput: any + if (isTriggerBlock) { + const workflowBlocks = useWorkflowStore.getState().blocks + const mergedStates = mergeSubblockState(workflowBlocks, workflowId) + const candidates = resolveStartCandidates(mergedStates, { execution: 'manual' }) + const candidate = candidates.find((c) => c.blockId === blockId) + + if (candidate) { + if (triggerNeedsMockPayload(candidate)) { + workflowInput = extractTriggerMockPayload(candidate) + logger.info('Extracted mock payload for trigger block', { blockId, workflowInput }) + } else if ( + candidate.path === StartBlockPath.SPLIT_API || + candidate.path === StartBlockPath.SPLIT_INPUT || + candidate.path === StartBlockPath.UNIFIED + ) { + const inputFormatValue = candidate.block.subBlocks?.inputFormat?.value + if (Array.isArray(inputFormatValue)) { + const testInput: Record = {} + inputFormatValue.forEach((field: any) => { + if (field && typeof field === 'object' && field.name && field.value !== undefined) { + testInput[field.name] = coerceValue(field.type, field.value) + } + }) + if (Object.keys(testInput).length > 0) { + workflowInput = testInput + logger.info('Extracted test input for trigger block', { blockId, workflowInput }) + } + } + } + } + } + logger.info('Starting run-from-block execution', { workflowId, startBlockId: blockId, isTriggerBlock, + hasInput: !!workflowInput, }) setIsExecuting(true) @@ -1487,6 +1522,7 @@ export function useWorkflowExecution() { workflowId, startBlockId: blockId, sourceSnapshot: effectiveSnapshot, + input: workflowInput, callbacks: { onExecutionStarted: (data) => { logger.info('Run-from-block execution started:', data) diff --git a/apps/sim/hooks/use-execution-stream.ts b/apps/sim/hooks/use-execution-stream.ts index 0273165e4a..08ca7164d2 100644 --- a/apps/sim/hooks/use-execution-stream.ts +++ b/apps/sim/hooks/use-execution-stream.ts @@ -151,6 +151,7 @@ export interface ExecuteFromBlockOptions { workflowId: string startBlockId: string sourceSnapshot: SerializableExecutionState + input?: any callbacks?: ExecutionStreamCallbacks } @@ -222,7 +223,7 @@ export function useExecutionStream() { }, []) const executeFromBlock = useCallback(async (options: ExecuteFromBlockOptions) => { - const { workflowId, startBlockId, sourceSnapshot, callbacks = {} } = options + const { workflowId, startBlockId, sourceSnapshot, input, callbacks = {} } = options if (abortControllerRef.current) { abortControllerRef.current.abort() @@ -238,7 +239,7 @@ export function useExecutionStream() { headers: { 'Content-Type': 'application/json', }, - body: JSON.stringify({ startBlockId, sourceSnapshot }), + body: JSON.stringify({ startBlockId, sourceSnapshot, input }), signal: abortController.signal, }) From d80608cdd5459992a3da6d2b761c18e7d8969e31 Mon Sep 17 00:00:00 2001 From: Siddharth Ganesan Date: Tue, 27 Jan 2026 16:20:41 -0800 Subject: [PATCH 19/33] Fix --- .../hooks/use-workflow-execution.ts | 50 ++++++++++++++++++- apps/sim/executor/execution/executor.ts | 8 ++- 2 files changed, 55 insertions(+), 3 deletions(-) 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 ec37906fa8..6f610ff9f4 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 @@ -15,6 +15,7 @@ import { TriggerUtils, } from '@/lib/workflows/triggers/triggers' import { useCurrentWorkflow } from '@/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-current-workflow' +import { getBlock } from '@/blocks' import type { SerializableExecutionState } from '@/executor/execution/types' import type { BlockLog, BlockState, ExecutionResult, StreamingExecution } from '@/executor/types' import { hasExecutionResult } from '@/executor/utils/errors' @@ -1477,8 +1478,29 @@ export function useWorkflowExecution() { const candidates = resolveStartCandidates(mergedStates, { execution: 'manual' }) const candidate = candidates.find((c) => c.blockId === blockId) + logger.info('Run-from-block trigger analysis', { + blockId, + blockType: workflowBlocks[blockId]?.type, + blockTriggerMode: workflowBlocks[blockId]?.triggerMode, + candidateFound: !!candidate, + candidatePath: candidate?.path, + allCandidates: candidates.map((c) => ({ + blockId: c.blockId, + type: c.block.type, + path: c.path, + })), + }) + if (candidate) { - if (triggerNeedsMockPayload(candidate)) { + const needsMockPayload = triggerNeedsMockPayload(candidate) + logger.info('Trigger mock payload check', { + needsMockPayload, + path: candidate.path, + isExternalTrigger: candidate.path === StartBlockPath.EXTERNAL_TRIGGER, + blockType: candidate.block.type, + }) + + if (needsMockPayload) { workflowInput = extractTriggerMockPayload(candidate) logger.info('Extracted mock payload for trigger block', { blockId, workflowInput }) } else if ( @@ -1500,6 +1522,32 @@ export function useWorkflowExecution() { } } } + } else { + // Fallback for trigger blocks not found in candidates + // This can happen when the block is a trigger by position (no incoming edges) + // but wasn't classified as a start candidate (e.g., triggerMode not set) + const block = mergedStates[blockId] + if (block) { + const blockConfig = getBlock(block.type) + const hasTriggers = blockConfig?.triggers?.available?.length + + if (hasTriggers || block.triggerMode) { + // Block has trigger capability - extract mock payload + const syntheticCandidate = { + blockId, + block, + path: StartBlockPath.EXTERNAL_TRIGGER, + } + workflowInput = extractTriggerMockPayload(syntheticCandidate) + logger.info('Extracted mock payload for trigger block (fallback)', { + blockId, + blockType: block.type, + hasTriggers, + triggerMode: block.triggerMode, + workflowInput, + }) + } + } } } diff --git a/apps/sim/executor/execution/executor.ts b/apps/sim/executor/execution/executor.ts index ec5fde24f6..ce35629698 100644 --- a/apps/sim/executor/execution/executor.ts +++ b/apps/sim/executor/execution/executor.ts @@ -107,7 +107,8 @@ export class DAGExecutor { startBlockId: string, sourceSnapshot: SerializableExecutionState ): Promise { - const dag = this.dagBuilder.build(this.workflow) + // Pass startBlockId as trigger so DAG includes it and all downstream blocks + const dag = this.dagBuilder.build(this.workflow, startBlockId) const executedBlocks = new Set(sourceSnapshot.executedBlocks) const validation = validateRunFromBlock(startBlockId, dag, executedBlocks) @@ -297,7 +298,10 @@ export class DAGExecutor { skipStarterBlockInit: true, }) } else if (overrides?.runFromBlockContext) { - logger.info('Run-from-block mode: skipping starter block initialization', { + // In run-from-block mode, still initialize the start block with workflow input + // This ensures trigger blocks get their mock payload + this.initializeStarterBlock(context, state, overrides.runFromBlockContext.startBlockId) + logger.info('Run-from-block mode: initialized start block', { startBlockId: overrides.runFromBlockContext.startBlockId, }) } else { From c201a7ca910a2d8a949daf4533fa0975ac766798 Mon Sep 17 00:00:00 2001 From: Siddharth Ganesan Date: Tue, 27 Jan 2026 16:53:13 -0800 Subject: [PATCH 20/33] Fix trigger clear snapshot --- .../w/[workflowId]/hooks/use-workflow-execution.ts | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) 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 6f610ff9f4..5e4125db71 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 @@ -1460,8 +1460,9 @@ export function useWorkflowExecution() { return } - // For trigger blocks with no snapshot, create an empty one - const effectiveSnapshot: SerializableExecutionState = snapshot || { + // For trigger blocks, always use empty snapshot to prevent stale data from different + // execution paths from being resolved. For non-trigger blocks, use the existing snapshot. + const emptySnapshot: SerializableExecutionState = { blockStates: {}, executedBlocks: [], blockLogs: [], @@ -1469,6 +1470,9 @@ export function useWorkflowExecution() { completedLoops: [], activeExecutionPath: [], } + const effectiveSnapshot: SerializableExecutionState = isTriggerBlock + ? emptySnapshot + : snapshot || emptySnapshot // Extract mock payload for trigger blocks let workflowInput: any From d9631424dcffbd4a5d350ba693d35f3066429319 Mon Sep 17 00:00:00 2001 From: Siddharth Ganesan Date: Tue, 27 Jan 2026 17:08:39 -0800 Subject: [PATCH 21/33] Fix loops and parallels --- apps/sim/executor/execution/executor.ts | 28 ++++++++++++++++++------- 1 file changed, 20 insertions(+), 8 deletions(-) diff --git a/apps/sim/executor/execution/executor.ts b/apps/sim/executor/execution/executor.ts index ce35629698..1dd3d4fb1b 100644 --- a/apps/sim/executor/execution/executor.ts +++ b/apps/sim/executor/execution/executor.ts @@ -107,8 +107,13 @@ export class DAGExecutor { startBlockId: string, sourceSnapshot: SerializableExecutionState ): Promise { - // Pass startBlockId as trigger so DAG includes it and all downstream blocks - const dag = this.dagBuilder.build(this.workflow, startBlockId) + // Check if startBlockId is a regular block in the workflow + // Parallel/loop containers are not in workflow.blocks, so we need to handle them differently + const isRegularBlock = this.workflow.blocks.some((b) => b.id === startBlockId) + + // For regular blocks, pass startBlockId so DAG includes it and all downstream blocks + // For containers (parallel/loop), build DAG normally and let it find the trigger + const dag = this.dagBuilder.build(this.workflow, isRegularBlock ? startBlockId : undefined) const executedBlocks = new Set(sourceSnapshot.executedBlocks) const validation = validateRunFromBlock(startBlockId, dag, executedBlocks) @@ -298,12 +303,19 @@ export class DAGExecutor { skipStarterBlockInit: true, }) } else if (overrides?.runFromBlockContext) { - // In run-from-block mode, still initialize the start block with workflow input - // This ensures trigger blocks get their mock payload - this.initializeStarterBlock(context, state, overrides.runFromBlockContext.startBlockId) - logger.info('Run-from-block mode: initialized start block', { - startBlockId: overrides.runFromBlockContext.startBlockId, - }) + // In run-from-block mode, initialize the start block only if it's a regular block + // Skip for sentinels/containers (loop/parallel) which aren't real blocks + const startBlockId = overrides.runFromBlockContext.startBlockId + const isRegularBlock = this.workflow.blocks.some((b) => b.id === startBlockId) + + if (isRegularBlock) { + this.initializeStarterBlock(context, state, startBlockId) + logger.info('Run-from-block mode: initialized start block', { startBlockId }) + } else { + logger.info('Run-from-block mode: skipping starter block init for container/sentinel', { + startBlockId, + }) + } } else { this.initializeStarterBlock(context, state, triggerBlockId) } From 4996eea2ee893e5736df52622474a4d84a972a63 Mon Sep 17 00:00:00 2001 From: Siddharth Ganesan Date: Tue, 27 Jan 2026 17:38:41 -0800 Subject: [PATCH 22/33] Fix --- apps/sim/executor/execution/executor.ts | 23 ++++++- apps/sim/executor/utils/run-from-block.ts | 65 +++++++++++++++---- .../lib/workflows/executor/execution-core.ts | 24 ++++++- 3 files changed, 96 insertions(+), 16 deletions(-) diff --git a/apps/sim/executor/execution/executor.ts b/apps/sim/executor/execution/executor.ts index 1dd3d4fb1b..db6d32cfb0 100644 --- a/apps/sim/executor/execution/executor.ts +++ b/apps/sim/executor/execution/executor.ts @@ -16,7 +16,7 @@ import { NodeExecutionOrchestrator } from '@/executor/orchestrators/node' import { ParallelOrchestrator } from '@/executor/orchestrators/parallel' import type { BlockState, ExecutionContext, ExecutionResult } from '@/executor/types' import { - computeDirtySet, + computeExecutionSets, type RunFromBlockContext, resolveContainerToSentinelStart, validateRunFromBlock, @@ -121,14 +121,31 @@ export class DAGExecutor { throw new Error(validation.error) } - const dirtySet = computeDirtySet(dag, startBlockId) + const { dirtySet, upstreamSet } = computeExecutionSets(dag, startBlockId) const effectiveStartBlockId = resolveContainerToSentinelStart(startBlockId, dag) ?? startBlockId + // Filter snapshot to only include upstream blocks - prevents references to non-upstream blocks + const filteredBlockStates: Record = {} + for (const [blockId, state] of Object.entries(sourceSnapshot.blockStates)) { + if (upstreamSet.has(blockId)) { + filteredBlockStates[blockId] = state + } + } + const filteredExecutedBlocks = sourceSnapshot.executedBlocks.filter((id) => upstreamSet.has(id)) + + const filteredSnapshot: SerializableExecutionState = { + ...sourceSnapshot, + blockStates: filteredBlockStates, + executedBlocks: filteredExecutedBlocks, + } + logger.info('Executing from block', { workflowId, startBlockId, effectiveStartBlockId, dirtySetSize: dirtySet.size, + upstreamSetSize: upstreamSet.size, + filteredBlockStatesCount: Object.keys(filteredBlockStates).length, totalBlocks: dag.nodes.size, dirtyBlocks: Array.from(dirtySet), }) @@ -156,7 +173,7 @@ export class DAGExecutor { const runFromBlockContext = { startBlockId: effectiveStartBlockId, dirtySet } const { context, state } = this.createExecutionContext(workflowId, undefined, { - snapshotState: sourceSnapshot, + snapshotState: filteredSnapshot, runFromBlockContext, }) diff --git a/apps/sim/executor/utils/run-from-block.ts b/apps/sim/executor/utils/run-from-block.ts index 260ffe7964..8bb6df1790 100644 --- a/apps/sim/executor/utils/run-from-block.ts +++ b/apps/sim/executor/utils/run-from-block.ts @@ -51,18 +51,30 @@ export interface RunFromBlockContext { } /** - * Computes all blocks that need re-execution when running from a specific block. - * Uses BFS to find all downstream blocks reachable via outgoing edges. + * Result of computing execution sets for run-from-block mode. + */ +export interface ExecutionSets { + /** Blocks that need re-execution (start block + all downstream) */ + dirtySet: Set + /** Blocks that are upstream (ancestors) of the start block */ + upstreamSet: Set +} + +/** + * Computes both the dirty set (downstream) and upstream set in a single traversal pass. + * - Dirty set: start block + all blocks reachable via outgoing edges (need re-execution) + * - Upstream set: all blocks reachable via incoming edges (can be referenced) * * For loop/parallel containers, starts from the sentinel-start node and includes * the container ID itself in the dirty set. * * @param dag - The workflow DAG * @param startBlockId - The block to start execution from - * @returns Set of block IDs that are "dirty" and need re-execution + * @returns Object containing both dirtySet and upstreamSet */ -export function computeDirtySet(dag: DAG, startBlockId: string): Set { +export function computeExecutionSets(dag: DAG, startBlockId: string): ExecutionSets { const dirty = new Set([startBlockId]) + const upstream = new Set() const sentinelStartId = resolveContainerToSentinelStart(startBlockId, dag) const traversalStartId = sentinelStartId ?? startBlockId @@ -70,29 +82,58 @@ export function computeDirtySet(dag: DAG, startBlockId: string): Set { dirty.add(sentinelStartId) } - const queue = [traversalStartId] - - while (queue.length > 0) { - const nodeId = queue.shift()! + // BFS downstream for dirty set + const downstreamQueue = [traversalStartId] + while (downstreamQueue.length > 0) { + const nodeId = downstreamQueue.shift()! const node = dag.nodes.get(nodeId) if (!node) continue for (const [, edge] of node.outgoingEdges) { if (!dirty.has(edge.target)) { dirty.add(edge.target) - queue.push(edge.target) + downstreamQueue.push(edge.target) } } } - logger.debug('Computed dirty set', { + // BFS upstream for upstream set + const upstreamQueue = [traversalStartId] + while (upstreamQueue.length > 0) { + const nodeId = upstreamQueue.shift()! + const node = dag.nodes.get(nodeId) + if (!node) continue + + for (const sourceId of node.incomingEdges) { + if (!upstream.has(sourceId)) { + upstream.add(sourceId) + upstreamQueue.push(sourceId) + } + } + } + + logger.debug('Computed execution sets', { startBlockId, traversalStartId, dirtySetSize: dirty.size, - dirtyBlocks: Array.from(dirty), + upstreamSetSize: upstream.size, }) - return dirty + return { dirtySet: dirty, upstreamSet: upstream } +} + +/** + * @deprecated Use computeExecutionSets instead for combined computation + */ +export function computeDirtySet(dag: DAG, startBlockId: string): Set { + return computeExecutionSets(dag, startBlockId).dirtySet +} + +/** + * @deprecated Use computeExecutionSets instead for combined computation + */ +export function computeUpstreamSet(dag: DAG, blockId: string): Set { + return computeExecutionSets(dag, blockId).upstreamSet } /** diff --git a/apps/sim/lib/workflows/executor/execution-core.ts b/apps/sim/lib/workflows/executor/execution-core.ts index bd9e2f69f0..fb15fb0bd6 100644 --- a/apps/sim/lib/workflows/executor/execution-core.ts +++ b/apps/sim/lib/workflows/executor/execution-core.ts @@ -27,6 +27,10 @@ import type { } from '@/executor/execution/types' import type { ExecutionResult, NormalizedBlockOutput } from '@/executor/types' import { hasExecutionResult } from '@/executor/utils/errors' +import { + buildParallelSentinelEndId, + buildSentinelEndId, +} from '@/executor/utils/subflow-utils' import { Serializer } from '@/serializer' const logger = createLogger('ExecutionCore') @@ -255,6 +259,24 @@ export async function executeWorkflowCore( processedInput = input || {} + // Resolve stopAfterBlockId for loop/parallel containers to their sentinel-end IDs + let resolvedStopAfterBlockId = stopAfterBlockId + if (stopAfterBlockId) { + if (serializedWorkflow.loops?.[stopAfterBlockId]) { + resolvedStopAfterBlockId = buildSentinelEndId(stopAfterBlockId) + logger.info(`[${requestId}] Resolved loop container to sentinel-end`, { + original: stopAfterBlockId, + resolved: resolvedStopAfterBlockId, + }) + } else if (serializedWorkflow.parallels?.[stopAfterBlockId]) { + resolvedStopAfterBlockId = buildParallelSentinelEndId(stopAfterBlockId) + logger.info(`[${requestId}] Resolved parallel container to sentinel-end`, { + original: stopAfterBlockId, + resolved: resolvedStopAfterBlockId, + }) + } + } + // Create and execute workflow with callbacks if (resumeFromSnapshot) { logger.info(`[${requestId}] Resume execution detected`, { @@ -305,7 +327,7 @@ export async function executeWorkflowCore( abortSignal, includeFileBase64, base64MaxBytes, - stopAfterBlockId, + stopAfterBlockId: resolvedStopAfterBlockId, } const executorInstance = new Executor({ From c68cda63ae99f389022c14358353014ded56e241 Mon Sep 17 00:00:00 2001 From: Siddharth Ganesan Date: Tue, 27 Jan 2026 17:53:37 -0800 Subject: [PATCH 23/33] Cleanup --- .../hooks/use-workflow-execution.ts | 65 +---------- apps/sim/executor/execution/executor.ts | 12 --- .../sim/executor/utils/run-from-block.test.ts | 101 +++++++++++++++++- apps/sim/executor/utils/run-from-block.ts | 24 ----- .../lib/workflows/executor/execution-core.ts | 13 +-- 5 files changed, 105 insertions(+), 110 deletions(-) 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 5e4125db71..471cecd9b6 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 @@ -1482,31 +1482,9 @@ export function useWorkflowExecution() { const candidates = resolveStartCandidates(mergedStates, { execution: 'manual' }) const candidate = candidates.find((c) => c.blockId === blockId) - logger.info('Run-from-block trigger analysis', { - blockId, - blockType: workflowBlocks[blockId]?.type, - blockTriggerMode: workflowBlocks[blockId]?.triggerMode, - candidateFound: !!candidate, - candidatePath: candidate?.path, - allCandidates: candidates.map((c) => ({ - blockId: c.blockId, - type: c.block.type, - path: c.path, - })), - }) - if (candidate) { - const needsMockPayload = triggerNeedsMockPayload(candidate) - logger.info('Trigger mock payload check', { - needsMockPayload, - path: candidate.path, - isExternalTrigger: candidate.path === StartBlockPath.EXTERNAL_TRIGGER, - blockType: candidate.block.type, - }) - - if (needsMockPayload) { + if (triggerNeedsMockPayload(candidate)) { workflowInput = extractTriggerMockPayload(candidate) - logger.info('Extracted mock payload for trigger block', { blockId, workflowInput }) } else if ( candidate.path === StartBlockPath.SPLIT_API || candidate.path === StartBlockPath.SPLIT_INPUT || @@ -1522,46 +1500,27 @@ export function useWorkflowExecution() { }) if (Object.keys(testInput).length > 0) { workflowInput = testInput - logger.info('Extracted test input for trigger block', { blockId, workflowInput }) } } } } else { - // Fallback for trigger blocks not found in candidates - // This can happen when the block is a trigger by position (no incoming edges) - // but wasn't classified as a start candidate (e.g., triggerMode not set) + // Fallback: block is trigger by position but not classified as start candidate const block = mergedStates[blockId] if (block) { const blockConfig = getBlock(block.type) const hasTriggers = blockConfig?.triggers?.available?.length if (hasTriggers || block.triggerMode) { - // Block has trigger capability - extract mock payload - const syntheticCandidate = { + workflowInput = extractTriggerMockPayload({ blockId, block, path: StartBlockPath.EXTERNAL_TRIGGER, - } - workflowInput = extractTriggerMockPayload(syntheticCandidate) - logger.info('Extracted mock payload for trigger block (fallback)', { - blockId, - blockType: block.type, - hasTriggers, - triggerMode: block.triggerMode, - workflowInput, }) } } } } - logger.info('Starting run-from-block execution', { - workflowId, - startBlockId: blockId, - isTriggerBlock, - hasInput: !!workflowInput, - }) - setIsExecuting(true) const executionId = uuidv4() const accumulatedBlockLogs: BlockLog[] = [] @@ -1576,10 +1535,6 @@ export function useWorkflowExecution() { sourceSnapshot: effectiveSnapshot, input: workflowInput, callbacks: { - onExecutionStarted: (data) => { - logger.info('Run-from-block execution started:', data) - }, - onBlockStarted: (data) => { activeBlocksSet.add(data.blockId) setActiveBlocks(new Set(activeBlocksSet)) @@ -1702,17 +1657,10 @@ export function useWorkflowExecution() { activeExecutionPath: Array.from(mergedExecutedBlocks), } setLastExecutionSnapshot(workflowId, updatedSnapshot) - logger.info('Updated execution snapshot after run-from-block', { - workflowId, - newBlocksExecuted: executedBlockIds.size, - }) } }, onExecutionError: (data) => { - logger.error('Run-from-block execution error:', data.error) - - // If block not found, the snapshot is stale - clear it if (data.error?.includes('Block not found in workflow')) { clearLastExecutionSnapshot(workflowId) addNotification({ @@ -1720,7 +1668,6 @@ export function useWorkflowExecution() { message: 'Workflow was modified. Run the workflow again to refresh.', workflowId, }) - logger.info('Cleared stale execution snapshot', { workflowId }) } else { addNotification({ level: 'error', @@ -1729,15 +1676,11 @@ export function useWorkflowExecution() { }) } }, - - onExecutionCancelled: () => { - logger.info('Run-from-block execution cancelled') - }, }, }) } catch (error) { if ((error as Error).name !== 'AbortError') { - logger.error('Run-from-block execution failed:', error) + logger.error('Run-from-block failed:', error) } } finally { setIsExecuting(false) diff --git a/apps/sim/executor/execution/executor.ts b/apps/sim/executor/execution/executor.ts index db6d32cfb0..6048167cf9 100644 --- a/apps/sim/executor/execution/executor.ts +++ b/apps/sim/executor/execution/executor.ts @@ -145,9 +145,6 @@ export class DAGExecutor { effectiveStartBlockId, dirtySetSize: dirtySet.size, upstreamSetSize: upstreamSet.size, - filteredBlockStatesCount: Object.keys(filteredBlockStates).length, - totalBlocks: dag.nodes.size, - dirtyBlocks: Array.from(dirtySet), }) // Remove incoming edges from non-dirty sources so convergent blocks don't wait for cached upstream @@ -164,10 +161,6 @@ export class DAGExecutor { for (const sourceId of nonDirtyIncoming) { node.incomingEdges.delete(sourceId) - logger.debug('Removed non-dirty incoming edge for run-from-block', { - nodeId, - sourceId, - }) } } @@ -327,11 +320,6 @@ export class DAGExecutor { if (isRegularBlock) { this.initializeStarterBlock(context, state, startBlockId) - logger.info('Run-from-block mode: initialized start block', { startBlockId }) - } else { - logger.info('Run-from-block mode: skipping starter block init for container/sentinel', { - startBlockId, - }) } } else { this.initializeStarterBlock(context, state, triggerBlockId) diff --git a/apps/sim/executor/utils/run-from-block.test.ts b/apps/sim/executor/utils/run-from-block.test.ts index 284379095f..07e39c58d6 100644 --- a/apps/sim/executor/utils/run-from-block.test.ts +++ b/apps/sim/executor/utils/run-from-block.test.ts @@ -1,9 +1,16 @@ import { describe, expect, it } from 'vitest' import type { DAG, DAGNode } from '@/executor/dag/builder' import type { DAGEdge, NodeMetadata } from '@/executor/dag/types' -import { computeDirtySet, validateRunFromBlock } from '@/executor/utils/run-from-block' +import { computeExecutionSets, validateRunFromBlock } from '@/executor/utils/run-from-block' import type { SerializedLoop, SerializedParallel } from '@/serializer/types' +/** + * Helper to extract dirty set from computeExecutionSets + */ +function computeDirtySet(dag: DAG, startBlockId: string): Set { + return computeExecutionSets(dag, startBlockId).dirtySet +} + /** * Helper to create a DAG node for testing */ @@ -491,3 +498,95 @@ describe('computeDirtySet with containers', () => { expect(dirtySet.has('A')).toBe(false) }) }) + +describe('computeExecutionSets upstream set', () => { + it('includes all upstream blocks in linear workflow', () => { + // A → B → C → D + const dag = createDAG([ + createNode('A', [{ target: 'B' }]), + createNode('B', [{ target: 'C' }]), + createNode('C', [{ target: 'D' }]), + createNode('D'), + ]) + + const { upstreamSet } = computeExecutionSets(dag, 'C') + + expect(upstreamSet.has('A')).toBe(true) + expect(upstreamSet.has('B')).toBe(true) + expect(upstreamSet.has('C')).toBe(false) // start block not in upstream + expect(upstreamSet.has('D')).toBe(false) // downstream + }) + + it('includes all branches in convergent upstream', () => { + // A → C + // B → C → D + const dag = createDAG([ + createNode('A', [{ target: 'C' }]), + createNode('B', [{ target: 'C' }]), + createNode('C', [{ target: 'D' }]), + createNode('D'), + ]) + + const { upstreamSet } = computeExecutionSets(dag, 'C') + + expect(upstreamSet.has('A')).toBe(true) + expect(upstreamSet.has('B')).toBe(true) + expect(upstreamSet.has('C')).toBe(false) + expect(upstreamSet.has('D')).toBe(false) + }) + + it('excludes parallel branches not in upstream path', () => { + // A → B → D + // A → C → D + // Running from B: upstream is A only, not C + const dag = createDAG([ + createNode('A', [{ target: 'B' }, { target: 'C' }]), + createNode('B', [{ target: 'D' }]), + createNode('C', [{ target: 'D' }]), + createNode('D'), + ]) + + const { upstreamSet, dirtySet } = computeExecutionSets(dag, 'B') + + // Upstream should only contain A + expect(upstreamSet.has('A')).toBe(true) + expect(upstreamSet.has('C')).toBe(false) // parallel branch, not upstream of B + // Dirty should contain B and D + expect(dirtySet.has('B')).toBe(true) + expect(dirtySet.has('D')).toBe(true) + expect(dirtySet.has('C')).toBe(false) + }) + + it('handles diamond pattern upstream correctly', () => { + // B + // ↗ ↘ + // A D → E + // ↘ ↗ + // C + // Running from D: upstream should be A, B, C + const dag = createDAG([ + createNode('A', [{ target: 'B' }, { target: 'C' }]), + createNode('B', [{ target: 'D' }]), + createNode('C', [{ target: 'D' }]), + createNode('D', [{ target: 'E' }]), + createNode('E'), + ]) + + const { upstreamSet, dirtySet } = computeExecutionSets(dag, 'D') + + expect(upstreamSet.has('A')).toBe(true) + expect(upstreamSet.has('B')).toBe(true) + expect(upstreamSet.has('C')).toBe(true) + expect(upstreamSet.has('D')).toBe(false) + expect(dirtySet.has('D')).toBe(true) + expect(dirtySet.has('E')).toBe(true) + }) + + it('returns empty upstream set for root block', () => { + const dag = createDAG([createNode('A', [{ target: 'B' }]), createNode('B')]) + + const { upstreamSet } = computeExecutionSets(dag, 'A') + + expect(upstreamSet.size).toBe(0) + }) +}) diff --git a/apps/sim/executor/utils/run-from-block.ts b/apps/sim/executor/utils/run-from-block.ts index 8bb6df1790..5813d52b55 100644 --- a/apps/sim/executor/utils/run-from-block.ts +++ b/apps/sim/executor/utils/run-from-block.ts @@ -1,9 +1,6 @@ -import { createLogger } from '@sim/logger' import { LOOP, PARALLEL } from '@/executor/constants' import type { DAG } from '@/executor/dag/builder' -const logger = createLogger('run-from-block') - /** * Builds the sentinel-start node ID for a loop. */ @@ -112,30 +109,9 @@ export function computeExecutionSets(dag: DAG, startBlockId: string): ExecutionS } } - logger.debug('Computed execution sets', { - startBlockId, - traversalStartId, - dirtySetSize: dirty.size, - upstreamSetSize: upstream.size, - }) - return { dirtySet: dirty, upstreamSet: upstream } } -/** - * @deprecated Use computeExecutionSets instead for combined computation - */ -export function computeDirtySet(dag: DAG, startBlockId: string): Set { - return computeExecutionSets(dag, startBlockId).dirtySet -} - -/** - * @deprecated Use computeExecutionSets instead for combined computation - */ -export function computeUpstreamSet(dag: DAG, blockId: string): Set { - return computeExecutionSets(dag, blockId).upstreamSet -} - /** * Validates that a block can be used as a run-from-block starting point. * diff --git a/apps/sim/lib/workflows/executor/execution-core.ts b/apps/sim/lib/workflows/executor/execution-core.ts index fb15fb0bd6..557bb284e8 100644 --- a/apps/sim/lib/workflows/executor/execution-core.ts +++ b/apps/sim/lib/workflows/executor/execution-core.ts @@ -27,10 +27,7 @@ import type { } from '@/executor/execution/types' import type { ExecutionResult, NormalizedBlockOutput } from '@/executor/types' import { hasExecutionResult } from '@/executor/utils/errors' -import { - buildParallelSentinelEndId, - buildSentinelEndId, -} from '@/executor/utils/subflow-utils' +import { buildParallelSentinelEndId, buildSentinelEndId } from '@/executor/utils/subflow-utils' import { Serializer } from '@/serializer' const logger = createLogger('ExecutionCore') @@ -264,16 +261,8 @@ export async function executeWorkflowCore( if (stopAfterBlockId) { if (serializedWorkflow.loops?.[stopAfterBlockId]) { resolvedStopAfterBlockId = buildSentinelEndId(stopAfterBlockId) - logger.info(`[${requestId}] Resolved loop container to sentinel-end`, { - original: stopAfterBlockId, - resolved: resolvedStopAfterBlockId, - }) } else if (serializedWorkflow.parallels?.[stopAfterBlockId]) { resolvedStopAfterBlockId = buildParallelSentinelEndId(stopAfterBlockId) - logger.info(`[${requestId}] Resolved parallel container to sentinel-end`, { - original: stopAfterBlockId, - resolved: resolvedStopAfterBlockId, - }) } } From 07dfedd5f146678ea9333beba77de08c2a3cb0b9 Mon Sep 17 00:00:00 2001 From: Siddharth Ganesan Date: Tue, 27 Jan 2026 18:07:36 -0800 Subject: [PATCH 24/33] Fix test --- apps/sim/executor/execution/executor.ts | 9 ++------- 1 file changed, 2 insertions(+), 7 deletions(-) diff --git a/apps/sim/executor/execution/executor.ts b/apps/sim/executor/execution/executor.ts index 6048167cf9..572ea483b1 100644 --- a/apps/sim/executor/execution/executor.ts +++ b/apps/sim/executor/execution/executor.ts @@ -107,13 +107,8 @@ export class DAGExecutor { startBlockId: string, sourceSnapshot: SerializableExecutionState ): Promise { - // Check if startBlockId is a regular block in the workflow - // Parallel/loop containers are not in workflow.blocks, so we need to handle them differently - const isRegularBlock = this.workflow.blocks.some((b) => b.id === startBlockId) - - // For regular blocks, pass startBlockId so DAG includes it and all downstream blocks - // For containers (parallel/loop), build DAG normally and let it find the trigger - const dag = this.dagBuilder.build(this.workflow, isRegularBlock ? startBlockId : undefined) + // Build full DAG to compute upstream set for snapshot filtering + const dag = this.dagBuilder.build(this.workflow) const executedBlocks = new Set(sourceSnapshot.executedBlocks) const validation = validateRunFromBlock(startBlockId, dag, executedBlocks) From 79857e1a04f2331e597bbe2c18a10af72123128d Mon Sep 17 00:00:00 2001 From: Siddharth Ganesan Date: Tue, 27 Jan 2026 18:16:46 -0800 Subject: [PATCH 25/33] Fix bugs --- .../hooks/use-workflow-execution.ts | 11 ++++++++--- apps/sim/executor/utils/run-from-block.test.ts | 18 ++++++++++++++++++ apps/sim/executor/utils/run-from-block.ts | 16 ++++++++-------- 3 files changed, 34 insertions(+), 11 deletions(-) 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 471cecd9b6..f68d870d15 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 @@ -1661,11 +1661,16 @@ export function useWorkflowExecution() { }, onExecutionError: (data) => { - if (data.error?.includes('Block not found in workflow')) { + const isWorkflowModified = + data.error?.includes('Block not found in workflow') || + data.error?.includes('Upstream dependency not executed') + + if (isWorkflowModified) { clearLastExecutionSnapshot(workflowId) addNotification({ - level: 'info', - message: 'Workflow was modified. Run the workflow again to refresh.', + level: 'error', + message: + 'Workflow was modified. Run the workflow again to enable running from block.', workflowId, }) } else { diff --git a/apps/sim/executor/utils/run-from-block.test.ts b/apps/sim/executor/utils/run-from-block.test.ts index 07e39c58d6..528b44defb 100644 --- a/apps/sim/executor/utils/run-from-block.test.ts +++ b/apps/sim/executor/utils/run-from-block.test.ts @@ -331,6 +331,24 @@ describe('validateRunFromBlock', () => { expect(result.error).toContain('Upstream dependency not executed') }) + it('rejects blocks with unexecuted transitive upstream dependencies', () => { + // A → X → B → C, where X is new (not executed) + // Running from C should fail because X in upstream chain wasn't executed + const dag = createDAG([ + createNode('A', [{ target: 'X' }]), + createNode('X', [{ target: 'B' }]), + createNode('B', [{ target: 'C' }]), + createNode('C'), + ]) + const executedBlocks = new Set(['A', 'B', 'C']) // X was not executed (new block) + + const result = validateRunFromBlock('C', dag, executedBlocks) + + expect(result.valid).toBe(false) + expect(result.error).toContain('Upstream dependency not executed') + expect(result.error).toContain('X') + }) + it('allows blocks with no dependencies even if not previously executed', () => { // A and B are independent (no edges) const dag = createDAG([createNode('A'), createNode('B')]) diff --git a/apps/sim/executor/utils/run-from-block.ts b/apps/sim/executor/utils/run-from-block.ts index 5813d52b55..dbe2e3fcd9 100644 --- a/apps/sim/executor/utils/run-from-block.ts +++ b/apps/sim/executor/utils/run-from-block.ts @@ -169,15 +169,15 @@ export function validateRunFromBlock( if (node.metadata.isSentinel) { return { valid: false, error: 'Cannot run from sentinel node' } } + } - if (node.incomingEdges.size > 0) { - for (const sourceId of node.incomingEdges.keys()) { - if (!executedBlocks.has(sourceId)) { - return { - valid: false, - error: `Upstream dependency not executed: ${sourceId}`, - } - } + // Check that ALL upstream blocks were executed (transitive check) + const { upstreamSet } = computeExecutionSets(dag, blockId) + for (const upstreamId of upstreamSet) { + if (!executedBlocks.has(upstreamId)) { + return { + valid: false, + error: `Upstream dependency not executed: ${upstreamId}`, } } } From 994a66417216c9052c5017b26e7a913773057149 Mon Sep 17 00:00:00 2001 From: Siddharth Ganesan Date: Tue, 27 Jan 2026 18:22:16 -0800 Subject: [PATCH 26/33] Catch error --- apps/sim/hooks/use-execution-stream.ts | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/apps/sim/hooks/use-execution-stream.ts b/apps/sim/hooks/use-execution-stream.ts index 08ca7164d2..b7a443ebd6 100644 --- a/apps/sim/hooks/use-execution-stream.ts +++ b/apps/sim/hooks/use-execution-stream.ts @@ -244,7 +244,12 @@ export function useExecutionStream() { }) if (!response.ok) { - const errorResponse = await response.json() + let errorResponse: any + try { + errorResponse = await response.json() + } catch { + throw new Error(`Server error (${response.status}): ${response.statusText}`) + } const error = new Error(errorResponse.error || 'Failed to start execution') if (errorResponse && typeof errorResponse === 'object') { Object.assign(error, { executionResult: errorResponse }) From 28fbd0c0861420b33a55397c454aeb3b4959981b Mon Sep 17 00:00:00 2001 From: Siddharth Ganesan Date: Tue, 27 Jan 2026 18:25:12 -0800 Subject: [PATCH 27/33] Fix --- .../sim/executor/utils/run-from-block.test.ts | 10 +++++----- apps/sim/executor/utils/run-from-block.ts | 19 +++++++++++-------- 2 files changed, 16 insertions(+), 13 deletions(-) diff --git a/apps/sim/executor/utils/run-from-block.test.ts b/apps/sim/executor/utils/run-from-block.test.ts index 528b44defb..082f7f54f1 100644 --- a/apps/sim/executor/utils/run-from-block.test.ts +++ b/apps/sim/executor/utils/run-from-block.test.ts @@ -331,9 +331,10 @@ describe('validateRunFromBlock', () => { expect(result.error).toContain('Upstream dependency not executed') }) - it('rejects blocks with unexecuted transitive upstream dependencies', () => { + it('allows running from block when immediate predecessor was executed (ignores transitive)', () => { // A → X → B → C, where X is new (not executed) - // Running from C should fail because X in upstream chain wasn't executed + // Running from C is allowed because B (immediate predecessor) was executed + // C will use B's cached output - doesn't matter that X is new const dag = createDAG([ createNode('A', [{ target: 'X' }]), createNode('X', [{ target: 'B' }]), @@ -344,9 +345,8 @@ describe('validateRunFromBlock', () => { const result = validateRunFromBlock('C', dag, executedBlocks) - expect(result.valid).toBe(false) - expect(result.error).toContain('Upstream dependency not executed') - expect(result.error).toContain('X') + // Valid because C's immediate predecessor B was executed + expect(result.valid).toBe(true) }) it('allows blocks with no dependencies even if not previously executed', () => { diff --git a/apps/sim/executor/utils/run-from-block.ts b/apps/sim/executor/utils/run-from-block.ts index dbe2e3fcd9..0f4bf62b8c 100644 --- a/apps/sim/executor/utils/run-from-block.ts +++ b/apps/sim/executor/utils/run-from-block.ts @@ -169,15 +169,18 @@ export function validateRunFromBlock( if (node.metadata.isSentinel) { return { valid: false, error: 'Cannot run from sentinel node' } } - } - // Check that ALL upstream blocks were executed (transitive check) - const { upstreamSet } = computeExecutionSets(dag, blockId) - for (const upstreamId of upstreamSet) { - if (!executedBlocks.has(upstreamId)) { - return { - valid: false, - error: `Upstream dependency not executed: ${upstreamId}`, + // Check immediate upstream dependencies were executed + for (const sourceId of node.incomingEdges) { + const sourceNode = dag.nodes.get(sourceId) + // Skip sentinel nodes - they're internal and not in executedBlocks + if (sourceNode?.metadata.isSentinel) continue + + if (!executedBlocks.has(sourceId)) { + return { + valid: false, + error: `Upstream dependency not executed: ${sourceId}`, + } } } } From 0ead5aa04ed1e5a39a661160606b1648bb3173f4 Mon Sep 17 00:00:00 2001 From: Siddharth Ganesan Date: Tue, 27 Jan 2026 18:35:33 -0800 Subject: [PATCH 28/33] Fix --- .../components/block-menu/block-menu.tsx | 32 ++++++++------- .../[workspaceId]/w/[workflowId]/workflow.tsx | 14 +++++++ apps/sim/executor/execution/engine.ts | 11 ++++-- apps/sim/executor/execution/executor.ts | 39 ++++++++++++++++++- 4 files changed, 78 insertions(+), 18 deletions(-) diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/block-menu/block-menu.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/block-menu/block-menu.tsx index dfeb6b0bd9..d28f30d63a 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/block-menu/block-menu.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/block-menu/block-menu.tsx @@ -91,6 +91,9 @@ export function BlockMenu({ const allNoteBlocks = selectedBlocks.every((b) => b.type === 'note') const isSubflow = isSingleBlock && (selectedBlocks[0]?.type === 'loop' || selectedBlocks[0]?.type === 'parallel') + const isInsideSubflow = + isSingleBlock && + (selectedBlocks[0]?.parentType === 'loop' || selectedBlocks[0]?.parentType === 'parallel') const canRemoveFromSubflow = showRemoveFromSubflow && !hasTriggerBlock @@ -212,8 +215,8 @@ export function BlockMenu({ )} - {/* Run from/until block - only for single non-note block selection */} - {isSingleBlock && !allNoteBlocks && ( + {/* Run from/until block - only for single non-note block, not inside subflows */} + {isSingleBlock && !allNoteBlocks && !isInsideSubflow && ( <> Run from block - { - if (!isExecuting) { - onRunUntilBlock?.() - onClose() - } - }} - > - Run until block - + {/* Hide "Run until" for triggers - they're always at the start */} + {!hasTriggerBlock && ( + { + if (!isExecuting) { + onRunUntilBlock?.() + onClose() + } + }} + > + Run until block + + )} )} diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/workflow.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/workflow.tsx index b1a9ef2df6..d27e41815e 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/workflow.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/workflow.tsx @@ -1128,8 +1128,22 @@ const WorkflowContent = React.memo(() => { const snapshot = getLastExecutionSnapshot(workflowIdParam) const incomingEdges = edges.filter((edge) => edge.target === block.id) const isTriggerBlock = incomingEdges.length === 0 + const isSubflow = block.type === 'loop' || block.type === 'parallel' + + // For subflows, check if the sentinel-end was executed (meaning the subflow completed at least once) + // Sentinel IDs follow the pattern: loop-{id}-sentinel-end or parallel-{id}-sentinel-end + const subflowWasExecuted = + isSubflow && + snapshot && + snapshot.executedBlocks.some( + (executedId) => + executedId === `loop-${block.id}-sentinel-end` || + executedId === `parallel-${block.id}-sentinel-end` + ) + const dependenciesSatisfied = isTriggerBlock || + subflowWasExecuted || (snapshot && incomingEdges.every((edge) => snapshot.executedBlocks.includes(edge.source))) const isNoteBlock = block.type === 'note' const isInsideSubflow = diff --git a/apps/sim/executor/execution/engine.ts b/apps/sim/executor/execution/engine.ts index 0c6b6a1e59..94c7e37a97 100644 --- a/apps/sim/executor/execution/engine.ts +++ b/apps/sim/executor/execution/engine.ts @@ -397,9 +397,14 @@ export class ExecutionEngine { } if (this.context.stopAfterBlockId === nodeId) { - logger.info('Stopping execution after target block', { nodeId }) - this.stoppedEarlyFlag = true - return + // For loop/parallel sentinels, only stop if the subflow has fully exited (all iterations done) + // shouldContinue: true means more iterations, shouldExit: true means loop is done + const shouldContinueLoop = output.shouldContinue === true + if (!shouldContinueLoop) { + logger.info('Stopping execution after target block', { nodeId }) + this.stoppedEarlyFlag = true + return + } } const readyNodes = this.edgeManager.processOutgoingEdges(node, output, false) diff --git a/apps/sim/executor/execution/executor.ts b/apps/sim/executor/execution/executor.ts index 572ea483b1..8cd699d9b4 100644 --- a/apps/sim/executor/execution/executor.ts +++ b/apps/sim/executor/execution/executor.ts @@ -26,6 +26,10 @@ import { buildStartBlockOutput, resolveExecutorStartBlock, } from '@/executor/utils/start-block' +import { + extractLoopIdFromSentinel, + extractParallelIdFromSentinel, +} from '@/executor/utils/subflow-utils' import { VariableResolver } from '@/executor/variables/resolver' import type { SerializedWorkflow } from '@/serializer/types' @@ -119,19 +123,50 @@ export class DAGExecutor { const { dirtySet, upstreamSet } = computeExecutionSets(dag, startBlockId) const effectiveStartBlockId = resolveContainerToSentinelStart(startBlockId, dag) ?? startBlockId + // Extract container IDs from sentinel IDs in upstream set + const upstreamContainerIds = new Set() + for (const nodeId of upstreamSet) { + const loopId = extractLoopIdFromSentinel(nodeId) + if (loopId) upstreamContainerIds.add(loopId) + const parallelId = extractParallelIdFromSentinel(nodeId) + if (parallelId) upstreamContainerIds.add(parallelId) + } + // Filter snapshot to only include upstream blocks - prevents references to non-upstream blocks const filteredBlockStates: Record = {} for (const [blockId, state] of Object.entries(sourceSnapshot.blockStates)) { - if (upstreamSet.has(blockId)) { + if (upstreamSet.has(blockId) || upstreamContainerIds.has(blockId)) { filteredBlockStates[blockId] = state } } - const filteredExecutedBlocks = sourceSnapshot.executedBlocks.filter((id) => upstreamSet.has(id)) + const filteredExecutedBlocks = sourceSnapshot.executedBlocks.filter( + (id) => upstreamSet.has(id) || upstreamContainerIds.has(id) + ) + + // Filter loop/parallel executions to only include upstream containers + const filteredLoopExecutions: Record = {} + if (sourceSnapshot.loopExecutions) { + for (const [loopId, execution] of Object.entries(sourceSnapshot.loopExecutions)) { + if (upstreamContainerIds.has(loopId)) { + filteredLoopExecutions[loopId] = execution + } + } + } + const filteredParallelExecutions: Record = {} + if (sourceSnapshot.parallelExecutions) { + for (const [parallelId, execution] of Object.entries(sourceSnapshot.parallelExecutions)) { + if (upstreamContainerIds.has(parallelId)) { + filteredParallelExecutions[parallelId] = execution + } + } + } const filteredSnapshot: SerializableExecutionState = { ...sourceSnapshot, blockStates: filteredBlockStates, executedBlocks: filteredExecutedBlocks, + loopExecutions: filteredLoopExecutions, + parallelExecutions: filteredParallelExecutions, } logger.info('Executing from block', { From a9f271cdb03efa1d90baa997ce6d641eaf7a9f94 Mon Sep 17 00:00:00 2001 From: Siddharth Ganesan Date: Tue, 27 Jan 2026 20:04:53 -0800 Subject: [PATCH 29/33] I think it works?? --- .../components/action-bar/action-bar.tsx | 15 +++++++--- .../components/block-menu/block-menu.tsx | 7 ++++- .../hooks/use-workflow-execution.ts | 25 +++++++++++++++-- .../[workspaceId]/w/[workflowId]/workflow.tsx | 28 +++++++++---------- apps/sim/executor/dag/builder.ts | 19 +++++++++---- apps/sim/executor/dag/construction/paths.ts | 11 +++++++- apps/sim/executor/execution/executor.ts | 10 +++++-- apps/sim/executor/utils/run-from-block.ts | 4 +++ 8 files changed, 86 insertions(+), 33 deletions(-) diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/action-bar/action-bar.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/action-bar/action-bar.tsx index e016918a15..dddeb126a2 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/action-bar/action-bar.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/action-bar/action-bar.tsx @@ -114,9 +114,17 @@ export const ActionBar = memo( const snapshot = activeWorkflowId ? getLastExecutionSnapshot(activeWorkflowId) : null const incomingEdges = edges.filter((edge) => edge.target === blockId) const isTriggerBlock = incomingEdges.length === 0 + + // Check if each source block is either executed OR is a trigger block (triggers don't need prior execution) + const isSourceSatisfied = (sourceId: string) => { + if (snapshot?.executedBlocks.includes(sourceId)) return true + // Check if source is a trigger (has no incoming edges itself) + const sourceIncomingEdges = edges.filter((edge) => edge.target === sourceId) + return sourceIncomingEdges.length === 0 + } + const dependenciesSatisfied = - isTriggerBlock || - (snapshot && incomingEdges.every((edge) => snapshot.executedBlocks.includes(edge.source))) + isTriggerBlock || incomingEdges.every((edge) => isSourceSatisfied(edge.source)) const canRunFromBlock = dependenciesSatisfied && !isNoteBlock && !isInsideSubflow && !isExecuting @@ -149,7 +157,7 @@ export const ActionBar = memo( 'dark:border-transparent dark:bg-[var(--surface-4)]' )} > - {!isNoteBlock && ( + {!isNoteBlock && !isInsideSubflow && (