Skip to content

Commit 47e693c

Browse files
committed
materialize refs before sending in response block
1 parent 13944f5 commit 47e693c

4 files changed

Lines changed: 148 additions & 39 deletions

File tree

apps/sim/app/api/workflows/[id]/execute/response-block.test.ts

Lines changed: 58 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -8,16 +8,22 @@
88
import { beforeEach, describe, expect, it, vi } from 'vitest'
99
import { AuthType } from '@/lib/auth/hybrid'
1010
import { clearLargeValueCacheForTests } from '@/lib/execution/payloads/cache'
11-
import { isLargeArrayManifest } from '@/lib/execution/payloads/large-array-manifest-metadata'
12-
import { isLargeValueRef } from '@/lib/execution/payloads/large-value-ref'
1311
import { compactExecutionPayload } from '@/lib/execution/payloads/serializer'
12+
import { EXECUTION_RESOURCE_LIMIT_CODE } from '@/lib/execution/resource-errors'
1413
import type { ExecutionResult } from '@/lib/workflows/types'
1514
import { createHttpResponseFromBlock, workflowHasResponseBlock } from '@/lib/workflows/utils'
1615

1716
const { mockUploadFile } = vi.hoisted(() => ({
1817
mockUploadFile: vi.fn(),
1918
}))
2019

