Skip to content

Commit 655fe4f

Browse files
Sg312icecrasher321
andauthored
feat(executor): run from/until block (#3029)
* Run from block * Fixes * Fix * Fix * Minor improvements * Fix * Fix trace spans * Fix loop l ogs * Change ordering * Run u ntil block * Lint * Clean up * Fix * Allow run from block for triggers * Consolidation * Fix lint * Fix * Fix mock payload * Fix * Fix trigger clear snapshot * Fix loops and parallels * Fix * Cleanup * Fix test * Fix bugs * Catch error * Fix * Fix * I think it works?? * Fix * Fix * Add tests * Fix lint --------- Co-authored-by: Vikhyath Mondreti <vikhyath@simstudio.ai>
1 parent 72a2f79 commit 655fe4f

File tree

22 files changed

+3143
-136
lines changed

22 files changed

+3143
-136
lines changed
Lines changed: 216 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,216 @@
1+
import { db, workflow as workflowTable } from '@sim/db'
2+
import { createLogger } from '@sim/logger'
3+
import { eq } from 'drizzle-orm'
4+
import { type NextRequest, NextResponse } from 'next/server'
5+
import { v4 as uuidv4 } from 'uuid'
6+
import { z } from 'zod'
7+
import { checkHybridAuth } from '@/lib/auth/hybrid'
8+
import { generateRequestId } from '@/lib/core/utils/request'
9+
import { SSE_HEADERS } from '@/lib/core/utils/sse'
10+
import { markExecutionCancelled } from '@/lib/execution/cancellation'
11+
import { LoggingSession } from '@/lib/logs/execution/logging-session'
12+
import { executeWorkflowCore } from '@/lib/workflows/executor/execution-core'
13+
import { createSSECallbacks } from '@/lib/workflows/executor/execution-events'
14+
import { ExecutionSnapshot } from '@/executor/execution/snapshot'
15+
import type { ExecutionMetadata, SerializableExecutionState } from '@/executor/execution/types'
16+
import { hasExecutionResult } from '@/executor/utils/errors'
17+
18+
const logger = createLogger('ExecuteFromBlockAPI')
19+
20+
const ExecuteFromBlockSchema = z.object({
21+
startBlockId: z.string().min(1, 'Start block ID is required'),
22+
sourceSnapshot: z.object({
23+
blockStates: z.record(z.any()),
24+
executedBlocks: z.array(z.string()),
25+
blockLogs: z.array(z.any()),
26+
decisions: z.object({
27+
router: z.record(z.string()),
28+
condition: z.record(z.string()),
29+
}),
30+
completedLoops: z.array(z.string()),
31+
loopExecutions: z.record(z.any()).optional(),
32+
parallelExecutions: z.record(z.any()).optional(),
33+
parallelBlockMapping: z.record(z.any()).optional(),
34+
activeExecutionPath: z.array(z.string()),
35+
}),
36+
input: z.any().optional(),
37+
})
38+
39+
export const runtime = 'nodejs'
40+
export const dynamic = 'force-dynamic'
41+
42+
export async function POST(req: NextRequest, { params }: { params: Promise<{ id: string }> }) {
43+
const requestId = generateRequestId()
44+
const { id: workflowId } = await params
45+
46+
try {
47+
const auth = await checkHybridAuth(req, { requireWorkflowId: false })
48+
if (!auth.success || !auth.userId) {
49+
return NextResponse.json({ error: auth.error || 'Unauthorized' }, { status: 401 })
50+
}
51+
const userId = auth.userId
52+
53+
let body: unknown
54+
try {
55+
body = await req.json()
56+
} catch {
57+
return NextResponse.json({ error: 'Invalid JSON body' }, { status: 400 })
58+
}
59+
60+
const validation = ExecuteFromBlockSchema.safeParse(body)
61+
if (!validation.success) {
62+
logger.warn(`[${requestId}] Invalid request body:`, validation.error.errors)
63+
return NextResponse.json(
64+
{
65+
error: 'Invalid request body',
66+
details: validation.error.errors.map((e) => ({
67+
path: e.path.join('.'),
68+
message: e.message,
69+
})),
70+
},
71+
{ status: 400 }
72+
)
73+
}
74+
75+
const { startBlockId, sourceSnapshot, input } = validation.data
76+
const executionId = uuidv4()
77+
78+
const [workflowRecord] = await db
79+
.select({ workspaceId: workflowTable.workspaceId, userId: workflowTable.userId })
80+
.from(workflowTable)
81+
.where(eq(workflowTable.id, workflowId))
82+
.limit(1)
83+
84+
if (!workflowRecord?.workspaceId) {
85+
return NextResponse.json({ error: 'Workflow not found or has no workspace' }, { status: 404 })
86+
}
87+
88+
const workspaceId = workflowRecord.workspaceId
89+
const workflowUserId = workflowRecord.userId
90+
91+
logger.info(`[${requestId}] Starting run-from-block execution`, {
92+
workflowId,
93+
startBlockId,
94+
executedBlocksCount: sourceSnapshot.executedBlocks.length,
95+
})
96+
97+
const loggingSession = new LoggingSession(workflowId, executionId, 'manual', requestId)
98+
const abortController = new AbortController()
99+
let isStreamClosed = false
100+
101+
const stream = new ReadableStream<Uint8Array>({
102+
async start(controller) {
103+
const { sendEvent, onBlockStart, onBlockComplete, onStream } = createSSECallbacks({
104+
executionId,
105+
workflowId,
106+
controller,
107+
isStreamClosed: () => isStreamClosed,
108+
setStreamClosed: () => {
109+
isStreamClosed = true
110+
},
111+
})
112+
113+
const metadata: ExecutionMetadata = {
114+
requestId,
115+
workflowId,
116+
userId,
117+
executionId,
118+
triggerType: 'manual',
119+
workspaceId,
120+
workflowUserId,
121+
useDraftState: true,
122+
isClientSession: true,
123+
startTime: new Date().toISOString(),
124+
}
125+
126+
const snapshot = new ExecutionSnapshot(metadata, {}, input || {}, {})
127+
128+
try {
129+
const startTime = new Date()
130+
131+
sendEvent({
132+
type: 'execution:started',
133+
timestamp: startTime.toISOString(),
134+
executionId,
135+
workflowId,
136+
data: { startTime: startTime.toISOString() },
137+
})
138+
139+
const result = await executeWorkflowCore({
140+
snapshot,
141+
loggingSession,
142+
abortSignal: abortController.signal,
143+
runFromBlock: {
144+
startBlockId,
145+
sourceSnapshot: sourceSnapshot as SerializableExecutionState,
146+
},
147+
callbacks: { onBlockStart, onBlockComplete, onStream },
148+
})
149+
150+
if (result.status === 'cancelled') {
151+
sendEvent({
152+
type: 'execution:cancelled',
153+
timestamp: new Date().toISOString(),
154+
executionId,
155+
workflowId,
156+
data: { duration: result.metadata?.duration || 0 },
157+
})
158+
} else {
159+
sendEvent({
160+
type: 'execution:completed',
161+
timestamp: new Date().toISOString(),
162+
executionId,
163+
workflowId,
164+
data: {
165+
success: result.success,
166+
output: result.output,
167+
duration: result.metadata?.duration || 0,
168+
startTime: result.metadata?.startTime || startTime.toISOString(),
169+
endTime: result.metadata?.endTime || new Date().toISOString(),
170+
},
171+
})
172+
}
173+
} catch (error: unknown) {
174+
const errorMessage = error instanceof Error ? error.message : 'Unknown error'
175+
logger.error(`[${requestId}] Run-from-block execution failed: ${errorMessage}`)
176+
177+
const executionResult = hasExecutionResult(error) ? error.executionResult : undefined
178+
179+
sendEvent({
180+
type: 'execution:error',
181+
timestamp: new Date().toISOString(),
182+
executionId,
183+
workflowId,
184+
data: {
185+
error: executionResult?.error || errorMessage,
186+
duration: executionResult?.metadata?.duration || 0,
187+
},
188+
})
189+
} finally {
190+
if (!isStreamClosed) {
191+
try {
192+
controller.enqueue(new TextEncoder().encode('data: [DONE]\n\n'))
193+
controller.close()
194+
} catch {}
195+
}
196+
}
197+
},
198+
cancel() {
199+
isStreamClosed = true
200+
abortController.abort()
201+
markExecutionCancelled(executionId).catch(() => {})
202+
},
203+
})
204+
205+
return new NextResponse(stream, {
206+
headers: { ...SSE_HEADERS, 'X-Execution-Id': executionId },
207+
})
208+
} catch (error: unknown) {
209+
const errorMessage = error instanceof Error ? error.message : 'Unknown error'
210+
logger.error(`[${requestId}] Failed to start run-from-block execution:`, error)
211+
return NextResponse.json(
212+
{ error: errorMessage || 'Failed to start execution' },
213+
{ status: 500 }
214+
)
215+
}
216+
}

apps/sim/app/api/workflows/[id]/execute/route.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,7 @@ const ExecuteWorkflowSchema = z.object({
5353
parallels: z.record(z.any()).optional(),
5454
})
5555
.optional(),
56+
stopAfterBlockId: z.string().optional(),
5657
})
5758

