Skip to content

Commit 58571fe

Browse files
waleedlatif1Sg312icecrasher321Theodore Li
authored
fix(hitl): fix stream endpoint, pause persistence, and resume page (#3995)
* Fix hitl stream * fix hitl pause persistence * Fix /stream endpoint allowing api key usage * resume page cleanup * fix type * make resume sync * fix types * address bugbot comments --------- Co-authored-by: Siddharth Ganesan <siddharthganesan@gmail.com> Co-authored-by: Vikhyath Mondreti <vikhyath@simstudio.ai> Co-authored-by: Theodore Li <teddy@zenobiapay.com>
1 parent 7e0794c commit 58571fe

File tree

15 files changed

+552
-228
lines changed

15 files changed

+552
-228
lines changed

apps/docs/content/docs/en/blocks/human-in-the-loop.mdx

Lines changed: 23 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -93,17 +93,36 @@ Access resume data in downstream blocks using `<blockId.resumeInput.fieldName>`.
9393
<Tab>
9494
### REST API
9595

96-
Programmatically resume workflows:
96+
Programmatically resume workflows using the resume endpoint. The `contextId` is available from the block's `resumeEndpoint` output or from the paused execution detail.
9797

9898
```bash
99-
POST /api/workflows/{workflowId}/executions/{executionId}/resume/{blockId}
99+
POST /api/resume/{workflowId}/{executionId}/{contextId}
100+
Content-Type: application/json
100101

101102
{
102-
"approved": true,
103-
"comments": "Looks good to proceed"
103+
"input": {
104+
"approved": true,
105+
"comments": "Looks good to proceed"
106+
}
104107
}
105108
```
106109

110+
The response includes a new `executionId` for the resumed execution:
111+
112+
```json
113+
{
114+
"status": "started",
115+
"executionId": "<resumeExecutionId>",
116+
"message": "Resume execution started."
117+
}
118+
```
119+
120+
To poll execution progress after resuming, connect to the SSE stream:
121+
122+
```bash
123+
GET /api/workflows/{workflowId}/executions/{resumeExecutionId}/stream
124+
```
125+
107126
Build custom approval UIs or integrate with existing systems.
108127
</Tab>
109128
<Tab>

apps/sim/app/api/resume/[workflowId]/[executionId]/[contextId]/route.ts

Lines changed: 32 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import { type NextRequest, NextResponse } from 'next/server'
33
import { AuthType } from '@/lib/auth/hybrid'
44
import { generateRequestId } from '@/lib/core/utils/request'
55
import { generateId } from '@/lib/core/utils/uuid'
6+
import { setExecutionMeta } from '@/lib/execution/event-buffer'
67
import { preprocessExecution } from '@/lib/execution/preprocessing'
78
import { PauseResumeManager } from '@/lib/workflows/executor/human-in-the-loop-manager'
89
import { getWorkspaceBilledAccountUserId } from '@/lib/workspaces/utils'
@@ -125,14 +126,43 @@ export async function POST(
125126
})
126127
}
127128