20+
const MATERIALIZATION_CONTEXT = {
21+
workspaceId: 'workspace-1',
22+
workflowId: 'workflow-1',
23+
executionId: 'execution-1',
24+
userId: 'user-1',
25+
}
26+
2127
vi.mock('@/lib/uploads', () => ({
2228
StorageService: {
2329
uploadFile: mockUploadFile,
@@ -92,14 +98,14 @@ describe('Response block gating by auth type', () => {
9298
expect(shouldFormatAsResponseBlock).toBe(false)
9399
})
94100

95-
it('should apply Response block formatting for API key callers', () => {
101+
it('should apply Response block formatting for API key callers', async () => {
96102
const authType = AuthType.API_KEY
97103
const hasResponseBlock = workflowHasResponseBlock(resultWithResponseBlock)
98104

99105
const shouldFormatAsResponseBlock = authType !== AuthType.INTERNAL_JWT && hasResponseBlock
100106
expect(shouldFormatAsResponseBlock).toBe(true)
101107

102-
const response = createHttpResponseFromBlock(resultWithResponseBlock)
108+
const response = await createHttpResponseFromBlock(resultWithResponseBlock)
103109
expect(response.status).toBe(200)
104110
})
105111

@@ -112,7 +118,7 @@ describe('Response block gating by auth type', () => {
112118
})
113119

114120
it('should return raw user data via createHttpResponseFromBlock', async () => {
115-
const response = createHttpResponseFromBlock(resultWithResponseBlock)
121+
const response = await createHttpResponseFromBlock(resultWithResponseBlock)
116122
const body = await response.json()
117123

118124
// Response block returns the user-defined data directly (no success/executionId wrapper)
@@ -121,66 +127,88 @@ describe('Response block gating by auth type', () => {
121127
expect(body.executionId).toBeUndefined()
122128
})
123129

124-
it('should respect custom status codes from Response block', () => {
130+
it('should respect custom status codes from Response block', async () => {
125131
const result = buildExecutionResult({
126132
output: { data: { error: 'Not found' }, status: 404, headers: {} },
127133
})
128134

129-
const response = createHttpResponseFromBlock(result)
135+
const response = await createHttpResponseFromBlock(result)
130136
expect(response.status).toBe(404)
131137
})
132138

133-
it('should return manifest metadata directly for Response block data', async () => {
139+
it('should materialize manifest data for Response block HTTP output', async () => {
140+
const rows = Array.from({ length: 100 }, (_, index) => ({
141+
key: `SIM-${index}`,
142+
payload: 'x'.repeat(100),
143+
}))
134144
const output = await compactExecutionPayload(
135145
{
136-
data: {
137-
rows: Array.from({ length: 120_000 }, (_, index) => ({
138-
key: `SIM-${index}`,
139-
payload: 'x'.repeat(100),
140-
})),
141-
},
146+
data: { rows },
142147
status: 200,
143148
headers: {},
144149
},
145150
{
146-
workspaceId: 'workspace-1',
147-
workflowId: 'workflow-1',
148-
executionId: 'execution-1',
149-
userId: 'user-1',
151+
...MATERIALIZATION_CONTEXT,
150152
requireDurable: true,
151153
preserveRoot: true,
154+
thresholdBytes: 1024,
152155
}
153156
)
154-
const response = createHttpResponseFromBlock(buildExecutionResult({ output }))
157+
const response = await createHttpResponseFromBlock(
158+
buildExecutionResult({ output }),
159+
MATERIALIZATION_CONTEXT
160+
)
155161
const body = await response.json()
156162

157163
expect(response.status).toBe(200)
158-
expect(isLargeArrayManifest(body.rows)).toBe(true)
164+
expect(body.rows).toEqual(rows)
159165
expect(body.success).toBeUndefined()
160166
})
161167

162-
it('should keep large string Response block data bounded as a generic ref', async () => {
168+
it('should materialize large string refs for Response block HTTP output', async () => {
169+
const text = 'x'.repeat(9 * 1024 * 1024)
163170
const output = await compactExecutionPayload(
164171
{
165-
data: {
166-
text: 'x'.repeat(9 * 1024 * 1024),
167-
},
172+
data: { text },
168173
status: 200,
169174
headers: {},
170175
},
171176
{
172-
workspaceId: 'workspace-1',
173-
workflowId: 'workflow-1',
174-
executionId: 'execution-1',
175-
userId: 'user-1',
177+
...MATERIALIZATION_CONTEXT,
176178
requireDurable: true,
177179
preserveRoot: true,
178180
}
179181
)
180-
const response = createHttpResponseFromBlock(buildExecutionResult({ output }))
182+
const response = await createHttpResponseFromBlock(
183+
buildExecutionResult({ output }),
184+
MATERIALIZATION_CONTEXT
185+
)
181186
const body = await response.json()
182187

183188
expect(response.status).toBe(200)
184-
expect(isLargeValueRef(body.text)).toBe(true)
189+
expect(body.text).toBe(text)
190+
})
191+
192+
it('should reject Response block HTTP output that is too large to inline', async () => {
193+
const output = await compactExecutionPayload(
194+
{
195+
data: {
196+
text: 'x'.repeat(17 * 1024 * 1024),
197+
},
198+
status: 200,
199+
headers: {},
200+
},
201+
{
202+
...MATERIALIZATION_CONTEXT,
203+
requireDurable: true,
204+
preserveRoot: true,
205+
}
206+
)
207+
208+
await expect(
209+
createHttpResponseFromBlock(buildExecutionResult({ output }), MATERIALIZATION_CONTEXT)
210+
).rejects.toMatchObject({
211+
code: EXECUTION_RESOURCE_LIMIT_CODE,
212+
})
185213
})
186214
})

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

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -794,7 +794,16 @@ async function handleExecutePost(
794794
preserveUserFileBase64: true,
795795
preserveRoot: true,
796796
})
797-
return createHttpResponseFromBlock({ ...result, output: compactResponseBlockOutput })
797+
return await createHttpResponseFromBlock(
798+
{ ...result, output: compactResponseBlockOutput },
799+
{
800+
workspaceId,
801+
workflowId,
802+
executionId,
803+
userId: actorUserId,
804+
allowLargeValueWorkflowScope: Boolean(resolvedRunFromBlock?.sourceSnapshot),
805+
}
806+
)
798807
}
799808

800809
const compactOutput = await compactRoutePayload(outputWithBase64, {

apps/sim/lib/workflows/utils.test.ts

Lines changed: 15 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -242,14 +242,25 @@ describe('validateWorkflowPermissions', () => {
242242
})
243243

244244
describe('createHttpResponseFromBlock', () => {
245-
it('returns large refs as response metadata without sync materialization', async () => {
246-
const response = createHttpResponseFromBlock({
245+
it('rejects large refs that cannot be materialized for HTTP response output', async () => {
246+
await expect(
247+
createHttpResponseFromBlock({
248+
output: {
249+
data: { issues: largeValueRef },
250+
status: 200,
251+
},
252+
} as any)
253+
).rejects.toThrow('This execution value is too large to inline')
254+
})
255+
256+
it('returns raw response data when no large execution values are present', async () => {
257+
const response = await createHttpResponseFromBlock({
247258
output: {
248-
data: { issues: largeValueRef },
259+
data: { issues: [] },
249260
status: 200,
250261
},
251262
} as any)
252263

253-
await expect(response.json()).resolves.toEqual({ issues: largeValueRef })
264+
await expect(response.json()).resolves.toEqual({ issues: [] })
254265
})
255266
})

apps/sim/lib/workflows/utils.ts

Lines changed: 65 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,19 @@ import { authorizeWorkflowByWorkspacePermission } from '@sim/workflow-authz'
66
import { and, asc, eq, inArray, isNull, max, min, sql } from 'drizzle-orm'
77
import { NextResponse } from 'next/server'
88
import { getSession } from '@/lib/auth'
9+
import {
10+
isLargeArrayManifest,
11+
materializeLargeArrayManifest,
12+
} from '@/lib/execution/payloads/large-array-manifest'
13+
import {
14+
getLargeValueMaterializationError,
15+
isLargeValueRef,
16+
} from '@/lib/execution/payloads/large-value-ref'
17+
import {
18+
type ExecutionMaterializationContext,
19+
MAX_INLINE_MATERIALIZATION_BYTES,
20+
} from '@/lib/execution/payloads/materialization.server'
21+
import { materializeLargeValueRef } from '@/lib/execution/payloads/store'
922
import { getNextWorkflowColor } from '@/lib/workflows/colors'
1023
import { buildDefaultWorkflowArtifacts } from '@/lib/workflows/defaults'
1124
import { saveWorkflowToNormalizedTables } from '@/lib/workflows/persistence/utils'
@@ -315,17 +328,65 @@ export const workflowHasResponseBlock = (
315328
return responseBlock !== undefined
316329
}
317330

318-
export const createHttpResponseFromBlock = (
319-
executionResult: Pick<ExecutionResult, 'output'>
320-
): NextResponse => {
331+
async function materializeHttpResponseValue(
332+
value: unknown,
333+
context: ExecutionMaterializationContext | undefined,
334+
seen = new WeakSet<object>()
335+
): Promise<unknown> {
336+
if (isLargeArrayManifest(value)) {
337+
return materializeLargeArrayManifest(value, {
338+
...context,
339+
maxBytes: MAX_INLINE_MATERIALIZATION_BYTES,
340+
})
341+
}
342+
343+
if (isLargeValueRef(value)) {
344+
const materialized = await materializeLargeValueRef(value, {
345+
...context,
346+
maxBytes: MAX_INLINE_MATERIALIZATION_BYTES,
347+
})
348+
if (materialized === undefined) {
349+
throw getLargeValueMaterializationError(value)
350+
}
351+
return materializeHttpResponseValue(materialized, context, seen)
352+
}
353+
354+
if (!value || typeof value !== 'object') {
355+
return value
356+
}
357+
358+
if (seen.has(value)) {
359+
return value
360+
}
361+
seen.add(value)
362+
363+
if (Array.isArray(value)) {
364+
return Promise.all(value.map((item) => materializeHttpResponseValue(item, context, seen)))
365+
}
366+
367+
return Object.fromEntries(
368+
await Promise.all(
369+
Object.entries(value as Record<string, unknown>).map(async ([key, entryValue]) => [
370+
key,
371+
await materializeHttpResponseValue(entryValue, context, seen),
372+
])
373+
)
374+
)
375+
}
376+
377+
export const createHttpResponseFromBlock = async (
378+
executionResult: Pick<ExecutionResult, 'output'>,
379+
context?: ExecutionMaterializationContext
380+
): Promise<NextResponse> => {
321381
const { data = {}, status = 200, headers = {} } = executionResult.output
382+
const responseData = await materializeHttpResponseValue(data, context)
322383

323384
const responseHeaders = new Headers({
324385
'Content-Type': 'application/json',
325386
...headers,
326387
})
327388

328-
return NextResponse.json(data, {
389+
return NextResponse.json(responseData, {
329390
status: status,
330391
headers: responseHeaders,
331392
})

0 commit comments

Comments
 (0)