5859
export const runtime = 'nodejs'
@@ -222,6 +223,7 @@ export async function POST(req: NextRequest, { params }: { params: Promise<{ id:
222223
includeFileBase64,
223224
base64MaxBytes,
224225
workflowStateOverride,
226+
stopAfterBlockId,
225227
} = validation.data
226228

227229
// 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:
237239
includeFileBase64,
238240
base64MaxBytes,
239241
workflowStateOverride,
242+
stopAfterBlockId: _stopAfterBlockId,
240243
workflowId: _workflowId, // Also exclude workflowId used for internal JWT auth
241244
...rest
242245
} = body
@@ -434,6 +437,7 @@ export async function POST(req: NextRequest, { params }: { params: Promise<{ id:
434437
loggingSession,
435438
includeFileBase64,
436439
base64MaxBytes,
440+
stopAfterBlockId,
437441
})
438442

439443
const outputWithBase64 = includeFileBase64
@@ -722,6 +726,7 @@ export async function POST(req: NextRequest, { params }: { params: Promise<{ id:
722726
abortSignal: abortController.signal,
723727
includeFileBase64,
724728
base64MaxBytes,
729+
stopAfterBlockId,
725730
})
726731

727732
if (result.status === 'paused') {

apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/action-bar/action-bar.tsx

Lines changed: 43 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,13 @@
11
import { memo, useCallback } from 'react'
2-
import { ArrowLeftRight, ArrowUpDown, Circle, CircleOff, LogOut } from 'lucide-react'
2+
import { ArrowLeftRight, ArrowUpDown, Circle, CircleOff, LogOut, Play } from 'lucide-react'
33
import { Button, Copy, Tooltip, Trash2 } from '@/components/emcn'
44
import { cn } from '@/lib/core/utils/cn'
55
import { isInputDefinitionTrigger } from '@/lib/workflows/triggers/input-definition-triggers'
66
import { useUserPermissionsContext } from '@/app/workspace/[workspaceId]/providers/workspace-permissions-provider'
7+
import { useWorkflowExecution } from '@/app/workspace/[workspaceId]/w/[workflowId]/hooks'
78
import { validateTriggerPaste } from '@/app/workspace/[workspaceId]/w/[workflowId]/utils'
89
import { useCollaborativeWorkflow } from '@/hooks/use-collaborative-workflow'
10+
import { useExecutionStore } from '@/stores/execution'
911
import { useNotificationStore } from '@/stores/notifications'
1012
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
1113
import { useWorkflowStore } from '@/stores/workflows/workflow/store'
@@ -49,6 +51,7 @@ export const ActionBar = memo(
4951
collaborativeBatchToggleBlockHandles,
5052
} = useCollaborativeWorkflow()
5153
const { setPendingSelection } = useWorkflowRegistry()
54+
const { handleRunFromBlock } = useWorkflowExecution()
5255

5356
const addNotification = useNotificationStore((s) => s.addNotification)
5457

@@ -97,12 +100,39 @@ export const ActionBar = memo(
97100
)
98101
)
99102

103+
const { activeWorkflowId } = useWorkflowRegistry()
104+
const { isExecuting, getLastExecutionSnapshot } = useExecutionStore()
100105
const userPermissions = useUserPermissionsContext()
106+
const edges = useWorkflowStore((state) => state.edges)
101107

102108
const isStartBlock = isInputDefinitionTrigger(blockType)
103109
const isResponseBlock = blockType === 'response'
104110
const isNoteBlock = blockType === 'note'
105111
const isSubflowBlock = blockType === 'loop' || blockType === 'parallel'
112+
const isInsideSubflow = parentId && (parentType === 'loop' || parentType === 'parallel')
113+
114+
const snapshot = activeWorkflowId ? getLastExecutionSnapshot(activeWorkflowId) : null
115+
const incomingEdges = edges.filter((edge) => edge.target === blockId)
116+
const isTriggerBlock = incomingEdges.length === 0
117+
118+
// Check if each source block is either executed OR is a trigger block (triggers don't need prior execution)
119+
const isSourceSatisfied = (sourceId: string) => {
120+
if (snapshot?.executedBlocks.includes(sourceId)) return true
121+
// Check if source is a trigger (has no incoming edges itself)
122+
const sourceIncomingEdges = edges.filter((edge) => edge.target === sourceId)
123+
return sourceIncomingEdges.length === 0
124+
}
125+
126+
// Non-trigger blocks need a snapshot to exist (so upstream outputs are available)
127+
const dependenciesSatisfied =
128+
isTriggerBlock || (snapshot && incomingEdges.every((edge) => isSourceSatisfied(edge.source)))
129+
const canRunFromBlock =
130+
dependenciesSatisfied && !isNoteBlock && !isInsideSubflow && !isExecuting
131+
132+
const handleRunFromBlockClick = useCallback(() => {
133+
if (!activeWorkflowId || !canRunFromBlock) return
134+
handleRunFromBlock(blockId, activeWorkflowId)
135+
}, [blockId, activeWorkflowId, canRunFromBlock, handleRunFromBlock])
106136

107137
/**
108138
* Get appropriate tooltip message based on disabled state
@@ -128,30 +158,35 @@ export const ActionBar = memo(
128158
'dark:border-transparent dark:bg-[var(--surface-4)]'
129159
)}
130160
>
131-
{!isNoteBlock && (
161+
{!isNoteBlock && !isInsideSubflow && (
132162
<Tooltip.Root>
133163
<Tooltip.Trigger asChild>
134164
<Button
135165
variant='ghost'
136166
onClick={(e) => {
137167
e.stopPropagation()
138-
if (!disabled) {
139-
collaborativeBatchToggleBlockEnabled([blockId])
168+
if (canRunFromBlock && !disabled) {
169+
handleRunFromBlockClick()
140170
}
141171
}}
142172
className={ACTION_BUTTON_STYLES}
143-
disabled={disabled}
173+
disabled={disabled || !canRunFromBlock}
144174
>
145-
{isEnabled ? <Circle className={ICON_SIZE} /> : <CircleOff className={ICON_SIZE} />}
175+
<Play className={ICON_SIZE} />
146176
</Button>
147177
</Tooltip.Trigger>
148178
<Tooltip.Content side='top'>
149-
{getTooltipMessage(isEnabled ? 'Disable Block' : 'Enable Block')}
179+
{(() => {
180+
if (disabled) return getTooltipMessage('Run from block')
181+
if (isExecuting) return 'Execution in progress'
182+
if (!dependenciesSatisfied) return 'Run upstream blocks first'
183+
return 'Run from block'
184+
})()}
150185
</Tooltip.Content>
151186
</Tooltip.Root>
152187
)}
153188

154-
{isSubflowBlock && (
189+
{!isNoteBlock && (
155190
<Tooltip.Root>
156191
<Tooltip.Trigger asChild>
157192
<Button

0 commit comments

Comments
 (0)