128-
PauseResumeManager.startResumeExecution({
129+
await setExecutionMeta(enqueueResult.resumeExecutionId, {
130+
status: 'active',
131+
userId,
132+
workflowId,
133+
})
134+
135+
const resumeArgs = {
129136
resumeEntryId: enqueueResult.resumeEntryId,
130137
resumeExecutionId: enqueueResult.resumeExecutionId,
131138
pausedExecution: enqueueResult.pausedExecution,
132139
contextId: enqueueResult.contextId,
133140
resumeInput: enqueueResult.resumeInput,
134141
userId: enqueueResult.userId,
135-
}).catch((error) => {
142+
}
143+
144+
const isApiCaller = access.auth?.authType === AuthType.API_KEY
145+
146+
if (isApiCaller) {
147+
const result = await PauseResumeManager.startResumeExecution(resumeArgs)
148+
149+
return NextResponse.json({
150+
success: result.success,
151+
status: result.status ?? (result.success ? 'completed' : 'failed'),
152+
executionId: enqueueResult.resumeExecutionId,
153+
output: result.output,
154+
error: result.error,
155+
metadata: result.metadata
156+
? {
157+
duration: result.metadata.duration,
158+
startTime: result.metadata.startTime,
159+
endTime: result.metadata.endTime,
160+
}
161+
: undefined,
162+
})
163+
}
164+
165+
PauseResumeManager.startResumeExecution(resumeArgs).catch((error) => {
136166
logger.error('Failed to start resume execution', {
137167
workflowId,
138168
parentExecutionId: executionId,

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

Lines changed: 40 additions & 45 deletions
Original file line numberDiff line numberDiff line change
@@ -41,7 +41,7 @@ import {
4141
} from '@/lib/uploads/utils/user-file-base64.server'
4242
import { executeWorkflowCore } from '@/lib/workflows/executor/execution-core'
4343
import { type ExecutionEvent, encodeSSEEvent } from '@/lib/workflows/executor/execution-events'
44-
import { PauseResumeManager } from '@/lib/workflows/executor/human-in-the-loop-manager'
44+
import { handlePostExecutionPauseState } from '@/lib/workflows/executor/pause-persistence'
4545
import {
4646
DIRECT_WORKFLOW_JOB_NAME,
4747
type QueuedWorkflowExecutionPayload,
@@ -903,6 +903,8 @@ async function handleExecutePost(
903903
abortSignal: timeoutController.signal,
904904
})
905905

906+
await handlePostExecutionPauseState({ result, workflowId, executionId, loggingSession })
907+
906908
if (
907909
result.status === 'cancelled' &&
908910
timeoutController.isTimedOut() &&
@@ -1359,31 +1361,7 @@ async function handleExecutePost(
13591361
runFromBlock: resolvedRunFromBlock,
13601362
})
13611363

1362-
if (result.status === 'paused') {
1363-
if (!result.snapshotSeed) {
1364-
reqLogger.error('Missing snapshot seed for paused execution')
1365-
await loggingSession.markAsFailed('Missing snapshot seed for paused execution')
1366-
} else {
1367-
try {
1368-
await PauseResumeManager.persistPauseResult({
1369-
workflowId,
1370-
executionId,
1371-
pausePoints: result.pausePoints || [],
1372-
snapshotSeed: result.snapshotSeed,
1373-
executorUserId: result.metadata?.userId,
1374-
})
1375-
} catch (pauseError) {
1376-
reqLogger.error('Failed to persist pause result', {
1377-
error: pauseError instanceof Error ? pauseError.message : String(pauseError),
1378-
})
1379-
await loggingSession.markAsFailed(
1380-
`Failed to persist pause state: ${pauseError instanceof Error ? pauseError.message : String(pauseError)}`
1381-
)
1382-
}
1383-
}
1384-
} else {
1385-
await PauseResumeManager.processQueuedResumes(executionId)
1386-
}
1364+
await handlePostExecutionPauseState({ result, workflowId, executionId, loggingSession })
13871365

13881366
if (result.status === 'cancelled') {
13891367
if (timeoutController.isTimedOut() && timeoutController.timeoutMs) {
@@ -1422,25 +1400,42 @@ async function handleExecutePost(
14221400
return
14231401
}
14241402

1425-
sendEvent({
1426-
type: 'execution:completed',
1427-
timestamp: new Date().toISOString(),
1428-
executionId,
1429-
workflowId,
1430-
data: {
1431-
success: result.success,
1432-
output: includeFileBase64
1433-
? await hydrateUserFilesWithBase64(result.output, {
1434-
requestId,
1435-
executionId,
1436-
maxBytes: base64MaxBytes,
1437-
})
1438-
: result.output,
1439-
duration: result.metadata?.duration || 0,
1440-
startTime: result.metadata?.startTime || startTime.toISOString(),
1441-
endTime: result.metadata?.endTime || new Date().toISOString(),
1442-
},
1443-
})
1403+
const sseOutput = includeFileBase64
1404+
? await hydrateUserFilesWithBase64(result.output, {
1405+
requestId,
1406+
executionId,
1407+
maxBytes: base64MaxBytes,
1408+
})
1409+
: result.output
1410+
1411+
if (result.status === 'paused') {
1412+
sendEvent({
1413+
type: 'execution:paused',
1414+
timestamp: new Date().toISOString(),
1415+
executionId,
1416+
workflowId,
1417+
data: {
1418+
output: sseOutput,
1419+
duration: result.metadata?.duration || 0,
1420+
startTime: result.metadata?.startTime || startTime.toISOString(),
1421+
endTime: result.metadata?.endTime || new Date().toISOString(),
1422+
},
1423+
})
1424+
} else {
1425+
sendEvent({
1426+
type: 'execution:completed',
1427+
timestamp: new Date().toISOString(),
1428+
executionId,
1429+
workflowId,
1430+
data: {
1431+
success: result.success,
1432+
output: sseOutput,
1433+
duration: result.metadata?.duration || 0,
1434+
startTime: result.metadata?.startTime || startTime.toISOString(),
1435+
endTime: result.metadata?.endTime || new Date().toISOString(),
1436+
},
1437+
})
1438+
}
14441439
finalMetaStatus = 'complete'
14451440
} catch (error: unknown) {
14461441
const isTimeout = isTimeoutError(error) || timeoutController.isTimedOut()

apps/sim/app/api/workflows/[id]/executions/[executionId]/stream/route.ts

Lines changed: 5 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import { createLogger } from '@sim/logger'
22
import { type NextRequest, NextResponse } from 'next/server'
3-
import { checkHybridAuth } from '@/lib/auth/hybrid'
3+
import { getSession } from '@/lib/auth'
44
import { SSE_HEADERS } from '@/lib/core/utils/sse'
55
import {
66
type ExecutionStreamStatus,
@@ -29,14 +29,14 @@ export async function GET(
2929
const { id: workflowId, executionId } = await params
3030

3131
try {
32-
const auth = await checkHybridAuth(req, { requireWorkflowId: false })
33-
if (!auth.success || !auth.userId) {
34-
return NextResponse.json({ error: auth.error || 'Unauthorized' }, { status: 401 })
32+
const session = await getSession()
33+
if (!session?.user?.id) {
34+
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
3535
}
3636

3737
const workflowAuthorization = await authorizeWorkflowByWorkspacePermission({
3838
workflowId,
39-
userId: auth.userId,
39+
userId: session.user.id,
4040
action: 'read',
4141
})
4242
if (!workflowAuthorization.allowed) {
@@ -46,16 +46,6 @@ export async function GET(
4646
)
4747
}
4848

49-
if (
50-
auth.apiKeyType === 'workspace' &&
51-
workflowAuthorization.workflow?.workspaceId !== auth.workspaceId
52-
) {
53-
return NextResponse.json(
54-
{ error: 'API key is not authorized for this workspace' },
55-
{ status: 403 }
56-
)
57-
}
58-
5949
const meta = await getExecutionMeta(executionId)
6050
if (!meta) {
6151
return NextResponse.json({ error: 'Execution buffer not found or expired' }, { status: 404 })

apps/sim/app/resume/[workflowId]/[executionId]/resume-page-client.tsx

Lines changed: 52 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import { useRouter } from 'next/navigation'
66
import {
77
Badge,
88
Button,
9+
Code,
910
Input,
1011
Label,
1112
Table,
@@ -155,14 +156,54 @@ function getBlockNameFromSnapshot(
155156
const parsed = JSON.parse(executionSnapshot.snapshot)
156157
const workflowState = parsed?.workflow
157158
if (!workflowState?.blocks || !Array.isArray(workflowState.blocks)) return null
158-
// Blocks are stored as an array of serialized blocks with id and metadata.name
159159
const block = workflowState.blocks.find((b: { id: string }) => b.id === blockId)
160160
return block?.metadata?.name || null
161161
} catch {
162162
return null
163163
}
164164
}
165165

166+
function renderStructuredValuePreview(value: unknown) {
167+
if (value === null || value === undefined) {
168+
return <span style={{ fontSize: '12px', color: 'var(--text-muted)' }}></span>
169+
}
170+
171+
if (typeof value === 'object') {
172+
return (
173+
<div style={{ minWidth: '220px' }}>
174+
<Code.Viewer
175+
code={JSON.stringify(value, null, 2)}
176+
language='json'
177+
wrapText
178+
className='max-h-[220px]'
179+
/>
180+
</div>
181+
)
182+
}
183+
184+
const stringValue = String(value)
185+
return (
186+
<div
187+
style={{
188+
display: 'inline-flex',
189+
maxWidth: '100%',
190+
borderRadius: '6px',
191+
border: '1px solid var(--border)',
192+
background: 'var(--surface-5)',
193+
padding: '4px 8px',
194+
whiteSpace: 'pre-wrap',
195+
wordBreak: 'break-word',
196+
fontFamily: 'var(--font-mono, monospace)',
197+
fontSize: '12px',
198+
lineHeight: '16px',
199+
color: 'var(--text-primary)',
200+
}}
201+
>
202+
{stringValue}
203+
</div>
204+
)
205+
}
206+
166207
export default function ResumeExecutionPage({
167208
params,
168209
initialExecutionDetail,
@@ -874,8 +915,11 @@ export default function ResumeExecutionPage({
874915
<Tooltip.Trigger asChild>
875916
<Button
876917
variant='outline'
918+
size='sm'
877919
onClick={refreshExecutionDetail}
878920
disabled={refreshingExecution}
921+
className='gap-1.5 px-2.5'
922+
aria-label='Refresh execution details'
879923
>
880924
<RefreshCw
881925
style={{
@@ -884,6 +928,7 @@ export default function ResumeExecutionPage({
884928
animation: refreshingExecution ? 'spin 1s linear infinite' : undefined,
885929
}}
886930
/>
931+
Refresh
887932
</Button>
888933
</Tooltip.Trigger>
889934
<Tooltip.Content>Refresh</Tooltip.Content>
@@ -1123,11 +1168,7 @@ export default function ResumeExecutionPage({
11231168
<TableRow key={row.id}>
11241169
<TableCell>{row.name}</TableCell>
11251170
<TableCell>{row.type}</TableCell>
1126-
<TableCell>
1127-
<code style={{ fontSize: '12px' }}>
1128-
{formatStructureValue(row.value)}
1129-
</code>
1130-
</TableCell>
1171+
<TableCell>{renderStructuredValuePreview(row.value)}</TableCell>
11311172
</TableRow>
11321173
))}
11331174
</TableBody>
@@ -1243,6 +1284,8 @@ export default function ResumeExecutionPage({
12431284
}}
12441285
placeholder='{"example": "value"}'
12451286
rows={6}
1287+
spellCheck={false}
1288+
className='min-h-[180px] border-[var(--border-1)] bg-[var(--surface-3)] font-mono text-[12px] leading-5'
12461289
/>
12471290
</div>
12481291
</div>
@@ -1267,10 +1310,10 @@ export default function ResumeExecutionPage({
12671310
{/* Footer */}
12681311
<div
12691312
style={{
1270-
marginTop: '32px',
1271-
padding: '16px',
1313+
maxWidth: '1200px',
1314+
margin: '24px auto 0',
1315+
padding: '0 24px 24px',
12721316
textAlign: 'center',
1273-
borderTop: '1px solid var(--border)',
12741317
fontSize: '13px',
12751318
color: 'var(--text-muted)',
12761319
}}

0 commit comments

Comments
 (0)